import { mkdir, writeFile } from "node:fs/promises"; import { basename, join } from "node:path"; import { createTmdbClient } from "@umt/adapters"; import { createMockTmdb, type MockTmdb } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createFmHarness, EXEC_KEY, type FmHarness, key, pgAvailable, READ_KEY, seedAsset, seedEntity, } from "./harness.ts"; // W9-H 文件管理 v2 后端缺口:G1 TMDB 候选建作品 / G2 最小体积 / G3 目标已存在策略 / G4 命名方式与来源 / // G5 整理记录提示 + 重新整理源 + 整理后刷新元数据 + 批量刮削 / G8 目录摘要 / G9 推荐名。 const skip = !(await pgAvailable()); // biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读响应 JSON type Loose = any; describe.skipIf(skip)("files v2(W9-H)", () => { let h: FmHarness; let tmdb: MockTmdb; let film: string; const big = "x".repeat(64); beforeAll(async () => { tmdb = await createMockTmdb(); h = await createFmHarness("v2", { tmdb: () => createTmdbClient({ apiKey: "test-tmdb-key", baseUrl: `${tmdb.url}/3`, retries: 0 }), }); film = await seedEntity(h.db, { kind: "movie", title: "Blue Film", year: 2021 }); await writeFile(join(h.dl, "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv"), big); await writeFile(join(h.dl, "Blue.Film.2021.sample.tiny.mkv"), "tiny"); await mkdir(join(h.dl, "The.Bear.S01.1080p"), { recursive: true }); await writeFile(join(h.dl, "The.Bear.S01.1080p", "The.Bear.S01E01.1080p.WEB-DL-GRP.mkv"), big); }); afterAll(async () => { await h?.close(); await tmdb?.close(); }); const organize = (body: Record, k = EXEC_KEY) => h.api("POST", "/files/organize", { idempotencyKey: key(), action: "copy", dryRun: true, ...body }, k); it("G1:按 TMDB 候选建作品 + 外部映射(accepted/manual)+ 季集单元;同 TMDB id 幂等", async () => { const r = await h.api( "POST", "/files/entity-from-candidate", { provider: "tmdb", externalId: "136315", kind: "tv" }, EXEC_KEY, ); expect(r.status).toBe(200); const d = r.body.data; expect(d).toMatchObject({ created: true, kind: "series", year: 2022 }); expect(d.contentUnits).toBe(31); // 3 季 + 8/10/10 集 const ident = await h.db .selectFrom("external_identity") .select(["provider", "external_id", "status", "evidence_level", "media_entity_id"]) .where("media_entity_id", "=", d.mediaEntityId) .where("provider", "=", "tmdb") .executeTakeFirstOrThrow(); expect(ident).toMatchObject({ external_id: "136315", status: "accepted", evidence_level: "manual" }); const units = await h.db .selectFrom("content_unit") .select(["kind"]) .where("media_entity_id", "=", d.mediaEntityId) .execute(); expect(units.filter((u) => u.kind === "season")).toHaveLength(3); expect(units.filter((u) => u.kind === "episode")).toHaveLength(28); const again = await h.api( "POST", "/files/entity-from-candidate", { provider: "tmdb", externalId: "136315", kind: "tv" }, EXEC_KEY, ); expect(again.body.data).toMatchObject({ mediaEntityId: d.mediaEntityId, created: false }); const count = await h.db .selectFrom("external_identity") .select(["id"]) .where("provider", "=", "tmdb") .where("external_id", "=", "136315") .execute(); expect(count).toHaveLength(1); // 只读 key 不能建 const ro = await h.api( "POST", "/files/entity-from-candidate", { provider: "tmdb", externalId: "1", kind: "movie" }, READ_KEY, ); expect(ro.status).toBe(403); // TMDB 无此条目 → 404 const nf = await h.api("POST", "/files/entity-from-candidate", { provider: "tmdb", externalId: "424242", kind: "movie", }); expect(nf.status).toBe(404); }); it("G1:整理项带 external → 预览就地建(或复用)作品,组标 entity;与 mediaEntityId 二选一", async () => { const r = await organize({ items: [ { root: h.dlRoot, path: "The.Bear.S01.1080p/The.Bear.S01E01.1080p.WEB-DL-GRP.mkv", external: { provider: "tmdb", externalId: "136315", kind: "tv" }, }, ], }); expect(r.status).toBe(200); const g = r.body.data.groups[0]; expect(g.error).toBeNull(); expect(g.entity).toMatchObject({ provider: "tmdb", externalId: "136315", created: false }); expect(g.files[0].target).toMatch(/Season 1/); const movie = await organize({ items: [ { root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv", external: { provider: "tmdb", externalId: "872585", kind: "movie" }, }, ], }); expect(movie.body.data.groups[0].entity.created).toBe(true); const both = await organize({ items: [ { root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv", mediaEntityId: film, external: { provider: "tmdb", externalId: "872585", kind: "movie" }, }, ], }); expect(both.status).toBe(400); const none = await organize({ items: [{ root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv" }] }); expect(none.status).toBe(400); }); it("G2:整理按最小体积排除(below_min_size);识别按最小体积跳过并计数", async () => { const items = [ { root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv", mediaEntityId: film }, { root: h.dlRoot, path: "Blue.Film.2021.sample.tiny.mkv", mediaEntityId: film }, ]; const r = await organize({ items, minSizeBytes: 32 }); const g = r.body.data.groups[0]; expect(g.minSizeBytes).toBe(32); expect(g.files.map((f: Loose) => basename(f.source))).toEqual(["Blue.Film.2021.2160p.BluRay.x265-GRP.mkv"]); expect(g.excluded).toContainEqual( expect.objectContaining({ source: join(h.dl, "Blue.Film.2021.sample.tiny.mkv"), reason: expect.any(String) }), ); // 非 sample 命名的小文件:明确 below_min_size + 体积 await writeFile(join(h.dl, "Blue.Film.2021.1080p.small.mkv"), "0123456789ab"); const r2 = await organize({ items: [{ root: h.dlRoot, path: "Blue.Film.2021.1080p.small.mkv", mediaEntityId: film }], minSizeBytes: 32, }); expect(r2.body.data.groups[0].excluded).toEqual([ { source: join(h.dl, "Blue.Film.2021.1080p.small.mkv"), kind: "video", size: 12, reason: "below_min_size" }, ]); const id = await h.api( "POST", "/files/identify", { items: [ { root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv" }, { root: h.dlRoot, path: "Blue.Film.2021.1080p.small.mkv" }, ], minSizeBytes: 32, }, READ_KEY, ); expect(id.status).toBe(200); expect(id.body.data.excludedBySize).toBe(1); const [a, b] = id.body.data.items; expect(a).toMatchObject({ size: 64, skipped: null }); expect(b).toMatchObject({ size: 12, skipped: "below_min_size", candidates: [] }); }); it("G3:目标已存在策略 skip / conflict / replace_with_verify 逐文件标注", async () => { const other = await seedEntity(h.db, { kind: "movie", title: "Clash Film", year: 2020 }); await writeFile(join(h.dl, "Clash.Film.2020.1080p.WEB-DL-GRP.mkv"), big); const items = [{ root: h.dlRoot, path: "Clash.Film.2020.1080p.WEB-DL-GRP.mkv", mediaEntityId: other }]; const first = await organize({ items }); const target = first.body.data.groups[0].files[0].target as string; expect(first.body.data.groups[0].files[0].existing).toBeNull(); expect(first.body.data.groups[0].onExisting).toBe("conflict"); await mkdir(join(target, ".."), { recursive: true }); await writeFile(target, "different-content-of-another-size"); const conflict = await organize({ items }); const gc = conflict.body.data.groups[0]; expect(gc.files[0]).toMatchObject({ existing: "conflict", action: "hold" }); expect(gc.error.code).toBe("plan_blocked"); const skipped = await organize({ items, onExisting: "skip" }); const gs = skipped.body.data.groups[0]; expect(gs.files[0]).toMatchObject({ existing: "skip", action: "skip", skipReason: "target_exists" }); expect(gs.error).toBeNull(); expect(gs.onExisting).toBe("skip"); const replace = await organize({ items, onExisting: "replace_with_verify" }); const gr = replace.body.data.groups[0]; expect(gr.files[0]).toMatchObject({ existing: "replace", action: "replace" }); expect(gr.error).toBeNull(); // 策略进请求指纹:skip 的 planId 不能拿去执行 replace const exec = await organize({ items, onExisting: "replace_with_verify", dryRun: false, planIds: [gs.planId] }); expect(exec.body.data.groups[0].error.code).toBe("plan_stale"); }); it("G4:命名方式 keep 保留原文件名(来源 override);template 记录模板来源/版本;执行后冻结进整理记录", async () => { const items = [{ root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv", mediaEntityId: film }]; const tpl = await organize({ items }); const gt = tpl.body.data.groups[0]; expect(gt.naming).toMatchObject({ mode: "template", templateSource: "global" }); expect(typeof gt.naming.templateVersion).toBe("number"); expect(gt.targetSegments).toEqual([{ text: "Blue Film (2021)", role: "root", bound: false }]); const keep = await organize({ items, naming: "keep" }); const gk = keep.body.data.groups[0]; expect(gk.naming).toMatchObject({ mode: "keep", templateSource: "override" }); expect(basename(gk.files[0].target)).toBe("Blue.Film.2021.2160p.BluRay.x265-GRP.mkv"); expect(gk.files[0].target).toContain("Blue Film (2021)"); const exec = await organize({ items, naming: "keep", dryRun: false, planIds: [gk.planId], refreshMetadata: true }); expect(exec.status).toBe(200); const g = exec.body.data.groups[0]; expect(g.error).toBeNull(); const rec = await h.db .selectFrom("organization_record") .select(["plan"]) .where("id", "=", g.recordId) .executeTakeFirstOrThrow(); expect((rec.plan as Loose).naming).toMatchObject({ mode: "keep", templateSource: "override" }); // G5:整理后刷新元数据 expect(g.refreshTaskId).toBeTruthy(); expect( h.engine.enqueued.some((e) => e.task === "refreshMetadata" && (e.input as Loose).mediaEntityId === film), ).toBe(true); }); it("G5:识别标已有整理记录;整理记录 → 源 FileRef(重新整理)", async () => { const id = await h.api( "POST", "/files/identify", { items: [{ root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv" }] }, READ_KEY, ); const recs = id.body.data.items[0].records; expect(recs.count).toBeGreaterThanOrEqual(1); expect(recs.lastRecordId).toBeTruthy(); const src = await h.api("GET", `/files/record-sources?recordId=${recs.lastRecordId}`, undefined, READ_KEY); expect(src.status).toBe(200); expect(src.body.data.items).toEqual([{ root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv" }]); expect(src.body.data.unresolved).toEqual([]); expect(src.body.data.mediaEntityId).toBe(film); }); it("G5:批量刷新元数据按作品去重,未登记作品跳过", async () => { const reg = await seedEntity(h.db, { kind: "movie", title: "Registered Film", year: 2018 }); await mkdir(join(h.lib, "Registered Film (2018)"), { recursive: true }); await writeFile(join(h.lib, "Registered Film (2018)", "a.mkv"), big); await writeFile(join(h.lib, "Registered Film (2018)", "b.mkv"), big); await seedAsset(h.db, h, { entityId: reg, rel: "Registered Film (2018)/a.mkv" }); await seedAsset(h.db, h, { entityId: reg, rel: "Registered Film (2018)/b.mkv" }); const bare = await seedEntity(h.db, { kind: "movie", title: "Bare Film", year: 2017 }); const r = await h.api( "POST", "/files/scrape-batch", { idempotencyKey: key(), items: [ { root: h.libRoot, path: "Registered Film (2018)/a.mkv" }, { root: h.libRoot, path: "Registered Film (2018)/b.mkv" }, ], mediaEntityIds: [reg, bare], }, EXEC_KEY, ); expect(r.status).toBe(200); expect(r.body.data.submitted.map((s: Loose) => s.mediaEntityId)).toEqual([reg]); expect(r.body.data.skipped).toEqual([{ ref: bare, reason: "not_registered" }]); }); it("G8:目录摘要 — 上一级、子目录是否有下级、视频/已登记计数", async () => { await mkdir(join(h.lib, "Tree", "A", "Deep"), { recursive: true }); await mkdir(join(h.lib, "Tree", "B"), { recursive: true }); await writeFile(join(h.lib, "Tree", "A", "x.mkv"), big); const r = await h.api("GET", `/files/tree?root=${h.libRoot}&path=Tree`, undefined, READ_KEY); expect(r.status).toBe(200); expect(r.body.data).toMatchObject({ path: "Tree", name: "Tree", parent: "", dirs: [ { name: "A", path: "Tree/A", hasChildren: true }, { name: "B", path: "Tree/B", hasChildren: false }, ], summary: { videoCount: 1, registeredCount: 0, partial: false }, }); const top = await h.api("GET", `/files/tree?root=${h.libRoot}&path=`, undefined, READ_KEY); expect(top.body.data.parent).toBeNull(); expect(top.body.data.summary.registeredCount).toBeGreaterThanOrEqual(2); }); it("G9:推荐名按命名模板渲染(文件名 / 作品目录);识别不可信 → null + 原因", async () => { const f = await h.api( "POST", "/files/suggest-name", { root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv" }, READ_KEY, ); expect(f.status).toBe(200); expect(f.body.data).toMatchObject({ mediaEntityId: film, templateSource: "global", reason: null, name: "Blue Film (2021) - 2160p.mkv", relativePath: "Blue Film (2021)/Blue Film (2021) - 2160p.mkv", }); expect(f.body.data.segments.map((s: Loose) => s.role)).toEqual(["root", "file"]); await mkdir(join(h.dl, "Blue.Film.2021.Pack"), { recursive: true }); const dir = await h.api( "POST", "/files/suggest-name", { root: h.dlRoot, path: "Blue.Film.2021.Pack", mediaEntityId: film }, READ_KEY, ); expect(dir.body.data.name).toBe("Blue Film (2021)"); await writeFile(join(h.dl, "Nobody.Knows.This.2011.mkv"), big); const unknown = await h.api( "POST", "/files/suggest-name", { root: h.dlRoot, path: "Nobody.Knows.This.2011.mkv" }, READ_KEY, ); expect(unknown.body.data).toMatchObject({ name: null, reason: "identity_uncertain" }); }); });