Files
UHD_Media_Tool/apps/server/test/file-manager/v2.test.ts
T
rootandClaude Opus 5.5 13132e7ad8 feat(file-manager): v2 backend gaps — TMDB candidate entities, min size, on-existing, naming source (W9-H)
- G1 createEntityFromTmdbCandidate (sources-identity service): entity + accepted
  manual external identity + season/episode units, idempotent per TMDB id;
  POST /files/entity-from-candidate and organize items with `external`
- G2 minSizeBytes for organize (excluded below_min_size) and identify
  (skipped + excludedBySize)
- G3 onExisting passed to planImport; dryRun marks each file
  (same/skip/replace/conflict); replace = explicit grant
- G4 naming template|keep; planImport namingMode/namingSource, frozen
  plan.naming {mode, templateSource, templateVersion}; targetSegments
- G5 identify prior-record hints, GET /files/record-sources, organize
  refreshMetadata, POST /files/scrape-batch (dedupe by entity)
- G8 GET /files/tree (parent, subdirs hasChildren, video/registered counts)
- G9 POST /files/suggest-name returns 501 until the naming engine lands

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 15:08:17 +08:00

329 lines
14 KiB
TypeScript

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 推荐名(引擎未就绪 501)。
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<string, unknown>, 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:推荐名在命名引擎接入前返回 501 not_implemented", async () => {
const r = await h.api(
"POST",
"/files/suggest-name",
{ root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv" },
READ_KEY,
);
expect(r.status).toBe(501);
expect(r.body.error.message).toMatch(/^not_implemented:/);
});
});