import { appendFile, mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { IDENTIFY_COMPLETED_EVENT, runIdentifyRemote } from "../../src/modules/file-manager/identify.ts"; import { createFmHarness, EXEC_KEY, type FmHarness, key, pgAvailable, READ_KEY, seedAsset, seedEntity, } from "./harness.ts"; // identify(只读,本地 + 远端召回前 5,不落库)、organize(按作品分组走 planImport,dryRun→execute 一致与源变更)、 // probe(已登记写回 media_info)、刷新元数据(scrape-batch,入队 refreshMetadata)。 const skip = !(await pgAvailable()); describe.skipIf(skip)("files 识别 / 整理 / 探测 / 刮削", () => { let h: FmHarness; let show: string; let film: string; beforeAll(async () => { h = await createFmHarness( "ident", { ao: { probe: async () => ({ status: "ok", container: "matroska", durationSec: 60, video: [{ codec: "hevc", width: 3840, height: 2160, hdr: "HDR10" }], audio: [{ codec: "truehd" }], subtitles: [], }), }, }, { catalog: true }, ); show = await seedEntity(h.db, { kind: "series", title: "Some Show", year: 2022 }); film = await seedEntity(h.db, { kind: "movie", title: "Blue Film", year: 2021 }); await mkdir(join(h.dl, "Some.Show.S01.1080p.WEB-DL")); await writeFile(join(h.dl, "Some.Show.S01.1080p.WEB-DL", "Some.Show.S01E02.1080p.WEB-DL-GRP.mkv"), "ep2"); await writeFile(join(h.dl, "Some.Show.S01.1080p.WEB-DL", "Some.Show.S01E03.1080p.WEB-DL-GRP.mkv"), "ep3"); await writeFile(join(h.dl, "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv"), "film-bytes"); await writeFile(join(h.dl, "Remote.Film.2019.1080p.mkv"), "remote"); }); afterAll(async () => h?.close()); it("identify:目录展开为视频,本地候选命中,季集解析,不写库", async () => { const before = await h.db.selectFrom("media_entity").select(["id"]).execute(); const r = await h.api( "POST", "/files/identify", { items: [{ root: h.dlRoot, path: "Some.Show.S01.1080p.WEB-DL" }], minSizeBytes: 0 }, READ_KEY, ); expect(r.status).toBe(200); expect(r.body.data.items).toHaveLength(2); const it0 = r.body.data.items[0]; expect(it0.parsed).toMatchObject({ season: 1, episodes: [2], resolution: "1080p" }); expect(it0.candidates[0]).toMatchObject({ mediaEntityId: show, provider: "local", title: "Some Show" }); expect(it0.candidates.length).toBeLessThanOrEqual(5); expect(it0.suggestion).toMatchObject({ mediaEntityId: show, contentUnit: "S01E02" }); const after = await h.db.selectFrom("media_entity").select(["id"]).execute(); expect(after).toHaveLength(before.length); const pool = await h.db.selectFrom("identity_candidate").select(["id"]).execute(); expect(pool).toHaveLength(0); }); it("identify:远端候选(catalog.lookup,wait=true 同步模式)", async () => { const r = await h.api("POST", "/files/identify?wait=true", { items: [{ root: h.dlRoot, path: "Remote.Film.2019.1080p.mkv" }], minSizeBytes: 0, }); expect(r.status).toBe(200); expect(r.body.data.remoteTaskId).toBeNull(); const c = r.body.data.items[0].candidates; expect(c[0]).toMatchObject({ provider: "tmdb", externalId: "999", mediaEntityId: null, kind: "movie" }); }); it("identify:缺省入队远端查询,先回本地命中 + remoteTaskId;任务完成发 identify.completed(S-7)", async () => { const before = h.engine.enqueued.length; const body = { items: [ { root: h.dlRoot, path: "Remote.Film.2019.1080p.mkv" }, { root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv" }, ], minSizeBytes: 0, }; const r = await h.api("POST", "/files/identify", body); expect(r.status).toBe(200); const [remote, local] = r.body.data.items; // 本地命中照常返回;远端候选不在请求内查 expect(remote.candidates).toEqual([]); expect(remote.warnings.some((w: string) => w.includes("后台"))).toBe(true); expect(local.candidates[0]).toMatchObject({ mediaEntityId: film }); expect(r.body.data.remotePending).toBe(2); const job = h.engine.enqueued.slice(before).find((e) => e.task === "identifyRemote"); expect(job).toBeTruthy(); expect(r.body.data.remoteTaskId).toMatch(/^run-/); // 模拟 worker 执行任务 const out = await runIdentifyRemote( h.module.deps(), r.body.data.remoteTaskId, job!.input as Parameters[2], ); expect(out.status).toBe("succeeded"); const ev = h.events.filter((e) => e.type === IDENTIFY_COMPLETED_EVENT).at(-1); expect(ev?.payload.taskId).toBe(r.body.data.remoteTaskId); const done = ev!.payload.items.find((i: { path: string }) => i.path === "Remote.Film.2019.1080p.mkv"); expect(done.candidates[0]).toMatchObject({ provider: "tmdb", externalId: "999" }); expect(done.warnings.some((w: string) => w.includes("后台"))).toBe(false); }); it("identify:无需远端查询时不入队(remoteTaskId=null)", async () => { const before = h.engine.enqueued.length; await mkdir(join(h.dl, "untitled"), { recursive: true }); await writeFile(join(h.dl, "untitled", "1080p.mkv"), "x"); const r = await h.api("POST", "/files/identify", { items: [{ root: h.dlRoot, path: "untitled/1080p.mkv" }] }); expect(r.status).toBe(200); expect(r.body.data.remoteTaskId).toBeNull(); expect(h.engine.enqueued.slice(before).some((e) => e.task === "identifyRemote")).toBe(false); }); it("identify:任务里识别失败也发 identify.completed(status=failed)", async () => { const out = await runIdentifyRemote(h.module.deps(), "run-x", { body: { items: [{ root: h.dlRoot, path: "no-such-file.mkv" }] } as never, actorId: "system", }); expect(out.status).toBe("failed"); expect(h.events.at(-1)).toMatchObject({ type: IDENTIFY_COMPLETED_EVENT, payload: { taskId: "run-x", status: "failed" }, }); }); it("identify:去重后的远端查询单次上限 20,超出的项只本地召回并给 warning(验收 07 S-6)", async () => { await mkdir(join(h.dl, "many"), { recursive: true }); const names = Array.from({ length: 23 }, (_, i) => `Distinct Title ${String.fromCharCode(65 + i)}.2019.1080p.mkv`); for (const n of names) await writeFile(join(h.dl, "many", n), "x"); const r = await h.api( "POST", "/files/identify", { items: [{ root: h.dlRoot, path: "many" }], minSizeBytes: 0 }, READ_KEY, ); expect(r.status).toBe(200); expect(r.body.data.items).toHaveLength(23); const capped = r.body.data.items.filter((i: { warnings: string[] }) => i.warnings.some((w) => w.includes("上限"))); expect(capped).toHaveLength(3); }); it("organize:dryRun 返回 planId 与前后路径;源变更 → 该组 plan_stale;重新预览后执行入队 verifyAndImport", async () => { const items = [{ root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv", mediaEntityId: film }]; const dry = await h.api("POST", "/files/organize", { idempotencyKey: key(), items, action: "copy", dryRun: true }); expect(dry.status).toBe(200); const g = dry.body.data.groups[0]; expect(g.error).toBeNull(); expect(g.planId).toBeTruthy(); expect(g.files[0].source).toBe(join(h.dl, "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv")); expect(g.files[0].target).toContain(join(h.lib, "Blue Film (2021)")); await appendFile(join(h.dl, "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv"), "+"); const stale = await h.api("POST", "/files/organize", { idempotencyKey: key(), items, action: "copy", dryRun: false, planIds: [g.planId], }); expect(stale.status).toBe(200); expect(stale.body.data.groups[0].error.code).toBe("plan_stale"); expect(stale.body.data.recordIds).toEqual([]); const dry2 = await h.api("POST", "/files/organize", { idempotencyKey: key(), items, action: "copy", dryRun: true }); const exec = await h.api("POST", "/files/organize", { idempotencyKey: key(), items, action: "copy", dryRun: false, planIds: [dry2.body.data.groups[0].planId], }); expect(exec.status).toBe(200); expect(exec.body.data.groups[0].error).toBeNull(); expect(exec.body.data.recordIds).toHaveLength(1); expect(exec.body.data.groups[0].files.map((f: { target: string }) => f.target)).toEqual( dry2.body.data.groups[0].files.map((f: { target: string }) => f.target), ); expect(h.engine.enqueued.some((e) => e.task === "verifyAndImport")).toBe(true); const rec = await h.db .selectFrom("organization_record") .select(["trigger", "action", "media_entity_id"]) .where("id", "=", exec.body.data.recordIds[0]) .executeTakeFirstOrThrow(); expect(rec).toEqual({ trigger: "manual", action: "copy", media_entity_id: film }); }); it("organize Move(验收 07 S-1):源是别的作品的已登记资产 → 只授权声明作品不够,整体 403 并列出", async () => { const other = await seedEntity(h.db, { kind: "movie", title: "Other Film", year: 2020 }); await mkdir(join(h.lib, "Stolen"), { recursive: true }); await writeFile(join(h.lib, "Stolen", "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv"), "someone-else"); await seedAsset(h.db, h, { entityId: other, rel: "Stolen/Blue.Film.2021.2160p.BluRay.x265-GRP.mkv" }); const grant = await h.db .insertInto("automation_grant") .values({ action: "organize.execute", scope: JSON.stringify({ mediaEntityIds: [film] }), granted_by: "test" }) .returning("id") .executeTakeFirstOrThrow(); const items = [{ root: h.libRoot, path: "Stolen/Blue.Film.2021.2160p.BluRay.x265-GRP.mkv", mediaEntityId: film }]; const dry = await h.api( "POST", "/files/organize", { idempotencyKey: key(), items, action: "move", dryRun: true }, EXEC_KEY, ); expect(dry.status).toBe(200); const r = await h.api( "POST", "/files/organize", { idempotencyKey: key(), items, action: "move", dryRun: false, planIds: [dry.body.data.groups[0].planId] }, EXEC_KEY, ); expect(r.status).toBe(403); expect(r.body.error.message).toMatch(/^approval_required:/); expect(r.body.error.message).toContain(other); const recs = await h.db .selectFrom("organization_record") .select("id") .where("source_path", "like", "%Stolen%") .execute(); expect(recs).toEqual([]); await h.db.updateTable("automation_grant").set({ status: "revoked" }).where("id", "=", grant.id).execute(); }); it("organize:选中的视频带上同目录同主干的字幕 / 外挂音轨,无关字幕不带", async () => { const dir = join(h.dl, "Some.Show.S01.1080p.WEB-DL"); await writeFile(join(dir, "Some.Show.S01E02.1080p.WEB-DL-GRP.chs.ass"), "sub-ep2"); await writeFile(join(dir, "Some.Show.S01E03.1080p.WEB-DL-GRP.mka"), "audio-ep3"); await writeFile(join(dir, "unrelated.srt"), "noise"); // 整理向导把目录展开成逐个视频后提交 const items = ["Some.Show.S01E02.1080p.WEB-DL-GRP.mkv", "Some.Show.S01E03.1080p.WEB-DL-GRP.mkv"].map((n) => ({ root: h.dlRoot, path: `Some.Show.S01.1080p.WEB-DL/${n}`, mediaEntityId: show, season: 1, })); const dry = await h.api("POST", "/files/organize", { idempotencyKey: key(), items, action: "copy", dryRun: true, minSizeBytes: 0, }); expect(dry.status).toBe(200); const g = dry.body.data.groups[0]; expect(g.error).toBeNull(); const byKind = (k: string) => g.files.filter((f: { kind: string }) => f.kind === k).map((f: { source: string; target: string }) => f); expect(byKind("subtitle")).toHaveLength(1); expect(byKind("subtitle")[0].source).toBe(join(dir, "Some.Show.S01E02.1080p.WEB-DL-GRP.chs.ass")); expect(byKind("subtitle")[0].target).toMatch(/S01E02.*\.chi\.zh-cn\.ass$/); expect(byKind("audio")).toHaveLength(1); expect(byKind("audio")[0].target).toMatch(/S01E03.*\.mka$/); expect(JSON.stringify(g)).not.toContain("unrelated.srt"); }); it("organize:路径逃逸同样拒绝", async () => { const r = await h.api("POST", "/files/organize", { idempotencyKey: key(), items: [{ root: h.dlRoot, path: "../outside/secret.txt", mediaEntityId: film }], action: "copy", dryRun: true, }); expect(r.status).toBeGreaterThanOrEqual(400); expect(r.status).toBeLessThan(500); }); it("probe:已登记资产写回 media_info;刷新元数据按作品入队", async () => { await mkdir(join(h.lib, "Blue Film probe")); await writeFile(join(h.lib, "Blue Film probe", "Blue.Film.2021.mkv"), "x"); const a = await seedAsset(h.db, h, { entityId: film, rel: "Blue Film probe/Blue.Film.2021.mkv" }); const r = await h.api("POST", "/files/probe", { items: [{ root: h.libRoot, path: "Blue Film probe/Blue.Film.2021.mkv" }], }); expect(r.status).toBe(200); expect(r.body.data.items[0]).toMatchObject({ status: "ok", savedToAsset: true, summary: { resolution: "3840x2160", hdr: "HDR10", videoCodec: "hevc", audioTracks: 1 }, }); const row = await h.db .selectFrom("media_asset") .select(["media_info"]) .where("id", "=", a.assetId) .executeTakeFirstOrThrow(); expect((row.media_info as { probe?: { status: string } }).probe?.status).toBe("ok"); // 未登记:只缓存 const u = await h.api("POST", "/files/probe", { items: [{ root: h.dlRoot, path: "Remote.Film.2019.1080p.mkv" }] }); expect(u.body.data.items[0]).toMatchObject({ status: "ok", savedToAsset: false, cached: false }); const u2 = await h.api("POST", "/files/probe", { items: [{ root: h.dlRoot, path: "Remote.Film.2019.1080p.mkv" }] }); expect(u2.body.data.items[0].cached).toBe(true); // 刷新元数据(scrape-batch):按作品入队 refreshMetadata;刮削(写 NFO)见 scrape.test.ts const b = await h.api("POST", "/files/scrape-batch", { idempotencyKey: key(), mediaEntityIds: [film] }); expect(b.status).toBe(200); expect(b.body.data.submitted).toHaveLength(1); expect(h.engine.enqueued.some((e) => e.task === "refreshMetadata")).toBe(true); }); });