merge: fix/i-scrape
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
commit
ee503dd1ea
35 files changed
+1915
-200
No files matched your search
@@ -0,0 +1,95 @@
|
||||
import { rm } from "node:fs/promises";
|
||||
import { createTempTree, type TempTree } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
||||
|
||||
// 整理登记资产时同一路径已有位置:
|
||||
// - 非洗版「替换」(向导 / 待处理选替换 = replace_with_verify)退役旧位置并新建资产,size / hash / 版本信息是新文件的;
|
||||
// - 复用之前判为缺失的位置(同名文件重新入库)时,位置恢复可用,资产事实换成这次的文件。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const OLD = "Film.2020.1080p.BluRay.x264-AAA.mkv";
|
||||
const NEW = "Film.2020.1080p.WEB-DL.x264-BBB.mkv";
|
||||
|
||||
describe.skipIf(!hasPg)("整理:替换与缺失位置复用时资产事实跟着新文件", () => {
|
||||
let h: Harness;
|
||||
let lib: TempTree;
|
||||
let src: TempTree;
|
||||
let entity = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
lib = await createTempTree({}, { sentinel: true, prefix: "umt-aflib-" });
|
||||
src = await createTempTree({ [OLD]: 200_000, [NEW]: 300_000 }, { prefix: "umt-afsrc-" });
|
||||
h = await createHarness("asset_facts");
|
||||
await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" });
|
||||
await h.setSettings("organize", { scrapeAfterImport: false });
|
||||
entity = await seedEntity(h.db, { kind: "movie", title: "替换片", year: 2020 });
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
for (const t of [lib, src]) await t?.cleanup();
|
||||
});
|
||||
|
||||
async function organize(file: string, extra: Record<string, unknown> = {}) {
|
||||
const plan = await planImport(h.d, {
|
||||
sourcePaths: [src.path(file)],
|
||||
mediaEntityId: entity,
|
||||
action: "copy",
|
||||
trigger: "manual",
|
||||
actorType: "user",
|
||||
...extra,
|
||||
});
|
||||
expect(plan.status).toBe("running");
|
||||
expect((await executeRecord(h.d, plan.recordId!)).status).toBe("succeeded");
|
||||
return h.db
|
||||
.selectFrom("organization_file_result")
|
||||
.select(["media_asset_id", "target_path"])
|
||||
.where("organization_record_id", "=", plan.recordId!)
|
||||
.where("role", "=", "main")
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
const asset = (id: string) =>
|
||||
h.db.selectFrom("media_asset").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
||||
const locations = (id: string) =>
|
||||
h.db.selectFrom("asset_location").select(["status", "size_bytes"]).where("media_asset_id", "=", id).execute();
|
||||
|
||||
let newAsset = "";
|
||||
let target = "";
|
||||
|
||||
it("非洗版替换:退役旧位置与旧资产,新建资产记新文件的大小与版本", async () => {
|
||||
const first = await organize(OLD);
|
||||
const second = await organize(NEW, { onExisting: "replace_with_verify", grantReplace: true });
|
||||
expect(second.target_path).toBe(first.target_path);
|
||||
expect(second.media_asset_id).not.toBe(first.media_asset_id);
|
||||
newAsset = second.media_asset_id!;
|
||||
target = second.target_path!;
|
||||
|
||||
expect((await asset(first.media_asset_id!)).status).toBe("retired");
|
||||
expect(await locations(first.media_asset_id!)).toEqual([{ status: "removed", size_bytes: expect.anything() }]);
|
||||
const a = await asset(newAsset);
|
||||
expect(a).toMatchObject({ status: "active", media_entity_id: entity });
|
||||
expect(Number(a.size_bytes)).toBe(300_000);
|
||||
expect(JSON.stringify(a.delivery)).toContain("BBB");
|
||||
expect((await locations(newAsset)).map((l) => l.status)).toEqual(["available"]);
|
||||
});
|
||||
|
||||
it("复用判为缺失的位置:位置恢复可用,资产恢复 active 且事实换成新文件", async () => {
|
||||
await h.db
|
||||
.updateTable("asset_location")
|
||||
.set({ status: "confirmed_missing" })
|
||||
.where("media_asset_id", "=", newAsset)
|
||||
.execute();
|
||||
await h.db.updateTable("media_asset").set({ status: "missing" }).where("id", "=", newAsset).execute();
|
||||
await rm(lib.path(target));
|
||||
const again = await organize(OLD);
|
||||
expect(again.media_asset_id).toBe(newAsset);
|
||||
const a = await asset(newAsset);
|
||||
expect(a.status).toBe("active");
|
||||
expect(Number(a.size_bytes)).toBe(200_000);
|
||||
expect(JSON.stringify(a.delivery)).toContain("AAA");
|
||||
const [loc] = await locations(newAsset);
|
||||
expect(loc).toMatchObject({ status: "available" });
|
||||
expect(Number(loc!.size_bytes)).toBe(200_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createTempTree, type TempTree } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
||||
|
||||
// 收尾时 ffprobe 判定「不是视频」:已拷 / 搬进库的新文件与它的附属文件移入回收站(不留未登记文件),
|
||||
// 附属文件结果改为跳过(main_failed),开待处理;重放不重复处理。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const FAKE = "Fake.2020.1080p.BluRay.x264-GRP.mkv";
|
||||
const SUB = "Fake.2020.1080p.BluRay.x264-GRP.zh.srt";
|
||||
|
||||
describe.skipIf(!hasPg)("整理:不是视频的文件不留在媒体库", () => {
|
||||
let h: Harness;
|
||||
let lib: TempTree;
|
||||
let src: TempTree;
|
||||
|
||||
beforeAll(async () => {
|
||||
lib = await createTempTree({}, { sentinel: true, prefix: "umt-nvlib-" });
|
||||
src = await createTempTree(
|
||||
{ [FAKE]: 200_000, [SUB]: "1\n00:00:01,000 --> 00:00:02,000\n字幕\n" },
|
||||
{ prefix: "umt-nvsrc-" },
|
||||
);
|
||||
h = await createHarness("not_video", {
|
||||
probe: async (p: string) =>
|
||||
p.endsWith(".mkv")
|
||||
? ({ status: "ok", video: [], audio: [], subtitle: [] } as never)
|
||||
: ({ status: "unknown" } as never),
|
||||
});
|
||||
await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" });
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
for (const t of [lib, src]) await t?.cleanup();
|
||||
});
|
||||
|
||||
it("Move:新文件与字幕移入回收站,不登记资产,开待处理;重放不重复", async () => {
|
||||
const entity = await seedEntity(h.db, { kind: "movie", title: "伪装片", year: 2020 });
|
||||
const plan = await planImport(h.d, {
|
||||
sourcePaths: [src.path(FAKE)],
|
||||
mediaEntityId: entity,
|
||||
action: "move",
|
||||
trigger: "manual",
|
||||
actorType: "user",
|
||||
});
|
||||
expect(plan.status).toBe("running");
|
||||
const ex = await executeRecord(h.d, plan.recordId!);
|
||||
expect(ex.status).toBe("failed");
|
||||
|
||||
const files = await h.db
|
||||
.selectFrom("organization_file_result")
|
||||
.select(["role", "status", "skip_reason", "target_path", "error", "media_asset_id"])
|
||||
.where("organization_record_id", "=", plan.recordId!)
|
||||
.orderBy("ordinal")
|
||||
.execute();
|
||||
const main = files.find((f) => f.role === "main")!;
|
||||
const sub = files.find((f) => f.role === "subtitle")!;
|
||||
expect(main).toMatchObject({ status: "failed", media_asset_id: null });
|
||||
expect((main.error as { code: string }).code).toBe("NOT_VIDEO");
|
||||
expect(sub).toMatchObject({ status: "skipped", skip_reason: "main_failed", media_asset_id: null });
|
||||
|
||||
// 库里不留文件,回收站里各一份(Move 时源已删,回收站里是唯一一份)
|
||||
expect(existsSync(join(lib.root, main.target_path!))).toBe(false);
|
||||
expect(existsSync(join(lib.root, sub.target_path!))).toBe(false);
|
||||
expect(existsSync(src.path(FAKE))).toBe(false);
|
||||
const trash = await h.db.selectFrom("trash_item").select(["original_path", "trash_path"]).execute();
|
||||
expect(trash.map((t) => t.original_path).sort()).toEqual([main.target_path, sub.target_path].sort());
|
||||
for (const t of trash) expect(existsSync(t.trash_path)).toBe(true);
|
||||
expect(await h.db.selectFrom("media_asset").select("id").where("media_entity_id", "=", entity).execute()).toEqual(
|
||||
[],
|
||||
);
|
||||
const decisions = await h.db
|
||||
.selectFrom("decision")
|
||||
.select(["title", "status"])
|
||||
.where("dedupe_key", "=", `organize_not_video:${plan.recordId}`)
|
||||
.execute();
|
||||
expect(decisions).toEqual([{ title: "1 个文件不是视频,没有入库", status: "open" }]);
|
||||
|
||||
// 重放:已结束记录不重复移动
|
||||
await executeRecord(h.d, plan.recordId!);
|
||||
expect(await h.db.selectFrom("trash_item").select("id").execute()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createTempTree, type TempTree } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
||||
|
||||
// 季号推断(审视 ④ 风险 9):解析器对有集号没季号的文件已推定第 1 季(seasonInferred),服务端与域层用同一个值;
|
||||
// 带季号与不带季号的文件混在一起时,不带季号的不会因为「季目录不唯一」被静默排除(回归用例)。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const SIZE = 200_000;
|
||||
|
||||
describe.skipIf(!hasPg)("整理:季号推断收敛到一处", () => {
|
||||
let h: Harness;
|
||||
let lib: TempTree;
|
||||
let src: TempTree;
|
||||
|
||||
beforeAll(async () => {
|
||||
lib = await createTempTree({}, { sentinel: true, prefix: "umt-silib-" });
|
||||
src = await createTempTree(
|
||||
{
|
||||
"Pack/Show.S02E01.1080p.WEB-DL.mkv": SIZE,
|
||||
"Pack/Show.E05.1080p.WEB-DL.mkv": SIZE,
|
||||
},
|
||||
{ prefix: "umt-sisrc-" },
|
||||
);
|
||||
h = await createHarness("season_inference");
|
||||
await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" });
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
for (const t of [lib, src]) await t?.cleanup();
|
||||
});
|
||||
|
||||
it("没季号的集按第 1 季入库,不以 no_target_dir 排除", async () => {
|
||||
const entity = await seedEntity(h.db, { kind: "series", title: "剧", year: 2020 });
|
||||
const plan = await planImport(h.d, {
|
||||
sourcePaths: [src.path("Pack")],
|
||||
mediaEntityId: entity,
|
||||
action: "copy",
|
||||
trigger: "manual",
|
||||
actorType: "user",
|
||||
dryRun: true,
|
||||
});
|
||||
const o = plan.plan!.organize;
|
||||
expect(o.excluded.filter((x) => x.reason === "no_target_dir")).toEqual([]);
|
||||
const e05 = o.files.find((f) => f.src.endsWith("Show.E05.1080p.WEB-DL.mkv"))!;
|
||||
expect(e05).toMatchObject({ season: 1, episodes: { start: 5, end: 5 } });
|
||||
expect(e05.dst).toContain("Season 1");
|
||||
expect(o.files.find((f) => f.src.endsWith("S02E01.1080p.WEB-DL.mkv"))!.dst).toContain("Season 2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, rm, stat, utimes, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { cachedSha256 } from "../../src/modules/acquisition-organize/services/organize-plan.ts";
|
||||
|
||||
// 计划期全量 sha256 按 (路径, 大小, 修改时间, inode) 缓存:同一文件没变时不重算,变了就重算。
|
||||
|
||||
const sha = (s: string) => createHash("sha256").update(s).digest("hex");
|
||||
|
||||
describe("计划期 sha256 缓存", () => {
|
||||
let dir = "";
|
||||
afterAll(async () => {
|
||||
if (dir) await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("事实不变命中缓存;修改时间变了重算", async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "umt-shacache-"));
|
||||
const p = join(dir, "a.mkv");
|
||||
await writeFile(p, "AAAA");
|
||||
const t = new Date("2024-01-01T00:00:00Z");
|
||||
await utimes(p, t, t);
|
||||
const facts = async () => {
|
||||
const s = await stat(p);
|
||||
return { size: s.size, mtimeMs: s.mtimeMs, ino: s.ino };
|
||||
};
|
||||
expect(await cachedSha256(p, await facts())).toBe(sha("AAAA"));
|
||||
// 同大小、同修改时间、同 inode 的改写:按缓存(这正是缓存键的语义)
|
||||
await writeFile(p, "BBBB");
|
||||
await utimes(p, t, t);
|
||||
expect(await cachedSha256(p, await facts())).toBe(sha("AAAA"));
|
||||
// 修改时间变了:重算
|
||||
const t2 = new Date("2024-01-02T00:00:00Z");
|
||||
await utimes(p, t2, t2);
|
||||
expect(await cachedSha256(p, await facts())).toBe(sha("BBBB"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { createTempTree, type TempTree } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
||||
|
||||
// 文件级标题粗核:合集 / 混合包里标题明显不是本作品的视频不按本作品命名入库,
|
||||
// 降为待确认(排除原因 title_mismatch),记录部分完成并开待处理;用户逐文件指定过的不核。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const SIZE = 200_000;
|
||||
const PACK = "Nolan.Pack";
|
||||
|
||||
describe.skipIf(!hasPg)("整理:文件标题明显不是本作品时降为待确认", () => {
|
||||
let h: Harness;
|
||||
let lib: TempTree;
|
||||
let src: TempTree;
|
||||
let entity = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
lib = await createTempTree({}, { sentinel: true, prefix: "umt-tclib-" });
|
||||
src = await createTempTree(
|
||||
{
|
||||
[`${PACK}/Inception.2010.1080p.BluRay.x264-GRP.mkv`]: SIZE,
|
||||
[`${PACK}/Interstellar.2014.1080p.BluRay.x264-GRP.mkv`]: SIZE,
|
||||
},
|
||||
{ prefix: "umt-tcsrc-" },
|
||||
);
|
||||
h = await createHarness("title_check");
|
||||
await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" });
|
||||
await h.setSettings("organize", { scrapeAfterImport: false });
|
||||
entity = await seedEntity(h.db, { kind: "movie", title: "盗梦空间", year: 2010 });
|
||||
await h.db.updateTable("media_entity").set({ original_title: "Inception" }).where("id", "=", entity).execute();
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
for (const t of [lib, src]) await t?.cleanup();
|
||||
});
|
||||
|
||||
it("合集里的别的电影:排除 title_mismatch,不入库,记录部分完成并开待处理", async () => {
|
||||
const plan = await planImport(h.d, {
|
||||
sourcePaths: [src.path(PACK)],
|
||||
mediaEntityId: entity,
|
||||
action: "copy",
|
||||
trigger: "auto",
|
||||
actorType: "system",
|
||||
});
|
||||
expect(plan.status).toBe("running");
|
||||
const ex = await executeRecord(h.d, plan.recordId!);
|
||||
expect(ex.status).toBe("partial");
|
||||
const files = await h.db
|
||||
.selectFrom("organization_file_result")
|
||||
.select(["source_path", "status", "skip_reason", "target_path"])
|
||||
.where("organization_record_id", "=", plan.recordId!)
|
||||
.execute();
|
||||
const other = files.find((f) => f.source_path.includes("Interstellar"))!;
|
||||
expect(other).toMatchObject({ status: "skipped", skip_reason: "excluded:title_mismatch", target_path: null });
|
||||
const mine = files.find((f) => f.source_path.includes("Inception"))!;
|
||||
expect(mine.status).toBe("succeeded");
|
||||
expect(existsSync(src.path(`${PACK}/Interstellar.2014.1080p.BluRay.x264-GRP.mkv`))).toBe(true);
|
||||
const decision = await h.db
|
||||
.selectFrom("decision")
|
||||
.select(["summary", "status"])
|
||||
.where("dedupe_key", "=", `organize_excluded:${plan.recordId}`)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(decision.status).toBe("open");
|
||||
expect(decision.summary).toContain("标题不是本作品");
|
||||
});
|
||||
|
||||
it("用户逐文件指定过的不核", async () => {
|
||||
const plan = await planImport(h.d, {
|
||||
sourcePaths: [src.path(PACK)],
|
||||
mediaEntityId: entity,
|
||||
action: "copy",
|
||||
trigger: "manual",
|
||||
actorType: "user",
|
||||
dryRun: true,
|
||||
fileOverrides: {
|
||||
[src.path(`${PACK}/Interstellar.2014.1080p.BluRay.x264-GRP.mkv`)]: { contentUnitIds: [] },
|
||||
},
|
||||
});
|
||||
expect(plan.plan!.organize.excluded.some((x) => x.reason === "title_mismatch")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { createTempTree, type TempTree } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||
import { SCRAPE_TASK, scrapeAfterImport } from "../../src/modules/acquisition-organize/services/scrape.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
||||
|
||||
// 入库后自动刮削(缺省开):只对有 TMDB id 或本地元数据的作品排;没排上(库根只登记、作品没有元数据、排队失败)时
|
||||
// 在整理记录上留一条「未刮削(原因)」的刮削记录;设置关闭是预期,不留。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const SIZE = 200_000;
|
||||
|
||||
describe.skipIf(!hasPg)("入库后自动刮削:缺省开启,没排上时整理记录上可见", () => {
|
||||
let h: Harness;
|
||||
let lib: TempTree;
|
||||
let src: TempTree;
|
||||
let rootId = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
lib = await createTempTree({}, { sentinel: true, prefix: "umt-sailib-" });
|
||||
src = await createTempTree(
|
||||
{
|
||||
"Tenet.2020.1080p.BluRay.x264-GRP.mkv": SIZE,
|
||||
"Dune.2021.1080p.BluRay.x264-GRP.mkv": SIZE,
|
||||
"Arrival.2016.1080p.BluRay.x264-GRP.mkv": SIZE,
|
||||
"Heat.1995.1080p.BluRay.x264-GRP.mkv": SIZE,
|
||||
},
|
||||
{ prefix: "umt-saisrc-" },
|
||||
);
|
||||
h = await createHarness("scrape_after_import");
|
||||
rootId = (await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" })).rootId;
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
for (const t of [lib, src]) await t?.cleanup();
|
||||
});
|
||||
|
||||
async function movie(title: string, opts: { tmdb?: string; metadata?: object } = {}) {
|
||||
const id = await seedEntity(h.db, { kind: "movie", title, year: 2020 });
|
||||
if (opts.metadata)
|
||||
await h.db
|
||||
.updateTable("media_entity")
|
||||
.set({ metadata: JSON.stringify(opts.metadata) })
|
||||
.where("id", "=", id)
|
||||
.execute();
|
||||
if (opts.tmdb)
|
||||
await h.db
|
||||
.insertInto("external_identity")
|
||||
.values({
|
||||
media_entity_id: id,
|
||||
provider: "tmdb",
|
||||
entity_type: "movie",
|
||||
external_id: opts.tmdb,
|
||||
status: "accepted",
|
||||
})
|
||||
.execute();
|
||||
return id;
|
||||
}
|
||||
async function organize(entity: string, file: string) {
|
||||
const plan = await planImport(h.d, {
|
||||
sourcePaths: [src.path(file)],
|
||||
mediaEntityId: entity,
|
||||
action: "copy",
|
||||
trigger: "manual",
|
||||
actorType: "user",
|
||||
});
|
||||
const ex = await executeRecord(h.d, plan.recordId!);
|
||||
expect(ex.status).toBe("succeeded");
|
||||
return plan.recordId!;
|
||||
}
|
||||
const recordsOf = (orgId: string) =>
|
||||
h.db
|
||||
.selectFrom("scrape_record")
|
||||
.select(["status", "reason", "trigger"])
|
||||
.where("organization_record_id", "=", orgId)
|
||||
.execute();
|
||||
|
||||
it("缺省设置:有 TMDB id 的作品入库后排上刮削", async () => {
|
||||
const id = await movie("信条", { tmdb: "577922" });
|
||||
const org = await organize(id, "Tenet.2020.1080p.BluRay.x264-GRP.mkv");
|
||||
expect(await recordsOf(org)).toEqual([{ status: "queued", reason: null, trigger: "auto" }]);
|
||||
expect(h.engine.enqueued.some((x) => x.task === SCRAPE_TASK)).toBe(true);
|
||||
});
|
||||
|
||||
it("没有 TMDB id 也没有本地元数据:不排任务,记「未刮削」scrape_no_metadata;重放不重复记", async () => {
|
||||
const before = h.engine.enqueued.filter((x) => x.task === SCRAPE_TASK).length;
|
||||
const id = await movie("沙丘");
|
||||
const org = await organize(id, "Dune.2021.1080p.BluRay.x264-GRP.mkv");
|
||||
expect(await recordsOf(org)).toEqual([{ status: "skipped", reason: "scrape_no_metadata", trigger: "auto" }]);
|
||||
expect(h.engine.enqueued.filter((x) => x.task === SCRAPE_TASK).length).toBe(before);
|
||||
await executeRecord(h.d, org);
|
||||
expect(await recordsOf(org)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("库根是只登记模式:记 scrape_root_read_only;排队失败:记 task_enqueue_failed", async () => {
|
||||
const id = await movie("降临", { tmdb: "329865" });
|
||||
const org = await organize(id, "Arrival.2016.1080p.BluRay.x264-GRP.mkv");
|
||||
await h.db.deleteFrom("scrape_record").where("organization_record_id", "=", org).execute();
|
||||
await h.db.updateTable("library_root").set({ management_mode: "register_only" }).where("id", "=", rootId).execute();
|
||||
try {
|
||||
await scrapeAfterImport(h.d, id, org);
|
||||
expect(await recordsOf(org)).toEqual([{ status: "skipped", reason: "scrape_root_read_only", trigger: "auto" }]);
|
||||
} finally {
|
||||
await h.db.updateTable("library_root").set({ management_mode: "managed" }).where("id", "=", rootId).execute();
|
||||
}
|
||||
await h.db.deleteFrom("scrape_record").where("organization_record_id", "=", org).execute();
|
||||
const enqueue = h.d.ctx.engine.enqueue;
|
||||
h.d.ctx.engine.enqueue = (async () => {
|
||||
throw new Error("引擎不可用");
|
||||
}) as never;
|
||||
try {
|
||||
await scrapeAfterImport(h.d, id, org);
|
||||
} finally {
|
||||
h.d.ctx.engine.enqueue = enqueue;
|
||||
}
|
||||
expect(await recordsOf(org)).toEqual([{ status: "failed", reason: "task_enqueue_failed", trigger: "auto" }]);
|
||||
});
|
||||
|
||||
it("设置关闭:不排也不记", async () => {
|
||||
await h.setSettings("organize", { scrapeAfterImport: false });
|
||||
const id = await movie("盗火线", { tmdb: "949" });
|
||||
const org = await organize(id, "Heat.1995.1080p.BluRay.x264-GRP.mkv");
|
||||
expect(await recordsOf(org)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import type { TmdbRichSeasonDetail } from "@umt/adapters";
|
||||
import type { ScrapeFileResult, ScrapeMatch } from "@umt/contracts";
|
||||
import { createTempTree, type TempTree } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { queueScrape, runScrapeRecord, scrapeEntity } from "../../src/modules/acquisition-organize/services/scrape.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedBase } from "./harness.ts";
|
||||
|
||||
// 刮削的集与布局:
|
||||
// - 多集文件(文件名 E01-E02 / 覆盖多个集单元)按 Kodi 约定在一个 NFO 里写多个 <episodedetails>;
|
||||
// - 综艺按播出日期命名的期数按日期对上集(本地集单元或 TMDB 季详情)写分集 NFO;
|
||||
// - 作品重刮只刮该作品的视频;同一目录里有别的作品时电影图片按 <视频名>-poster 命名;
|
||||
// - 认不出集号的跳过算部分完成;图片扩展名按实际类型;取消时记录不停在进行中。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const at = new Date().toISOString();
|
||||
const f = (value: unknown) => ({ value, source: "tmdb", language: "zh-CN", at, locked: false });
|
||||
const enc = (s: string) => new TextEncoder().encode(s);
|
||||
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
function seasonOf(n: number, eps: { ep: number; name: string; airDate: string }[]): TmdbRichSeasonDetail {
|
||||
return {
|
||||
tvId: "777",
|
||||
seasonNumber: n,
|
||||
name: `第 ${n} 季`,
|
||||
nameLanguage: "zh-CN",
|
||||
overview: null,
|
||||
overviewLanguage: null,
|
||||
airDate: eps[0]?.airDate ?? null,
|
||||
posterPath: null,
|
||||
fetchedLanguages: ["zh-CN"],
|
||||
episodes: eps.map((e) => ({
|
||||
seasonNumber: n,
|
||||
episodeNumber: e.ep,
|
||||
name: e.name,
|
||||
nameLanguage: "zh-CN",
|
||||
overview: `${e.name}简介`,
|
||||
overviewLanguage: "zh-CN",
|
||||
airDate: e.airDate,
|
||||
runtime: null,
|
||||
stillPath: null,
|
||||
voteAverage: null,
|
||||
voteCount: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe.skipIf(!hasPg)("刮削:多集文件、综艺日期期数、平铺目录、状态", () => {
|
||||
let h: Harness;
|
||||
let lib: TempTree;
|
||||
let rootId = "";
|
||||
let seasonDetail: (n: number) => Promise<TmdbRichSeasonDetail | null> = async () => null;
|
||||
let imageMime = "image/jpeg";
|
||||
|
||||
beforeAll(async () => {
|
||||
lib = await createTempTree(
|
||||
{
|
||||
"TV/双集剧 (2020)/Season 1/Double.S01E01-E02.mkv": 100,
|
||||
"TV/双集剧 (2020)/Season 1/Double.S01E03.mkv": 100,
|
||||
"TV/综艺 (2024)/Season 1/综艺 - 2024-05-03.mkv": 100,
|
||||
"TV/综艺 (2024)/Season 1/综艺 - 2024-05-10.mkv": 100,
|
||||
"TV/综艺 (2024)/Season 1/综艺 - 2024-06-01.mkv": 100,
|
||||
"Movies/盗梦空间 (2010).mkv": 100,
|
||||
"Movies/星际穿越 (2014).mkv": 100,
|
||||
"Single/阿凡达 (2009)/阿凡达 (2009).mkv": 100,
|
||||
},
|
||||
{ sentinel: true, prefix: "umt-screps-" },
|
||||
);
|
||||
h = await createHarness("scrape_eps", {
|
||||
scrapeImage: async (p) => ({ content: imageMime === "image/png" ? PNG : enc(p), mime: imageMime }),
|
||||
scrapeTmdbSeason: async (_tv, n) => seasonDetail(n),
|
||||
});
|
||||
rootId = (await seedBase(h.db, { libraryPath: lib.root, cachePath: lib.root, qbUrl: "http://127.0.0.1:9" })).rootId;
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await lib?.cleanup();
|
||||
});
|
||||
|
||||
async function entity(kind: string, title: string, meta: object, tmdb?: { id: string; type: "movie" | "tv" }) {
|
||||
const id = (
|
||||
await h.db
|
||||
.insertInto("media_entity")
|
||||
.values({ kind: kind as never, title, year: 2020, status: "active", metadata: JSON.stringify(meta) })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
if (tmdb)
|
||||
await h.db
|
||||
.insertInto("external_identity")
|
||||
.values({
|
||||
media_entity_id: id,
|
||||
provider: "tmdb",
|
||||
entity_type: tmdb.type,
|
||||
external_id: tmdb.id,
|
||||
status: "accepted",
|
||||
})
|
||||
.execute();
|
||||
return id;
|
||||
}
|
||||
async function unit(entityId: string, season: number, ep: number | null, meta: object, airDate?: string) {
|
||||
return (
|
||||
await h.db
|
||||
.insertInto("content_unit")
|
||||
.values({
|
||||
media_entity_id: entityId,
|
||||
kind: ep === null ? "season" : "episode",
|
||||
season_number: season,
|
||||
episode_number: ep,
|
||||
status: "confirmed",
|
||||
title: (meta as { name?: { value?: string } }).name?.value ?? null,
|
||||
air_date: airDate ?? null,
|
||||
metadata: JSON.stringify(meta),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
}
|
||||
async function scrape(entityId: string | null, kind: "movie" | "tv", path: string, externalId: string | null = null) {
|
||||
const match: ScrapeMatch = {
|
||||
mediaEntityId: entityId,
|
||||
provider: externalId ? "tmdb" : null,
|
||||
externalId,
|
||||
kind,
|
||||
title: "x",
|
||||
year: null,
|
||||
source: entityId ? "library" : "chosen",
|
||||
};
|
||||
const q = await queueScrape(h.d, {
|
||||
rootId: `lib:${rootId}`,
|
||||
rootPath: lib.root,
|
||||
libraryRootId: rootId,
|
||||
path,
|
||||
match,
|
||||
mode: "overwrite",
|
||||
trigger: "manual",
|
||||
});
|
||||
return runScrapeRecord(h.d, q.recordId);
|
||||
}
|
||||
const byPath = (files: ScrapeFileResult[], suffix: string) => files.find((x) => x.path.endsWith(suffix));
|
||||
const count = (xml: string, re: RegExp) => xml.match(re)?.length ?? 0;
|
||||
|
||||
it("多集文件(文件名 E01-E02):一个 NFO 里每集一个 <episodedetails>,按集号顺序", async () => {
|
||||
const id = await entity("series", "双集剧", { title: f("双集剧") }, { id: "500", type: "tv" });
|
||||
const s = await unit(id, 1, null, { name: f("第 1 季") });
|
||||
for (const [ep, name] of [
|
||||
[1, "开端"],
|
||||
[2, "发展"],
|
||||
[3, "结局"],
|
||||
] as const)
|
||||
await h.db
|
||||
.insertInto("content_unit")
|
||||
.values({
|
||||
media_entity_id: id,
|
||||
parent_unit_id: s,
|
||||
kind: "episode",
|
||||
season_number: 1,
|
||||
episode_number: ep,
|
||||
status: "confirmed",
|
||||
title: name,
|
||||
metadata: JSON.stringify({ name: f(name), overview: f(`${name}简介`) }),
|
||||
})
|
||||
.execute();
|
||||
const r = await scrape(id, "tv", "TV/双集剧 (2020)", "500");
|
||||
expect(r.status).toBe("succeeded");
|
||||
expect(byPath(r.files, "S01E01-E02.nfo")).toMatchObject({ action: "written" });
|
||||
const xml = await readFile(lib.path("TV/双集剧 (2020)/Season 1/Double.S01E01-E02.nfo"), "utf8");
|
||||
expect(count(xml, /<\?xml /g)).toBe(1);
|
||||
expect(count(xml, /<episodedetails>/g)).toBe(2);
|
||||
expect(xml.indexOf("<title>开端</title>")).toBeLessThan(xml.indexOf("<title>发展</title>"));
|
||||
const single = await readFile(lib.path("TV/双集剧 (2020)/Season 1/Double.S01E03.nfo"), "utf8");
|
||||
expect(count(single, /<episodedetails>/g)).toBe(1);
|
||||
});
|
||||
|
||||
it("多集文件(已登记、覆盖两个集单元):按覆盖单元写两块", async () => {
|
||||
const id = await entity("series", "覆盖剧", { title: f("覆盖剧") }, { id: "501", type: "tv" });
|
||||
const s = await unit(id, 1, null, { name: f("第 1 季") });
|
||||
const eps: string[] = [];
|
||||
for (const [ep, name] of [
|
||||
[1, "甲"],
|
||||
[2, "乙"],
|
||||
] as const)
|
||||
eps.push(
|
||||
(
|
||||
await h.db
|
||||
.insertInto("content_unit")
|
||||
.values({
|
||||
media_entity_id: id,
|
||||
parent_unit_id: s,
|
||||
kind: "episode",
|
||||
season_number: 1,
|
||||
episode_number: ep,
|
||||
status: "confirmed",
|
||||
title: name,
|
||||
metadata: JSON.stringify({ name: f(name) }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id,
|
||||
);
|
||||
// 文件名没有集号区间,覆盖单元是 E01 + E02
|
||||
const path = "TV/覆盖剧 (2020)/Season 1/覆盖剧 - S01E01.mkv";
|
||||
await (await import("node:fs/promises")).mkdir(lib.path("TV/覆盖剧 (2020)/Season 1"), { recursive: true });
|
||||
await (await import("node:fs/promises")).writeFile(lib.path(path), "video");
|
||||
const asset = await h.db
|
||||
.insertInto("media_asset")
|
||||
.values({ media_entity_id: id, kind: "video", status: "active" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.insertInto("asset_location")
|
||||
.values({
|
||||
media_asset_id: asset.id,
|
||||
location_kind: "library",
|
||||
library_root_id: rootId,
|
||||
relative_path: path,
|
||||
status: "available",
|
||||
})
|
||||
.execute();
|
||||
for (const u of eps)
|
||||
await h.db
|
||||
.insertInto("asset_unit_coverage")
|
||||
.values({ media_asset_id: asset.id, content_unit_id: u, coverage: "full" })
|
||||
.execute();
|
||||
const r = await scrape(id, "tv", "TV/覆盖剧 (2020)", "501");
|
||||
expect(r.status).toBe("succeeded");
|
||||
const xml = await readFile(lib.path(path.replace(/\.mkv$/, ".nfo")), "utf8");
|
||||
expect(count(xml, /<episodedetails>/g)).toBe(2);
|
||||
expect(xml).toContain("<title>乙</title>");
|
||||
});
|
||||
|
||||
let variety = "";
|
||||
it("综艺按日期命名:按集单元的播出日期对上集,写 <aired> 与集号;对不上的跳过", async () => {
|
||||
const id = await entity("variety", "综艺", { title: f("综艺") }, { id: "777", type: "tv" });
|
||||
variety = id;
|
||||
const s = await unit(id, 1, null, { name: f("第 1 季") });
|
||||
for (const [ep, name, day] of [
|
||||
[1, "第一期", "2024-05-03"],
|
||||
[2, "第二期", "2024-05-10"],
|
||||
] as const)
|
||||
await h.db
|
||||
.insertInto("content_unit")
|
||||
.values({
|
||||
media_entity_id: id,
|
||||
parent_unit_id: s,
|
||||
kind: "episode",
|
||||
season_number: 1,
|
||||
episode_number: ep,
|
||||
status: "confirmed",
|
||||
title: name,
|
||||
air_date: day,
|
||||
metadata: JSON.stringify({ name: f(name), airDate: f(day) }),
|
||||
})
|
||||
.execute();
|
||||
seasonDetail = async () => null;
|
||||
const r = await scrape(id, "tv", "TV/综艺 (2024)", "777");
|
||||
const nfo = await readFile(lib.path("TV/综艺 (2024)/Season 1/综艺 - 2024-05-10.nfo"), "utf8");
|
||||
expect(nfo).toContain("<title>第二期</title>");
|
||||
expect(nfo).toContain("<aired>2024-05-10</aired>");
|
||||
expect(nfo).toContain("<episode>2</episode>");
|
||||
expect(byPath(r.files, "2024-05-03.nfo")).toMatchObject({ action: "written" });
|
||||
// 6 月 1 日这期季表里没有(TMDB 也取不到):跳过,算部分完成
|
||||
expect(byPath(r.files, "2024-06-01.mkv")).toMatchObject({ action: "skipped", reason: "scrape_no_episode" });
|
||||
expect(r.status).toBe("partial");
|
||||
});
|
||||
|
||||
it("综艺按日期命名:本地没有这一期时按 TMDB 季详情的播出日期对集", async () => {
|
||||
seasonDetail = async (n) =>
|
||||
n === 1
|
||||
? seasonOf(1, [
|
||||
{ ep: 1, name: "第一期", airDate: "2024-05-03" },
|
||||
{ ep: 2, name: "第二期", airDate: "2024-05-10" },
|
||||
{ ep: 5, name: "六月特辑", airDate: "2024-06-01" },
|
||||
])
|
||||
: null;
|
||||
const r = await scrape(variety, "tv", "TV/综艺 (2024)", "777");
|
||||
expect(byPath(r.files, "2024-06-01.nfo")).toMatchObject({ action: "written" });
|
||||
const nfo = await readFile(lib.path("TV/综艺 (2024)/Season 1/综艺 - 2024-06-01.nfo"), "utf8");
|
||||
expect(nfo).toContain("<title>六月特辑</title>");
|
||||
expect(nfo).toContain("<episode>5</episode>");
|
||||
expect(r.status).toBe("succeeded");
|
||||
});
|
||||
|
||||
async function registerVideo(entityId: string, path: string) {
|
||||
const a = await h.db
|
||||
.insertInto("media_asset")
|
||||
.values({ media_entity_id: entityId, kind: "video", status: "active" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.insertInto("asset_location")
|
||||
.values({
|
||||
media_asset_id: a.id,
|
||||
location_kind: "library",
|
||||
library_root_id: rootId,
|
||||
relative_path: path,
|
||||
status: "available",
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
it("平铺模板:作品重刮只刮本作品的视频,图片按 <视频名>-poster 命名,不写目录共用的 poster", async () => {
|
||||
const meta = (t: string) => ({ title: f(t), posterPath: f(`/${t}.jpg`), backdropPath: f(`/${t}-bg.jpg`) });
|
||||
const inception = await entity("movie", "盗梦空间", meta("盗梦空间"), { id: "27205", type: "movie" });
|
||||
const interstellar = await entity("movie", "星际穿越", meta("星际穿越"), { id: "157336", type: "movie" });
|
||||
await registerVideo(inception, "Movies/盗梦空间 (2010).mkv");
|
||||
await registerVideo(interstellar, "Movies/星际穿越 (2014).mkv");
|
||||
const q = await scrapeEntity(h.d, inception, { trigger: "manual", mode: "overwrite" });
|
||||
expect(q).toHaveLength(1);
|
||||
const r = await runScrapeRecord(h.d, q[0]!.recordId);
|
||||
expect(r.status).toBe("succeeded");
|
||||
expect(r.videos).toEqual(["Movies/盗梦空间 (2010).mkv"]);
|
||||
expect(r.files.map((x) => x.path).sort()).toEqual([
|
||||
"Movies/盗梦空间 (2010)-fanart.jpg",
|
||||
"Movies/盗梦空间 (2010)-poster.jpg",
|
||||
"Movies/盗梦空间 (2010).nfo",
|
||||
]);
|
||||
await expect(stat(lib.path("Movies/星际穿越 (2014).nfo"))).rejects.toThrow();
|
||||
await expect(stat(lib.path("Movies/poster.jpg"))).rejects.toThrow();
|
||||
expect(await readFile(lib.path("Movies/盗梦空间 (2010).nfo"), "utf8")).toContain("<title>盗梦空间</title>");
|
||||
});
|
||||
|
||||
it("单部电影的目录照旧写 poster / fanart;图片扩展名按实际类型", async () => {
|
||||
const avatar = await entity(
|
||||
"movie",
|
||||
"阿凡达",
|
||||
{ title: f("阿凡达"), posterPath: f("https://img.example.test/avatar.webp") },
|
||||
{ id: "19995", type: "movie" },
|
||||
);
|
||||
await registerVideo(avatar, "Single/阿凡达 (2009)/阿凡达 (2009).mkv");
|
||||
imageMime = "image/png";
|
||||
try {
|
||||
const q = await scrapeEntity(h.d, avatar, { trigger: "manual", mode: "overwrite" });
|
||||
const r = await runScrapeRecord(h.d, q[0]!.recordId);
|
||||
expect(r.status).toBe("succeeded");
|
||||
expect(r.files.find((x) => x.role === "poster")).toMatchObject({
|
||||
path: "Single/阿凡达 (2009)/poster.png",
|
||||
action: "written",
|
||||
});
|
||||
await expect(stat(lib.path("Single/阿凡达 (2009)/poster.webp"))).rejects.toThrow();
|
||||
expect(new Uint8Array(await readFile(lib.path("Single/阿凡达 (2009)/poster.png")))).toEqual(PNG);
|
||||
} finally {
|
||||
imageMime = "image/jpeg";
|
||||
}
|
||||
});
|
||||
|
||||
it("任务被取消:记录收尾为失败(scrape_canceled),不停在进行中", async () => {
|
||||
const id = await entity("movie", "取消", { title: f("取消") }, { id: "1", type: "movie" });
|
||||
const match: ScrapeMatch = {
|
||||
mediaEntityId: id,
|
||||
provider: "tmdb",
|
||||
externalId: "1",
|
||||
kind: "movie",
|
||||
title: "取消",
|
||||
year: null,
|
||||
source: "library",
|
||||
};
|
||||
const q = await queueScrape(h.d, {
|
||||
rootId: `lib:${rootId}`,
|
||||
rootPath: lib.root,
|
||||
libraryRootId: rootId,
|
||||
path: "Single/阿凡达 (2009)",
|
||||
match,
|
||||
trigger: "manual",
|
||||
});
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(runScrapeRecord(h.d, q.recordId, ac.signal)).rejects.toThrow();
|
||||
const row = await h.db
|
||||
.selectFrom("scrape_record")
|
||||
.select(["status", "reason", "finished_at"])
|
||||
.where("id", "=", q.recordId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(row).toMatchObject({ status: "failed", reason: "scrape_canceled" });
|
||||
expect(row.finished_at).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -205,9 +205,14 @@ describe.skipIf(skip)("file manager in-place import", () => {
|
||||
try {
|
||||
const r = await queue(s.items, { scrapeAfterImport: true });
|
||||
expect((await executeRecord(h.module.deps().ao, r.recordId)).status).toBe("succeeded");
|
||||
// 不刮削,只在整理记录上留一条「未刮削(只登记模式)」
|
||||
expect(
|
||||
await h.db.selectFrom("scrape_record").select("id").where("organization_record_id", "=", r.recordId).execute(),
|
||||
).toEqual([]);
|
||||
await h.db
|
||||
.selectFrom("scrape_record")
|
||||
.select(["status", "reason"])
|
||||
.where("organization_record_id", "=", r.recordId)
|
||||
.execute(),
|
||||
).toEqual([{ status: "skipped", reason: "scrape_root_read_only" }]);
|
||||
expect(await readdir(join(h.lib, s.path.split("/")[0]!))).toEqual(["original.name.mkv"]);
|
||||
} finally {
|
||||
await h.db.updateTable("library_root").set({ management_mode: "managed" }).where("id", "=", rootId).execute();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { access, mkdir, mkdtemp, readFile, rm, symlink, utimes, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { findCrossDeviceBase } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
@@ -171,6 +171,21 @@ describe.skipIf(skip)("files 删除与回收站", () => {
|
||||
expect(left.map((x) => x.id)).not.toContain(oldId);
|
||||
});
|
||||
|
||||
it("purge 过期项时,过期且没有登记的 .umt-trash/replaced-* 一并清掉", async () => {
|
||||
const oldBin = join(h.lib, ".umt-trash", "replaced-2020-01-01T00-00-00-000Z-aaaaaa");
|
||||
const newBin = join(h.lib, ".umt-trash", "replaced-2099-01-01T00-00-00-000Z-bbbbbb");
|
||||
await mkdir(oldBin, { recursive: true });
|
||||
await mkdir(newBin, { recursive: true });
|
||||
await writeFile(join(oldBin, "old.mkv"), "o");
|
||||
await writeFile(join(newBin, "new.mkv"), "n");
|
||||
const past = new Date(Date.now() - 30 * 86_400_000);
|
||||
await utimes(oldBin, past, past);
|
||||
const r = await h.api("POST", "/files/trash/purge", { idempotencyKey: key() }, SESSION);
|
||||
expect(r.status).toBe(200);
|
||||
expect(await exists(oldBin)).toBe(false);
|
||||
expect(await exists(join(newBin, "new.mkv"))).toBe(true);
|
||||
});
|
||||
|
||||
it("永久删除默认关闭;API Key 删除需一次性审批", async () => {
|
||||
await writeFile(join(h.lib, "perm.txt"), "p");
|
||||
const p = await del({ items: [{ root: h.libRoot, path: "perm.txt" }], mode: "permanent", dryRun: false });
|
||||
|
||||
@@ -65,5 +65,11 @@
|
||||
"ours": "<视频名>-thumb.jpg",
|
||||
"reason": "Kodi 约定 -thumb 后缀;Jellyfin / Emby 同样识别,且不会和同名海报规则冲突"
|
||||
}
|
||||
]
|
||||
],
|
||||
"multiEpisode": {
|
||||
"case": "s01e01-e02(一个文件覆盖第 1、2 集)",
|
||||
"mp": "只按首集(begin_episode)写一个 <episodedetails>,与 s01e01 相同",
|
||||
"ours": "按 Kodi 多集约定写两个 <episodedetails> 块:第一块同 s01e01,第二块同 s01e02(各自的已登记差异照旧)",
|
||||
"reason": "只写首集时 Kodi 只显示第一集,第二集在媒体库里缺失;Jellyfin / Emby 也读多块 NFO"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fetchRichDetail, fetchRichSeason } from "@umt/adapters";
|
||||
import { episodeNfo, planScrapeTargets, seasonNfo, seasonPosterName, workNfo } from "@umt/domain";
|
||||
import { episodeNfo, multiEpisodeNfo, planScrapeTargets, seasonNfo, seasonPosterName, workNfo } from "@umt/domain";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { nfoEpisode, nfoSeason, nfoWork } from "../../src/modules/acquisition-organize/services/scrape-input.ts";
|
||||
import { richEntityFields } from "../../src/modules/sources-identity/service/metadata-rich.ts";
|
||||
@@ -12,7 +12,8 @@ import { richEntityFields } from "../../src/modules/sources-identity/service/met
|
||||
const read = (f: string) => JSON.parse(readFileSync(new URL(f, import.meta.url), "utf8"));
|
||||
const raw = read("./tmdb-raw.json") as Record<string, unknown>;
|
||||
const mp = read("./scrape-mp.json") as Record<string, string>;
|
||||
const devs = read("./nfo-deviations.json").deviations as { case: string; tag: string }[];
|
||||
const devFile = read("./nfo-deviations.json");
|
||||
const devs = devFile.deviations as { case: string; tag: string }[];
|
||||
|
||||
const LANGS = ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"];
|
||||
const client = {
|
||||
@@ -63,23 +64,23 @@ async function ours(): Promise<Record<string, string>> {
|
||||
metadata: { name: { value: sd.name }, overview: { value: sd.overview }, airDate: { value: sd.airDate } },
|
||||
}),
|
||||
);
|
||||
for (const e of sd.episodes) {
|
||||
out[`s01e${String(e.episodeNumber).padStart(2, "0")}`] = episodeNfo(
|
||||
nfoEpisode(1, e.episodeNumber, {
|
||||
season_number: 1,
|
||||
episode_number: e.episodeNumber,
|
||||
title: e.name,
|
||||
air_date: e.airDate,
|
||||
metadata: {
|
||||
name: { value: e.name },
|
||||
overview: { value: e.overview },
|
||||
airDate: { value: e.airDate },
|
||||
runtime: { value: e.runtime },
|
||||
voteAverage: { value: e.voteAverage },
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
const eps = sd.episodes.map((e) =>
|
||||
nfoEpisode(1, e.episodeNumber, {
|
||||
season_number: 1,
|
||||
episode_number: e.episodeNumber,
|
||||
title: e.name,
|
||||
air_date: e.airDate,
|
||||
metadata: {
|
||||
name: { value: e.name },
|
||||
overview: { value: e.overview },
|
||||
airDate: { value: e.airDate },
|
||||
runtime: { value: e.runtime },
|
||||
voteAverage: { value: e.voteAverage },
|
||||
},
|
||||
}),
|
||||
);
|
||||
for (const e of eps) out[`s01e${String(e.episode).padStart(2, "0")}`] = episodeNfo(e);
|
||||
out["s01e01-e02"] = multiEpisodeNfo(eps.slice(0, 2));
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -109,6 +110,24 @@ describe("NFO 与 MP 差分", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("多集文件:按登记的偏离写多个 <episodedetails> 块,每块与 MP 对应单集一致(MP 只写首集)", () => {
|
||||
expect(devFile.multiEpisode?.reason).toBeTruthy();
|
||||
const xml = mine["s01e01-e02"]!;
|
||||
const [decl, ...body] = xml.split("\n");
|
||||
expect(decl).toBe(mp.s01e01!.split("\n")[0]);
|
||||
const blocks = body
|
||||
.join("\n")
|
||||
.split(/(?=<episodedetails>)/)
|
||||
.filter((b) => b.trim());
|
||||
expect(blocks).toHaveLength(2);
|
||||
for (const [i, c] of ["s01e01", "s01e02"].entries()) {
|
||||
const skip = new Set(devs.filter((d) => d.case === c).map((d) => d.tag));
|
||||
const a = normalize(mp[c]!).filter((l) => !skip.has(tagOf(l) ?? "") && !l.startsWith("<?xml"));
|
||||
const b = normalize(blocks[i]!).filter((l) => !skip.has(tagOf(l) ?? ""));
|
||||
expect(b).toEqual(a);
|
||||
}
|
||||
});
|
||||
|
||||
it("编码与声明:UTF-8、两空格缩进、末尾换行,和 MP 一致", () => {
|
||||
for (const c of cases) {
|
||||
expect(mine[c]!.split("\n")[0]).toBe(mp[c]!.split("\n")[0]);
|
||||
|
||||
Reference in new issue
Block a user