Files
UHD_Media_Tool/apps/server/test/acquisition-organize/episode-resolution-files.test.ts
T

402 lines
16 KiB
TypeScript

import { mkdir, readFile, writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { type EpisodeResolution, parseRelease, resolveEpisodes } from "@umt/domain";
import { toParsedColumns } from "@umt/mapping";
import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { submitDownload, syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
import { type FrozenPlan, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
import { queueScrape, runScrapeRecord } from "../../src/modules/acquisition-organize/services/scrape.ts";
import { canonicalParsed } from "../../src/modules/sources-identity/service/episode-resolution.ts";
import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
// 文件侧集数映射(docs/plan/episode-resolution.md 批次 3):整理计划与季包选文件对文件名重新解析出的季集,
// 按作品季表 + 映射规则换成规范季集。ANi 连续编号 `- 25` 下载时认作 S02E13,整理时不能又按文件名认成 S01E25。
const hasPg = await pgAvailable();
const SIZE = 300_000;
// 已完结:S1 = 12、S2 = 13
const TABLE = {
seasons: [
{ season: 1, total: 12 },
{ season: 2, total: 13 },
],
latestSeason: 2,
ended: true,
};
const ani = (ep: number) => `[ANi] Kusuriya no Hitorigoto - ${ep} [1080P].mkv`;
describe.skipIf(!hasPg)("整理与季包选文件按作品映射集数(真实 PG + mock qB + 临时目录)", () => {
let h: Harness;
let qb: MockQb;
let lib: TempTree;
let cache: TempTree;
let src: TempTree;
let base: Awaited<ReturnType<typeof seedBase>>;
const disk = fakeDisk(10_000_000_000);
const torrents = new Map<string, Uint8Array>();
let n = 0;
beforeAll(async () => {
qb = await createMockQb({ addVisibleAfterPolls: 0 });
lib = await createTempTree({}, { sentinel: true, prefix: "umt-eplib-" });
cache = await createTempTree({}, { prefix: "umt-epcache-" });
const pack: Record<string, number> = {};
for (let e = 13; e <= 25; e++) pack[`pack/${ani(e)}`] = SIZE;
src = await createTempTree(
{
...pack,
[`single/${ani(25)}`]: SIZE,
[`over/${ani(40)}`]: SIZE,
"rule/[ANi] Rule Show - 14 [1080P].mkv": SIZE,
"plain/Plain.Show.S01E05.1080p.WEB-DL.x264-GRP.mkv": SIZE,
},
{ prefix: "umt-epsrc-" },
);
disk.state.watch = [cache.root];
disk.setPct(10);
h = await createHarness(
"episode_files",
{
diskUsage: disk.fn,
torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }),
},
{ withSources: true },
);
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
}, 60_000);
afterAll(async () => {
await h?.close();
await qb?.close();
for (const t of [lib, cache, src]) await t?.cleanup();
});
/** 剧集作品 + 季表(季单元总集数 + 已完结) */
async function series(title: string, table = TABLE) {
const id = await seedEntity(h.db, { kind: "series", title, year: 2023 });
await h.db
.updateTable("media_entity")
.set({ metadata: JSON.stringify({ airStatus: { value: "Ended", source: "tmdb" } }) })
.where("id", "=", id)
.execute();
const units: Record<number, string> = {};
for (const s of table.seasons) {
units[s.season] = (
await h.db
.insertInto("content_unit")
.values({
media_entity_id: id,
kind: "season",
season_number: s.season,
episode_total: s.total,
status: "confirmed",
source: "tmdb",
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
return { id, units };
}
/** 已识别的资源:parsed 为规范季集,episode_resolution 为判定结果(evidence 可追加标记,验证整理沿用的是它) */
async function release(entityId: string, title: string, opts: { files?: string[]; mark?: string } = {}) {
const raw = parseRelease(title);
const resolution: EpisodeResolution = resolveEpisodes({ parsed: raw, kind: "series", table: TABLE, rules: [] });
if (opts.mark) resolution.evidence.push(opts.mark);
const cols = toParsedColumns(canonicalParsed(raw, resolution));
const t = opts.files
? makeTorrent({
name: title,
files: opts.files.map((path) => ({ path, length: SIZE })),
pieceSeed: Math.floor(Math.random() * 1e6),
})
: makeTorrent({ name: `${title}.mkv`, length: SIZE, pieceSeed: Math.floor(Math.random() * 1e6) });
const id = (
await h.db
.insertInto("source_release")
.values({
site_id: base.siteId,
site_release_id: `${Math.floor(Math.random() * 1e9)}`,
title,
size_bytes: SIZE,
infohash_v1: t.hash,
media_entity_id: entityId,
resolution_status: "resolved",
parsed: JSON.stringify(cols.parsed),
parser_version: cols.parserVersion,
episode_resolution: JSON.stringify(resolution),
raw: JSON.stringify({ _site: { hr: false } }),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
torrents.set(id, t.bytes);
return { id, hash: t.hash, resolution };
}
/** qB 里已完成的下载任务(接管进来),关联到资源 */
async function completedTask(releaseId: string, name: string, file: string) {
n++;
const hash = n.toString(16).padStart(40, "b");
qb.seed({ hash, name, tags: ["umt"], save_path: cache.root });
await qb.complete(hash, { files: [{ path: `${name}/${file}`, size: SIZE }] });
await syncDownloads(h.d);
const dt = await h.db
.selectFrom("download_task")
.select("id")
.where("torrent_hash", "=", hash)
.executeTakeFirstOrThrow();
await h.db.updateTable("download_task").set({ source_release_id: releaseId }).where("id", "=", dt.id).execute();
return dt.id;
}
const dryRun = async (entityId: string, dir: string, extra: Partial<Parameters<typeof planImport>[1]> = {}) => {
const r = await planImport(h.d, {
sourcePaths: [src.path(dir)],
mediaEntityId: entityId,
action: "copy",
trigger: "manual",
actorType: "user",
dryRun: true,
...extra,
});
expect(r.plan, JSON.stringify(r)).toBeDefined();
return r.plan!;
};
const mains = (plan: FrozenPlan) =>
plan.organize.files
.filter((f) => f.role === "main")
.map((f) => ({ src: basename(f.src), dst: basename(f.dst), season: f.season, episodes: f.episodes }));
it("关联资源 `- 25` 已认作 S02E13:下载文件 `- 25` 整理命名与单元为 S02E13,沿用资源的判定", async () => {
const { id } = await series("药屋少女的呢喃");
const rel = await release(id, "[ANi] Kusuriya no Hitorigoto - 25 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]", {
mark: "test:from_release",
});
expect(rel.resolution.canonical).toEqual({ season: 2, episodes: { start: 13, end: 13 } });
const dtId = await completedTask(rel.id, "[ANi] Kusuriya no Hitorigoto - 25", ani(25));
const r = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system", dryRun: true });
expect(r.status, JSON.stringify(r)).toBe("queued");
const plan = r.plan!;
const [m] = mains(plan);
expect(m).toMatchObject({ season: 2, episodes: { start: 13, end: 13 } });
expect(m!.dst).toMatch(/S02E13/);
expect(m!.dst).not.toMatch(/S01E25/);
const fact = Object.values(plan.facts)[0]!;
expect(fact).toMatchObject({ season: 2, episodes: { start: 13, end: 13 }, unitKeys: ["s2e13"] });
expect(fact.episodeResolution).toMatchObject({
method: "absolute_cross_season",
source: { season: 1, episodes: { start: 25, end: 25 } },
});
expect(fact.episodeResolution!.evidence).toContain("test:from_release");
});
it("季包 `- 13`…`- 25`(无季号)逐文件映射到 S02E01…S02E13", async () => {
const { id } = await series("季包作品");
const plan = await dryRun(id, "pack");
const got = mains(plan).sort((a, b) => a.episodes!.start - b.episodes!.start);
expect(got).toHaveLength(13);
got.forEach((m, i) => {
expect(m.season).toBe(2);
expect(m.episodes).toEqual({ start: i + 1, end: i + 1 });
expect(m.dst).toMatch(new RegExp(`S02E${String(i + 1).padStart(2, "0")}`));
});
expect(Object.values(plan.facts).every((f) => f.season === 2)).toBe(true);
});
it("用户逐文件指定的季集优先,不被映射覆盖", async () => {
const { id } = await series("指定集数作品");
const file = src.path(`single/${ani(25)}`);
const plan = await dryRun(id, "single", {
fileOverrides: { [file]: { season: 1, episodes: { start: 7, end: 7 } } },
});
const [m] = mains(plan);
expect(m).toMatchObject({ season: 1, episodes: { start: 7, end: 7 } });
expect(m!.dst).toMatch(/S01E07/);
expect(plan.facts[file]).toMatchObject({ season: 1, unitKeys: ["s1e7"] });
expect(plan.facts[file]!.episodeResolution).toBeUndefined();
});
it("文件集数待确认:不按原号命名,整理失败原因 episode_unresolved,并入关联资源的 episode_mapping 待处理", async () => {
const { id } = await series("待确认作品");
// 没有关联资源:只看计划
const dry = await planImport(h.d, {
sourcePaths: [src.path("over")],
mediaEntityId: id,
action: "copy",
trigger: "manual",
actorType: "user",
dryRun: true,
});
expect(dry.status).toBe("failed");
expect(dry.reason).toBe("episode_unresolved");
expect(dry.plan!.organize.files).toHaveLength(0);
expect(dry.plan!.organize.excluded).toEqual([
expect.objectContaining({ reason: "episode_unresolved", detail: expect.stringContaining("S01E40") }),
]);
expect(Object.values(dry.plan!.unresolvedEpisodes ?? {})[0]).toMatchObject({
method: "unresolved",
source: { season: 1, episodes: { start: 40, end: 40 } },
});
// 关联资源(同样待确认):记录失败 + 待处理
const rel = await release(id, "[ANi] Kusuriya no Hitorigoto - 40 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]");
expect(rel.resolution.method).toBe("unresolved");
const dtId = await completedTask(rel.id, "[ANi] Kusuriya no Hitorigoto - 40", ani(40));
const r = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system" });
expect(r).toMatchObject({ status: "failed", reason: "episode_unresolved" });
const files = await h.db
.selectFrom("organization_file_result")
.select(["target_path", "skip_reason"])
.where("organization_record_id", "=", r.recordId!)
.execute();
expect(files.every((f) => !f.target_path?.includes("S01E40"))).toBe(true);
expect(files.map((f) => f.skip_reason)).toContain("excluded:episode_unresolved");
const decision = await h.db
.selectFrom("decision")
.select(["evidence", "status"])
.where("kind", "=", "episode_mapping")
.where("subject_id", "=", id)
.executeTakeFirstOrThrow();
expect(decision.evidence).toMatchObject({ sourceReleaseIds: [rel.id] });
});
it("有 active 规则 EP-12(→ 第 2 季)时整理按规则", async () => {
const { id } = await series("规则作品");
const rule = await h.db
.insertInto("episode_mapping_rule")
.values({
media_entity_id: id,
season: 1,
release_group: "ANi",
kind: "expression",
expression: "EP-12",
target_season: 2,
created_by_type: "user",
created_by_id: "admin",
})
.returning("id")
.executeTakeFirstOrThrow();
const plan = await dryRun(id, "rule");
const [m] = mains(plan);
expect(m).toMatchObject({ season: 2, episodes: { start: 2, end: 2 } });
expect(m!.dst).toMatch(/S02E02/);
expect(Object.values(plan.facts)[0]!.episodeResolution).toMatchObject({ method: "rule", ruleId: rule.id });
});
it("原号本来就对(S01E05):整理行为不变", async () => {
const { id } = await series("原号作品");
const plan = await dryRun(id, "plain");
const [m] = mains(plan);
expect(m).toMatchObject({ season: 1, episodes: { start: 5, end: 5 } });
expect(m!.dst).toMatch(/S01E05/);
const fact = Object.values(plan.facts)[0]!;
expect(fact).toMatchObject({ season: 1, unitKeys: ["s1e5"] });
expect(fact.episodeResolution).toMatchObject({ method: "raw" });
});
it("刮削:未登记的 `- 25` 按作品映射成 S02E13 对集元数据;待确认的 `- 40` 不写分集 NFO", async () => {
const { id } = await series("刮削作品");
await h.db
.updateTable("media_entity")
.set({
metadata: JSON.stringify({
airStatus: { value: "Ended", source: "tmdb" },
title: { value: "刮削作品", source: "tmdb", language: "zh-CN", at: new Date().toISOString(), locked: false },
}),
})
.where("id", "=", id)
.execute();
await h.db
.insertInto("content_unit")
.values({
media_entity_id: id,
kind: "episode",
season_number: 2,
episode_number: 13,
status: "confirmed",
source: "tmdb",
title: "第二季终集",
})
.execute();
const dir = "刮削作品 (2023)";
await mkdir(join(lib.root, dir), { recursive: true });
for (const ep of [25, 40]) await writeFile(join(lib.root, dir, ani(ep)), "v");
const q = await queueScrape(h.d, {
rootId: `lib:${base.rootId}`,
rootPath: lib.root,
libraryRootId: base.rootId,
path: dir,
match: {
mediaEntityId: id,
provider: null,
externalId: null,
kind: "tv",
title: "刮削作品",
year: 2023,
source: "chosen",
},
trigger: "manual",
});
const r = await runScrapeRecord(h.d, q.recordId);
expect(r.files.find((f) => f.path === `${dir}/${ani(40)}`)).toMatchObject({
action: "skipped",
reason: "episode_unresolved",
});
const nfo = await readFile(join(lib.root, dir, ani(25).replace(/\.mkv$/, ".nfo")), "utf8");
expect(nfo).toContain("<title>第二季终集</title>");
expect(nfo).toMatch(/<season>2<\/season>\n {2}<episode>13<\/episode>/);
});
it("季包选文件:订阅缺 S02E03,季包 `- 13`…`- 25` 只下 `- 15`", async () => {
const { id } = await series("季包选文件作品");
const sub = await h.db
.insertInto("subscription")
.values({ media_entity_id: id, media_kind: "series", mode: "continuous", status: "active" })
.returning("id")
.executeTakeFirstOrThrow();
const slot = await h.db
.insertInto("acquisition_slot")
.values({
media_entity_id: id,
subscription_id: sub.id,
name: "1080p",
hard_conditions: JSON.stringify({}),
upgrade_threshold: JSON.stringify({}),
})
.returning("id")
.executeTakeFirstOrThrow();
const names: string[] = [];
for (let e = 13; e <= 25; e++) names.push(ani(e));
const rel = await release(id, "[ANi] Kusuriya no Hitorigoto [13-25][1080P][Baha][WEB-DL]", { files: names });
const plan = await h.db
.insertInto("acquisition_plan")
.values({
slot_id: slot.id,
source_release_id: rel.id,
status: "reserved",
evaluation: JSON.stringify({ instanceId: base.instanceId, savePath: cache.root, mediaEntityId: id }),
payload_hash: `ph-${rel.id}`,
idempotency_key: `ik-${rel.id}`,
unit_keys: ["s2e3"],
})
.returning("id")
.executeTakeFirstOrThrow();
const s = await submitDownload(h.d, plan.id);
expect(s.status, JSON.stringify(s)).toBe("submitted");
const row = await h.db
.selectFrom("acquisition_plan")
.select(["file_selection", "unit_keys"])
.where("id", "=", plan.id)
.executeTakeFirstOrThrow();
expect(row.file_selection).toMatchObject({ mode: "selected", selectedUnits: ["s2e3"] });
expect(row.unit_keys).toEqual(["s2e3"]);
const t = qb.get(rel.hash)!;
const on = t.files.filter((f) => f.priority !== 0).map((f) => f.name.split("/").pop());
expect(on).toEqual([ani(15)]);
});
});