Files
UHD_Media_Tool/apps/server/test/file-manager/source-monitor.test.ts
T
rootandClaude Opus 5.5 ceb66f8c77 feat: v0.3.8–v0.3.14 工作区汇总(依赖升级、整理来源、手动入库、原地入库等)
生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。

- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
  PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
  (metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)

同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
  由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距

依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。

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

322 lines
15 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { mkdir, rename, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createQbClient } from "@umt/adapters";
import type { OrganizeSourceConfig } from "@umt/contracts";
import { createMockQb, type MockQb } from "@umt/testkit";
import { afterEach, describe, expect, it } from "vitest";
import {
deleteOrganizeSource,
listOrganizeSources,
saveOrganizeSource,
} from "../../src/modules/file-manager/organize-sources.ts";
import { advanceSnapshot, scanOrganizeSource, scanStock } from "../../src/modules/file-manager/source-monitor.ts";
import { createFmHarness, type FmHarness, pgAvailable, seedDownload, seedEntity } from "./harness.ts";
const fact = { size: 100, mtimeMs: 1000 };
describe("source snapshot observations", () => {
it("baselines historical files and requires two stable observations for new and changed files", () => {
const baseline = advanceSnapshot(null, { "old.mkv": fact });
expect(baseline.eligible).toEqual([]);
const added = advanceSnapshot(baseline.snapshot, { "old.mkv": fact, "new.mkv": fact });
expect(added.eligible).toEqual([]);
const stable = advanceSnapshot(added.snapshot, { "old.mkv": fact, "new.mkv": fact });
expect(stable.eligible).toEqual(["new.mkv"]);
const changed = advanceSnapshot(stable.snapshot, { "old.mkv": { ...fact, size: 200 }, "new.mkv": fact });
expect(changed.eligible).not.toContain("old.mkv");
expect(advanceSnapshot(changed.snapshot, { "old.mkv": { ...fact, size: 200 } }).eligible).toEqual(["old.mkv"]);
});
});
const skip = !(await pgAvailable());
describe.skipIf(skip)("persistent source monitoring", () => {
let h: FmHarness | undefined;
let qb: MockQb | undefined;
afterEach(async () => {
await qb?.close();
qb = undefined;
await h?.close();
h = undefined;
});
const setup = async () => {
qb = await createMockQb();
h = await createFmHarness("source_monitor", {
ao: {
downloader: async (inst) =>
createQbClient({ instanceId: inst.id, baseUrl: qb!.url, username: "admin", password: "adminadmin" }),
},
});
const config: Omit<OrganizeSourceConfig, "id"> = {
name: "来源",
enabled: true,
source: { root: h.dlRoot, path: "" },
target: { root: h.libRoot, path: "" },
mediaType: "all",
category: null,
action: "copy",
trigger: "snapshot",
downloaderInstanceId: null,
tag: null,
intervalSec: 60,
};
const id = randomUUID();
await saveOrganizeSource(h.module.deps(), id, config);
return { h, id, config, qb };
};
it("first scan persists stock baseline without records; stock never changes baseline", async () => {
const { h, id } = await setup();
await writeFile(join(h.dl, "old.mkv"), "old video content");
const result = await scanOrganizeSource(h.module.deps(), { sourceId: id });
expect(result).toMatchObject({ baseline: true, queued: 0 });
const before = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow();
expect(before.baseline_at).not.toBeNull();
await writeFile(join(h.dl, "new.mkv"), "new video content");
const stock = await scanStock(h.module.deps(), id);
expect(stock.items.map((f) => f.path).sort()).toEqual(["new.mkv", "old.mkv"]);
const after = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow();
expect(after).toEqual(before);
expect(await h.db.selectFrom("organization_record").select("id").execute()).toEqual([]);
expect((await listOrganizeSources(h.module.deps())).items[0]?.fileCount).toBe(1);
});
it("unavailable source preserves baseline and retry does not import historical files", async () => {
const { h, id } = await setup();
await writeFile(join(h.dl, "old.mkv"), "old video content");
await scanOrganizeSource(h.module.deps(), { sourceId: id });
const before = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow();
await rename(h.dl, `${h.dl}-offline`);
await scanOrganizeSource(h.module.deps(), { sourceId: id });
const failed = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow();
expect(failed.snapshot).toEqual(before.snapshot);
expect(failed.baseline_at).toEqual(before.baseline_at);
expect(failed.last_error).not.toBeNull();
await rename(`${h.dl}-offline`, h.dl);
expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({
baseline: false,
eligible: 0,
queued: 0,
});
});
it("an identified stable new file plans and queues import once", async () => {
const { h, id, config } = await setup();
await saveOrganizeSource(h.module.deps(), id, { ...config, category: "欧美电影" });
const entityId = await seedEntity(h.db, { kind: "movie", title: "Snapshot Film", year: 2024 });
await h.db
.insertInto("external_identity")
.values({
media_entity_id: entityId,
provider: "tmdb",
external_id: "123456",
status: "accepted",
entity_type: "movie",
})
.execute();
await scanOrganizeSource(h.module.deps(), { sourceId: id });
await writeFile(join(h.dl, "Snapshot.Film.2024.tmdb-123456.1080p.BluRay.mkv"), "video content for snapshot import");
expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ eligible: 0, queued: 0 });
const stableResult = await scanOrganizeSource(h.module.deps(), { sourceId: id });
expect(stableResult).toMatchObject({ eligible: 1, queued: 1 });
const imports = h.engine.enqueued.filter((e) => e.task === "verifyAndImport");
expect(imports).toHaveLength(1);
expect(imports[0]?.input).toMatchObject({ recordId: expect.any(String) });
await scanOrganizeSource(h.module.deps(), { sourceId: id });
expect(h.engine.enqueued.filter((e) => e.task === "verifyAndImport")).toHaveLength(1);
});
it("new stable files create one scoped decision and never reprocess baseline stock", async () => {
const { h, id } = await setup();
await writeFile(join(h.dl, "historical.mkv"), "historical video content");
await scanOrganizeSource(h.module.deps(), { sourceId: id });
await writeFile(join(h.dl, "unidentified.mkv"), "unidentified video content");
expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ eligible: 0, queued: 0 });
expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ eligible: 1, queued: 0 });
const decisions = await h.db.selectFrom("decision").selectAll().execute();
expect(decisions).toHaveLength(1);
expect(decisions[0]?.evidence).toMatchObject({ sourceConfigId: id, root: h.dlRoot, path: "unidentified.mkv" });
await scanOrganizeSource(h.module.deps(), { sourceId: id });
expect(await h.db.selectFrom("decision").select("id").execute()).toHaveLength(1);
});
it("active and completed QB tasks remain under the downloader tag policy", async () => {
const { h, id } = await setup();
await scanOrganizeSource(h.module.deps(), { sourceId: id });
await writeFile(join(h.dl, "active.mkv"), "active video content");
await writeFile(join(h.dl, "completed.mkv"), "completed video content");
await seedDownload(h.db, h, { name: "active.mkv", status: "downloading" });
await seedDownload(h.db, h, { name: "completed.mkv", status: "completed" });
await scanOrganizeSource(h.module.deps(), { sourceId: id });
expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ eligible: 2, queued: 0 });
expect(await h.db.selectFrom("organization_record").select("id").execute()).toEqual([]);
expect(await scanStock(h.module.deps(), id)).toMatchObject({
unavailable: 1,
items: [{ root: h.dlRoot, path: "completed.mkv" }],
});
});
it("untagged paused QB downloads without DB rows wait for completion before snapshot import", async () => {
const { h, id, qb, config } = await setup();
await saveOrganizeSource(h.module.deps(), id, { ...config, category: "欧美电影" });
const entityId = await seedEntity(h.db, { kind: "movie", title: "Snapshot Film", year: 2024 });
await h.db
.insertInto("external_identity")
.values({
media_entity_id: entityId,
provider: "tmdb",
external_id: "123456",
status: "accepted",
entity_type: "movie",
})
.execute();
await scanOrganizeSource(h.module.deps(), { sourceId: id });
const name = "Snapshot.Film.2024.tmdb-123456.1080p.BluRay.mkv";
await writeFile(join(h.dl, name), "stable incomplete video content");
qb.seed({
hash: "a".repeat(40),
name,
tags: [],
state: "pausedDL",
progress: 0.5,
amount_left: 50,
save_path: h.dl,
content_path: join(h.dl, name),
});
expect(await h.db.selectFrom("download_task").select("id").execute()).toEqual([]);
await scanOrganizeSource(h.module.deps(), { sourceId: id });
expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ eligible: 1, queued: 0 });
expect(await scanStock(h.module.deps(), id)).toMatchObject({ items: [], unavailable: 1 });
expect(await h.db.selectFrom("decision").select("id").execute()).toEqual([]);
expect(h.engine.enqueued.filter((e) => e.task === "verifyAndImport")).toEqual([]);
await qb.complete("a".repeat(40));
expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ eligible: 1, queued: 1 });
expect(await h.db.selectFrom("download_task").select("id").execute()).toEqual([]);
});
it("unreachable QB preserves the previous baseline and rejects stock", async () => {
const { h, id, qb } = await setup();
await writeFile(join(h.dl, "baseline.mkv"), "baseline video content");
await scanOrganizeSource(h.module.deps(), { sourceId: id });
const before = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow();
qb.fault("torrents/info", { status: 503, times: 10 });
await writeFile(join(h.dl, "later.mkv"), "later video content");
expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ queued: 0 });
const after = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow();
expect(after.snapshot).toEqual(before.snapshot);
expect(after.baseline_at).toEqual(before.baseline_at);
expect(after.last_error).toContain("无法查询来源下载器");
await expect(scanStock(h.module.deps(), id)).rejects.toThrow("无法查询来源下载器");
});
it("partial records exclude successful files while leaving failed files available", async () => {
const { h, id } = await setup();
await writeFile(join(h.dl, "success.mkv"), "successful video content");
await writeFile(join(h.dl, "failure.mkv"), "failed video content");
const record = await h.db
.insertInto("organization_record")
.values({ trigger: "manual", actor_type: "system", action: "copy", status: "partial" })
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("organization_file_result")
.values([
{
organization_record_id: record.id,
ordinal: 0,
mode: "copy",
source_path: join(h.dl, "success.mkv"),
status: "succeeded",
},
{
organization_record_id: record.id,
ordinal: 1,
mode: "copy",
source_path: join(h.dl, "failure.mkv"),
status: "failed",
},
])
.execute();
expect(await scanStock(h.module.deps(), id)).toMatchObject({
items: [{ root: h.dlRoot, path: "failure.mkv" }],
skipped: 1,
});
});
it("historical success excludes only matching source facts, so replacement files remain candidates", async () => {
const { h, id } = await setup();
const names = ["same.mkv", "size-changed.mkv", "mtime-changed.mkv"];
await Promise.all(names.map((n) => writeFile(join(h.dl, n), "video content")));
const facts = Object.fromEntries(
await Promise.all(
names.map(async (n) => {
const st = await stat(join(h.dl, n));
return [join(h.dl, n), { size: st.size, mtimeMs: st.mtimeMs }];
}),
),
);
facts[join(h.dl, "size-changed.mkv")]!.size -= 1;
facts[join(h.dl, "mtime-changed.mkv")]!.mtimeMs -= 1;
const record = await h.db
.insertInto("organization_record")
.values({
trigger: "manual",
actor_type: "system",
action: "copy",
status: "succeeded",
plan: JSON.stringify({ sourceFacts: facts }),
})
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("organization_file_result")
.values(
names.map((n, ordinal) => ({
organization_record_id: record.id,
ordinal,
mode: "copy" as const,
source_path: join(h.dl, n),
source_size_bytes: facts[join(h.dl, n)]!.size,
status: "succeeded" as const,
})),
)
.execute();
const stock = await scanStock(h.module.deps(), id);
expect(stock.skipped).toBe(1);
expect(stock.items.map((f) => f.path).sort()).toEqual(["mtime-changed.mkv", "size-changed.mkv"]);
});
it("stock batches stay within the identify wizard limit", async () => {
const { h, id } = await setup();
await Promise.all(Array.from({ length: 201 }, (_, n) => writeFile(join(h.dl, `stock-${n}.mkv`), "video content")));
const stock = await scanStock(h.module.deps(), id);
expect(stock.items).toHaveLength(200);
expect(stock.truncated).toBe(true);
expect(await h.db.selectFrom("organize_source_snapshot").selectAll().execute()).toEqual([]);
});
it("stock omits successfully organized files", async () => {
const { h, id } = await setup();
const path = join(h.dl, "organized.mkv");
await writeFile(path, "organized video content");
const record = await h.db
.insertInto("organization_record")
.values({ trigger: "manual", actor_type: "system", action: "copy", status: "succeeded" })
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("organization_file_result")
.values({ organization_record_id: record.id, mode: "copy", source_path: path, status: "succeeded" })
.execute();
expect(await scanStock(h.module.deps(), id)).toMatchObject({ items: [], skipped: 1 });
});
it("source edits reset baseline; identical categories coexist; deleting removes state", async () => {
const { h, id, config } = await setup();
await mkdir(join(h.dl, "other"));
await scanOrganizeSource(h.module.deps(), { sourceId: id });
await saveOrganizeSource(h.module.deps(), randomUUID(), { ...config, category: "电影" });
await saveOrganizeSource(h.module.deps(), id, {
...config,
category: "电影",
source: { ...config.source, path: "other" },
});
expect((await listOrganizeSources(h.module.deps())).items).toHaveLength(2);
expect(
await h.db.selectFrom("organize_source_snapshot").selectAll().where("source_id", "=", id).executeTakeFirst(),
).toBeUndefined();
await scanOrganizeSource(h.module.deps(), { sourceId: id });
await deleteOrganizeSource(h.module.deps(), id);
expect(
await h.db.selectFrom("organize_source_snapshot").selectAll().where("source_id", "=", id).executeTakeFirst(),
).toBeUndefined();
});
});