Files
UHD_Media_Tool/apps/server/test/file-manager/organize-episode-structure.test.ts
T
rootandClaude Opus 5.5 63e94b308e feat(identify-words): 识别词——全局识别词(MP 语法)+ 作品识别词(季集换算),规则页「识别词」页签
- 新表 identify_words(0094):全局一块 + 每部作品一块原文,带版本与审计;episode_mapping_rule 转成文本行后删表
- 全局识别词移植 MP WordsMatcher(屏蔽 / 替换 / 集数偏移 / 替换+偏移 / {[tmdbid=…]}),所有按标题 / 文件名识别的地方解析前套用,命中的行记在解析结果上;保存后后台按改动行重新识别
- 作品识别词:S02 >> S01 EP+24 / S01E13 >> S00E01 / S01 @组 >> 忽略,解析器 + 打印器 + 编译;发布组繁简视为同一个
- 去掉规则自动停用:算出来对不上季表只待确认,要停用就注释掉那一行
- 集数待确认的采用 / 忽略与智能体 apply_rule 改为往作品识别词追加一行(带来历注释)
- 编辑器可视化 / 文本两种模式改同一份原文,保存前试算;作品页「集数映射」用同一个编辑器

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-08 17:03:00 +08:00

300 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createTmdbClient } from "@umt/adapters";
import { createMockTmdb, type MockTmdb, SPLIT_COUR_FIXTURE, TMDB_FIXTURES } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { FrozenPlan } from "../../src/modules/acquisition-organize/services/organize.ts";
import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts";
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
import { setWordsText } from "../support/identify-words.ts";
import { createFmHarness, EXEC_KEY, type FmHarness, key, pgAvailable, seedDownload, seedEntity } from "./harness.ts";
// 整理时指定季集结构(docs/runbook/organize.md「整理时指定剧集组」):作品是 TMDB 默认季(S1 = 25 集),
// 剧集组「制作季」拆成 S1(1–12)、S2(13–25)。临时按组整理 S01E14 → 命名 S02E02,资产覆盖仍挂作品的 S01E14 单元;
// 勾「同时设为作品的季集结构」→ 先切换作品(单元原地迁移),再按新结构整理;缺省跟随作品时行为不变。
const skip = !(await pgAvailable());
describe.skipIf(skip)("整理时指定季集结构", () => {
let h: FmHarness;
let tmdb: MockTmdb;
let show: string;
let unitE14: string;
beforeAll(async () => {
tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, SPLIT_COUR_FIXTURE] });
h = await createFmHarness("organize_episode_structure", {
tmdb: () => createTmdbClient({ apiKey: "test-tmdb-key", baseUrl: `${tmdb.url}/3`, retries: 0 }),
});
show = await seedEntity(h.db, { kind: "series", title: "Split Cour", year: 2024 });
await h.db
.insertInto("external_identity")
.values({
media_entity_id: show,
provider: "tmdb",
entity_type: "tv",
external_id: String(SPLIT_COUR_FIXTURE.id),
status: "accepted",
})
.execute();
expect((await refreshMetadata(h.module.deps().sources(), show, { full: true })).status).toBe("refreshed");
unitE14 = await unitAt(1, 14);
for (const name of [
"Split Cour S01E13 [1080p].mkv",
"Split Cour S01E14 [1080p].mkv",
"Split Cour S01E14 [2160p].mkv",
])
await writeFile(join(h.dl, name), name.padEnd(64, "x"));
await mkdir(join(h.lib, "alt"), { recursive: true });
});
afterAll(async () => {
await h?.close();
await tmdb?.close();
});
async function unitAt(season: number, episode: number): Promise<string> {
return (
await h.db
.selectFrom("content_unit")
.select("id")
.where("media_entity_id", "=", show)
.where("kind", "=", "episode")
.where("season_number", "=", season)
.where("episode_number", "=", episode)
.where("status", "<>", "retired")
.executeTakeFirstOrThrow()
).id;
}
const entityGroup = async () =>
(await h.db.selectFrom("media_entity").select("episode_group_id").where("id", "=", show).executeTakeFirstOrThrow())
.episode_group_id;
const coverage = async (recordId: string) =>
h.db
.selectFrom("organization_file_result as f")
.innerJoin("asset_unit_coverage as c", "c.media_asset_id", "f.media_asset_id")
.innerJoin("content_unit as u", "u.id", "c.content_unit_id")
.select(["u.id", "u.season_number", "u.episode_number"])
.where("f.organization_record_id", "=", recordId)
.execute();
const frozen = async (recordId: string) =>
(await h.db.selectFrom("organization_record").select("plan").where("id", "=", recordId).executeTakeFirstOrThrow())
.plan as unknown as FrozenPlan;
async function organize(path: string, extra: Record<string, unknown> = {}, targetDir?: string) {
const body = {
action: "copy",
items: [
{
root: h.dlRoot,
path,
mediaEntityId: show,
...(targetDir ? { targetDir: { root: h.libRoot, path: targetDir } } : {}),
},
],
...extra,
};
const dry = await h.api("POST", "/files/organize", { ...body, idempotencyKey: key(), dryRun: true });
expect(dry.status, JSON.stringify(dry.body)).toBe(200);
const preview = dry.body.data.groups[0];
expect(preview.error, JSON.stringify(preview)).toBeNull();
const groupAfterPreview = await entityGroup();
const exec = await h.api("POST", "/files/organize", {
...body,
idempotencyKey: key(),
dryRun: false,
planIds: [preview.planId],
});
expect(exec.status, JSON.stringify(exec.body)).toBe(200);
expect(exec.body.data.groups[0].error, JSON.stringify(exec.body.data.groups[0])).toBeNull();
const recordId = exec.body.data.recordIds[0] as string;
expect((await executeRecord(h.module.deps().ao, recordId)).status).toBe("succeeded");
return { preview, groupAfterPreview, exec: exec.body.data.groups[0], recordId };
}
it("缺省跟随作品:按作品默认季命名与登记,计划里不记季集结构(回归)", async () => {
const r = await organize("Split Cour S01E13 [1080p].mkv");
expect(r.preview.files[0].target).toMatch(/Season 1\/.*S01E13/);
expect(r.preview.episodeStructure).toBeUndefined();
expect(await coverage(r.recordId)).toEqual([expect.objectContaining({ season_number: 1, episode_number: 13 })]);
expect((await frozen(r.recordId)).episodeStructure).toBeUndefined();
});
it("指定与作品相同的结构等同跟随作品", async () => {
const dry = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
action: "copy",
dryRun: true,
items: [{ root: h.dlRoot, path: "Split Cour S01E14 [1080p].mkv", mediaEntityId: show }],
episodeStructures: [{ mediaEntityId: show, groupId: null }],
});
expect(dry.status).toBe(200);
const g = dry.body.data.groups[0];
expect(g.files[0].target).toMatch(/Season 1\/.*S01E14/);
expect(g.episodeStructure).toEqual({ groupId: null, name: "TMDB 默认季", temporary: false });
});
it("临时按剧集组整理:S01E14 命名为 S02E02,作品设置不变,资产覆盖挂作品的 S01E14 单元", async () => {
const r = await organize("Split Cour S01E14 [1080p].mkv", {
episodeStructures: [{ mediaEntityId: show, groupId: "grp-production" }],
});
expect(r.preview.files[0].target).toMatch(/Season 2\/.*S02E02/);
expect(r.preview.episodeStructure).toEqual({ groupId: "grp-production", name: "制作季", temporary: true });
expect(await entityGroup()).toBeNull();
expect(await coverage(r.recordId)).toEqual([{ id: unitE14, season_number: 1, episode_number: 14 }]);
const plan = await frozen(r.recordId);
expect(plan.episodeStructure).toEqual({
groupId: "grp-production",
name: "制作季",
entityGroupId: null,
temporary: true,
});
const fx = Object.values(plan.facts)[0]!;
expect(fx).toMatchObject({
season: 1,
episodes: { start: 14, end: 14 },
structureEpisodes: { season: 2, episodes: { start: 2, end: 2 } },
});
expect(fx.episodeResolution?.evidence).toContain("structure:grp-production");
});
it("勾「同时设为作品的季集结构」但 Move 授权不通过:不执行,作品结构不变", async () => {
const body = {
action: "move",
items: [
{
root: h.dlRoot,
path: "Split Cour S01E14 [2160p].mkv",
mediaEntityId: show,
targetDir: { root: h.libRoot, path: "alt" },
},
],
episodeStructures: [{ mediaEntityId: show, groupId: "grp-production", applyToEntity: true }],
};
const dry = await h.api("POST", "/files/organize", { ...body, idempotencyKey: key(), dryRun: true }, EXEC_KEY);
expect(dry.status, JSON.stringify(dry.body)).toBe(200);
expect(dry.body.data.groups[0].error).toBeNull();
const exec = await h.api(
"POST",
"/files/organize",
{ ...body, idempotencyKey: key(), dryRun: false, planIds: [dry.body.data.groups[0].planId] },
EXEC_KEY,
);
expect(exec.status).toBe(403);
expect(exec.body.error.message).toMatch(/^approval_required:/);
expect(await entityGroup()).toBeNull();
expect(await unitAt(1, 14)).toBe(unitE14);
});
it("勾「同时设为作品的季集结构」但做种义务未满足、不能移动:该组不执行,作品结构不变", async () => {
const name = "Split Cour S01E14 [2160p].mkv";
const task = await seedDownload(h.db, h, { name, status: "completed" });
const body = {
action: "move",
items: [{ root: h.dlRoot, path: name, mediaEntityId: show, targetDir: { root: h.libRoot, path: "alt" } }],
episodeStructures: [{ mediaEntityId: show, groupId: "grp-production", applyToEntity: true }],
};
const dry = await h.api("POST", "/files/organize", { ...body, idempotencyKey: key(), dryRun: true });
expect(dry.status, JSON.stringify(dry.body)).toBe(200);
expect(dry.body.data.groups[0].error).toBeNull();
const exec = await h.api("POST", "/files/organize", {
...body,
idempotencyKey: key(),
dryRun: false,
planIds: [dry.body.data.groups[0].planId],
});
expect(exec.status).toBe(200);
expect(exec.body.data.groups[0].error?.code).toBe("download_active");
expect(exec.body.data.recordIds).toEqual([]);
expect(await entityGroup()).toBeNull();
expect(await unitAt(1, 14)).toBe(unitE14);
await h.db.deleteFrom("download_task").where("id", "=", task).execute();
});
it("勾「同时设为作品的季集结构」:预览不切换,执行时先切换作品、单元原地迁移,命名 S02E02,覆盖挂迁移后的单元", async () => {
const r = await organize(
"Split Cour S01E14 [2160p].mkv",
{ episodeStructures: [{ mediaEntityId: show, groupId: "grp-production", applyToEntity: true }] },
"alt",
);
expect(r.groupAfterPreview).toBeNull();
expect(r.preview.episodeStructure).toEqual({
groupId: "grp-production",
name: "制作季",
temporary: true,
appliedToEntity: true,
});
expect(r.preview.files[0].target).toMatch(/alt\/.*S02E02/);
expect(r.exec.files[0].target).toMatch(/alt\/.*S02E02/);
expect(await entityGroup()).toBe("grp-production");
// 单元 id 不变,原地迁到组的 S2E2
expect(await unitAt(2, 2)).toBe(unitE14);
expect(await coverage(r.recordId)).toEqual([{ id: unitE14, season_number: 2, episode_number: 2 }]);
expect((await frozen(r.recordId)).episodeStructure).toEqual({
groupId: "grp-production",
name: "制作季",
entityGroupId: "grp-production",
temporary: false,
appliedToEntity: true,
});
});
it("作品是剧集组时临时按默认季:S01E13 命名 S01E13,覆盖换回组的 S02E01 单元", async () => {
const dry = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
action: "copy",
dryRun: true,
items: [
{
root: h.dlRoot,
path: "Split Cour S01E13 [1080p].mkv",
mediaEntityId: show,
targetDir: { root: h.libRoot, path: "alt" },
},
],
episodeStructures: [{ mediaEntityId: show, groupId: null }],
});
expect(dry.status, JSON.stringify(dry.body)).toBe(200);
const g = dry.body.data.groups[0];
expect(g.files[0].target).toMatch(/alt\/.*S01E13/);
expect(g.episodeStructure).toEqual({ groupId: null, name: "TMDB 默认季", temporary: true });
const art = await h.db
.selectFrom("artifact")
.select("content")
.where("id", "=", g.planId)
.executeTakeFirstOrThrow();
const plan = (art.content as { plan: FrozenPlan }).plan;
expect(Object.values(plan.facts)[0]).toMatchObject({
season: 2,
episodes: { start: 1, end: 1 },
structureEpisodes: { season: 1, episodes: { start: 13, end: 13 } },
});
});
it("作品的映射规则按作品结构写:临时结构下规则结果经 TMDB 原季集换到指定结构", async () => {
// 作品现在是「制作季」:ABS 组按 EP-24 换到组的第 2 季(- 26 → 组 S02E02 = 默认 S01E14)
await setWordsText(h.db, show, "S01 @ABS >> S02 EP-24\n");
await writeFile(join(h.dl, "[ABS] Split Cour - 26 [1080p].mkv"), "abs".padEnd(64, "x"));
const dry = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
action: "copy",
dryRun: true,
items: [{ root: h.dlRoot, path: "[ABS] Split Cour - 26 [1080p].mkv", mediaEntityId: show }],
episodeStructures: [{ mediaEntityId: show, groupId: null }],
});
expect(dry.status, JSON.stringify(dry.body)).toBe(200);
const g = dry.body.data.groups[0];
expect(g.files[0].target).toMatch(/Season 1\/.*S01E14/);
const art = await h.db
.selectFrom("artifact")
.select("content")
.where("id", "=", g.planId)
.executeTakeFirstOrThrow();
const fx = Object.values((art.content as { plan: FrozenPlan }).plan.facts)[0]!;
expect(fx).toMatchObject({ season: 2, episodes: { start: 2, end: 2 } });
expect(fx.episodeResolution).toMatchObject({
method: "rule",
canonical: { season: 1, episodes: { start: 14, end: 14 } },
});
});
});