生产 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]>
322 lines
15 KiB
TypeScript
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();
|
|
});
|
|
});
|