feat(organize): 刮削写远端与 Alist 目录监控

- 目标在 Alist 时 NFO / 图片(含 clearlogo)本地生成后上传,已存在策略与本地一致
- 整理来源可选 Alist 根(手动 / 目录快照):按 MP 快照语义限深度、按目录修改时间跳过未变子树,
  列表失败保留基线,复用两次观察稳定与失败记录

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 23:52:39 +08:00
1 parent ccd2cc2c37
commit c1bc389058
5 files changed
+305 -11

No files matched your search

@@ -0,0 +1,213 @@
import { randomUUID } from "node:crypto";
import type { OrganizeSourceConfig } from "@umt/contracts";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { runScrapeRecord, scrapeEntity } from "../../src/modules/acquisition-organize/services/scrape.ts";
import { saveOrganizeSource } from "../../src/modules/file-manager/organize-sources.ts";
import { scanOrganizeSource, scanStock } from "../../src/modules/file-manager/source-monitor.ts";
import { createFmHarness, type FmHarness, pgAvailable, seedEntity } from "../file-manager/harness.ts";
import { addAlist, addAlistLibrary, type TestAlist, text } from "./alist.ts";
// 刮削写远端(NFO / 海报 / 背景 / clearlogo 本地生成后上传;已存在策略与本地一致)
// 与目录监控发现 Alist 新文件(快照两次稳定才整理、深度上限、按目录修改时间跳过未变子树、列表失败保留基线)。
const skip = !(await pgAvailable());
const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00]);
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
describe.skipIf(skip)("Alist:刮削写远端", () => {
let h: FmHarness;
let a: TestAlist;
let lib: { libraryRootId: string; root: string };
beforeAll(async () => {
h = await createFmHarness(
"alist_scrape",
{
ao: {
async scrapeImage(p) {
return p.endsWith(".png") ? { content: PNG, mime: "image/png" } : { content: JPEG, mime: "image/jpeg" };
},
async scrapeTmdbDetail() {
throw new Error("不应请求 TMDB:本地作品有元数据");
},
},
},
{ runTransfers: false },
);
a = await addAlist(h.db, { name: "OL", rootPath: "/s1" });
lib = await addAlistLibrary(h.db, a, "/s1/movies");
});
afterAll(async () => h?.close());
const run = async (entity: string, mode: "missing" | "overwrite") => {
const recs = await scrapeEntity(h.module.deps().ao, entity, { trigger: "manual", mode });
for (const r of recs) await runScrapeRecord(h.module.deps().ao, r.recordId);
return h.db
.selectFrom("scrape_record")
.selectAll()
.where(
"id",
"in",
recs.map((r) => r.recordId),
)
.execute();
};
it("NFO 与图片(含 clearlogo)写到 Alist 上作品目录;只补缺失跳过已有,全部覆盖改写", async () => {
const film = await seedEntity(h.db, { kind: "movie", title: "星际穿越", year: 2014 });
await h.db
.updateTable("media_entity")
.set({
metadata: JSON.stringify({
overview: { value: "穿越虫洞" },
posterPath: { value: "/inter.jpg" },
backdropPath: { value: "/inter-bg.jpg" },
logoPath: { value: "/inter-logo.png" },
}),
})
.where("id", "=", film)
.execute();
await h.db
.insertInto("external_identity")
.values({
media_entity_id: film,
provider: "tmdb",
entity_type: "movie",
external_id: "157336",
status: "accepted",
})
.execute();
a.mock.put("/s1/movies/星际穿越 (2014)/星际穿越 (2014) - 1080p.mkv", "video");
const asset = await h.db
.insertInto("media_asset")
.values({ media_entity_id: film, kind: "video", size_bytes: 5 })
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("asset_location")
.values({
media_asset_id: asset.id,
location_kind: "library",
library_root_id: lib.libraryRootId,
relative_path: "星际穿越 (2014)/星际穿越 (2014) - 1080p.mkv",
status: "available",
is_primary: true,
})
.execute();
const first = await run(film, "overwrite");
expect(first[0]!.status, JSON.stringify(first[0])).toBe("succeeded");
const names = a.mock.listNames("/s1/movies/星际穿越 (2014)");
expect(names.some((n) => n.endsWith(".nfo"))).toBe(true);
expect(names.some((n) => n.includes("poster"))).toBe(true);
expect(names).toContain("clearlogo.png");
expect(names.filter((n) => n.includes(".umt-new") || n.endsWith(".uhd-partial"))).toEqual([]);
const nfo = names.find((n) => n.endsWith(".nfo"))!;
expect(text(a.mock.read(`/s1/movies/星际穿越 (2014)/${nfo}`))).toContain("星际穿越");
// 用户改过 NFO:只补缺失不动它
a.mock.put(`/s1/movies/星际穿越 (2014)/${nfo}`, "<movie>用户改的</movie>");
const missing = await run(film, "missing");
expect(missing[0]!.status).toMatch(/succeeded|partial|skipped/);
expect(text(a.mock.read(`/s1/movies/星际穿越 (2014)/${nfo}`))).toBe("<movie>用户改的</movie>");
// 全部覆盖:改写回来
await run(film, "overwrite");
expect(text(a.mock.read(`/s1/movies/星际穿越 (2014)/${nfo}`))).toContain("星际穿越");
});
});
describe.skipIf(skip)("Alist:目录监控(远端快照)", () => {
let h: FmHarness;
let a: TestAlist;
let sourceId: string;
beforeAll(async () => {
h = await createFmHarness("alist_monitor", {}, { catalog: true, runTransfers: false });
a = await addAlist(h.db, { name: "OL", rootPath: "/s1", mockOptions: { token: "t" } });
a.mock.put("/s1/incoming/Old.Film.2010.1080p.mkv", "old video bytes");
a.mock.put("/s1/incoming/deep/a/b/c/d/e/Too.Deep.2011.mkv", "too deep");
const film = await seedEntity(h.db, { kind: "movie", title: "New Film", year: 2021 });
await h.db
.insertInto("external_identity")
.values({
media_entity_id: film,
provider: "tmdb",
external_id: "654321",
status: "accepted",
entity_type: "movie",
})
.execute();
const config: Omit<OrganizeSourceConfig, "id"> = {
name: "Alist 来源",
enabled: true,
source: { root: a.root as OrganizeSourceConfig["source"]["root"], path: "incoming" },
target: { root: h.libRoot as OrganizeSourceConfig["target"]["root"], path: "" },
mediaType: "all",
category: "欧美电影",
action: "copy",
trigger: "snapshot",
downloaderInstanceId: null,
tag: null,
intervalSec: 60,
};
sourceId = randomUUID();
await saveOrganizeSource(h.module.deps(), sourceId, config);
});
afterAll(async () => h?.close());
it("首扫建基线不整理;新文件两次观察稳定后整理;深度超限不进快照", async () => {
const d = h.module.deps();
const base = await scanOrganizeSource(d, { sourceId });
expect(base).toMatchObject({ baseline: true, queued: 0 });
const snap0 = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow();
const files0 = Object.keys((snap0.snapshot as { files: Record<string, unknown> }).files);
expect(files0).toContain("incoming/Old.Film.2010.1080p.mkv");
expect(files0.some((f) => f.includes("Too.Deep"))).toBe(false);
a.mock.put("/s1/incoming/New.Film.2021.tmdb-654321.1080p.WEB-DL.mkv", "new video bytes!!");
const first = await scanOrganizeSource(d, { sourceId });
expect(first.eligible).toBe(0);
const second = await scanOrganizeSource(d, { sourceId });
expect(second.eligible).toBe(1);
expect(second.queued).toBe(1);
const rec = await h.db.selectFrom("organization_record").select(["id", "trigger"]).executeTakeFirstOrThrow();
expect(rec.trigger).toBe("auto");
const files = await h.db
.selectFrom("organization_file_result")
.select("source_path")
.where("organization_record_id", "=", rec.id)
.execute();
expect(files.map((f) => f.source_path)).toContain(a.v("/s1/incoming/New.Film.2021.tmdb-654321.1080p.WEB-DL.mkv"));
expect(h.engine.enqueued.some((e) => e.task === "verifyAndImport")).toBe(true);
});
it("按目录修改时间跳过未变子树:子目录没变就不再列", async () => {
const d = h.module.deps();
a.mock.put("/s1/incoming/sub/Inner.Film.2012.mkv", "inner");
// sub 目录修改时间早于上次扫描 → 沿用旧快照(新文件要等目录时间变化才会被看到)
a.mock.dirs.set("/s1/incoming/sub", 1);
a.mock.requests.length = 0;
await scanOrganizeSource(d, { sourceId });
const listed = a.mock.requests.filter((r) => r.path === "/api/fs/list").map((r) => r.body!.path);
expect(listed).toContain("/s1/incoming");
expect(listed).not.toContain("/s1/incoming/sub");
// 目录时间更新后再扫就会进去
a.mock.dirs.set("/s1/incoming/sub", Date.now() + 60_000);
a.mock.requests.length = 0;
await scanOrganizeSource(d, { sourceId });
expect(a.mock.requests.filter((r) => r.path === "/api/fs/list").map((r) => r.body!.path)).toContain(
"/s1/incoming/sub",
);
});
it("Alist 连不上:记 last_error,保留基线;存量整理也能列出远端文件", async () => {
const d = h.module.deps();
const before = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow();
a.mock.failNext(10);
await scanOrganizeSource(d, { sourceId });
const after = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow();
expect(after.last_error).toBeTruthy();
expect(after.baseline_at).toEqual(before.baseline_at);
a.mock.failNext(0);
const stock = await scanStock(d, sourceId);
expect(stock.items.map((i) => i.path)).toContain("incoming/Old.Film.2010.1080p.mkv");
});
});