feat(organize): 整理、季包选文件、刮削、文件管理识别统一套用集数映射;单集沿用资源判定,待确认文件不按原号入库
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
691671a1d2
commit
fe597187dd
12 files changed
+848
-21
No files matched your search
@@ -0,0 +1,401 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
import { type EpisodeResolution, parseRelease, resolveEpisodes } from "@umt/domain";
|
||||
import { toParsedColumns } from "@umt/mapping";
|
||||
import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { submitDownload, syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
|
||||
import { type FrozenPlan, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||
import { queueScrape, runScrapeRecord } from "../../src/modules/acquisition-organize/services/scrape.ts";
|
||||
import { canonicalParsed } from "../../src/modules/sources-identity/service/episode-resolution.ts";
|
||||
import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
||||
|
||||
// 文件侧集数映射(docs/plan/episode-resolution.md 批次 3):整理计划与季包选文件对文件名重新解析出的季集,
|
||||
// 按作品季表 + 映射规则换成规范季集。ANi 连续编号 `- 25` 下载时认作 S02E13,整理时不能又按文件名认成 S01E25。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const SIZE = 300_000;
|
||||
|
||||
// 已完结:S1 = 12、S2 = 13
|
||||
const TABLE = {
|
||||
seasons: [
|
||||
{ season: 1, total: 12 },
|
||||
{ season: 2, total: 13 },
|
||||
],
|
||||
latestSeason: 2,
|
||||
ended: true,
|
||||
};
|
||||
const ani = (ep: number) => `[ANi] Kusuriya no Hitorigoto - ${ep} [1080P].mkv`;
|
||||
|
||||
describe.skipIf(!hasPg)("整理与季包选文件按作品映射集数(真实 PG + mock qB + 临时目录)", () => {
|
||||
let h: Harness;
|
||||
let qb: MockQb;
|
||||
let lib: TempTree;
|
||||
let cache: TempTree;
|
||||
let src: TempTree;
|
||||
let base: Awaited<ReturnType<typeof seedBase>>;
|
||||
const disk = fakeDisk(10_000_000_000);
|
||||
const torrents = new Map<string, Uint8Array>();
|
||||
let n = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
qb = await createMockQb({ addVisibleAfterPolls: 0 });
|
||||
lib = await createTempTree({}, { sentinel: true, prefix: "umt-eplib-" });
|
||||
cache = await createTempTree({}, { prefix: "umt-epcache-" });
|
||||
const pack: Record<string, number> = {};
|
||||
for (let e = 13; e <= 25; e++) pack[`pack/${ani(e)}`] = SIZE;
|
||||
src = await createTempTree(
|
||||
{
|
||||
...pack,
|
||||
[`single/${ani(25)}`]: SIZE,
|
||||
[`over/${ani(40)}`]: SIZE,
|
||||
"rule/[ANi] Rule Show - 14 [1080P].mkv": SIZE,
|
||||
"plain/Plain.Show.S01E05.1080p.WEB-DL.x264-GRP.mkv": SIZE,
|
||||
},
|
||||
{ prefix: "umt-epsrc-" },
|
||||
);
|
||||
disk.state.watch = [cache.root];
|
||||
disk.setPct(10);
|
||||
h = await createHarness(
|
||||
"episode_files",
|
||||
{
|
||||
diskUsage: disk.fn,
|
||||
torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }),
|
||||
},
|
||||
{ withSources: true },
|
||||
);
|
||||
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await qb?.close();
|
||||
for (const t of [lib, cache, src]) await t?.cleanup();
|
||||
});
|
||||
|
||||
/** 剧集作品 + 季表(季单元总集数 + 已完结) */
|
||||
async function series(title: string, table = TABLE) {
|
||||
const id = await seedEntity(h.db, { kind: "series", title, year: 2023 });
|
||||
await h.db
|
||||
.updateTable("media_entity")
|
||||
.set({ metadata: JSON.stringify({ airStatus: { value: "Ended", source: "tmdb" } }) })
|
||||
.where("id", "=", id)
|
||||
.execute();
|
||||
const units: Record<number, string> = {};
|
||||
for (const s of table.seasons) {
|
||||
units[s.season] = (
|
||||
await h.db
|
||||
.insertInto("content_unit")
|
||||
.values({
|
||||
media_entity_id: id,
|
||||
kind: "season",
|
||||
season_number: s.season,
|
||||
episode_total: s.total,
|
||||
status: "confirmed",
|
||||
source: "tmdb",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
}
|
||||
return { id, units };
|
||||
}
|
||||
|
||||
/** 已识别的资源:parsed 为规范季集,episode_resolution 为判定结果(evidence 可追加标记,验证整理沿用的是它) */
|
||||
async function release(entityId: string, title: string, opts: { files?: string[]; mark?: string } = {}) {
|
||||
const raw = parseRelease(title);
|
||||
const resolution: EpisodeResolution = resolveEpisodes({ parsed: raw, kind: "series", table: TABLE, rules: [] });
|
||||
if (opts.mark) resolution.evidence.push(opts.mark);
|
||||
const cols = toParsedColumns(canonicalParsed(raw, resolution));
|
||||
const t = opts.files
|
||||
? makeTorrent({
|
||||
name: title,
|
||||
files: opts.files.map((path) => ({ path, length: SIZE })),
|
||||
pieceSeed: Math.floor(Math.random() * 1e6),
|
||||
})
|
||||
: makeTorrent({ name: `${title}.mkv`, length: SIZE, pieceSeed: Math.floor(Math.random() * 1e6) });
|
||||
const id = (
|
||||
await h.db
|
||||
.insertInto("source_release")
|
||||
.values({
|
||||
site_id: base.siteId,
|
||||
site_release_id: `${Math.floor(Math.random() * 1e9)}`,
|
||||
title,
|
||||
size_bytes: SIZE,
|
||||
infohash_v1: t.hash,
|
||||
media_entity_id: entityId,
|
||||
resolution_status: "resolved",
|
||||
parsed: JSON.stringify(cols.parsed),
|
||||
parser_version: cols.parserVersion,
|
||||
episode_resolution: JSON.stringify(resolution),
|
||||
raw: JSON.stringify({ _site: { hr: false } }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
torrents.set(id, t.bytes);
|
||||
return { id, hash: t.hash, resolution };
|
||||
}
|
||||
|
||||
/** qB 里已完成的下载任务(接管进来),关联到资源 */
|
||||
async function completedTask(releaseId: string, name: string, file: string) {
|
||||
n++;
|
||||
const hash = n.toString(16).padStart(40, "b");
|
||||
qb.seed({ hash, name, tags: ["umt"], save_path: cache.root });
|
||||
await qb.complete(hash, { files: [{ path: `${name}/${file}`, size: SIZE }] });
|
||||
await syncDownloads(h.d);
|
||||
const dt = await h.db
|
||||
.selectFrom("download_task")
|
||||
.select("id")
|
||||
.where("torrent_hash", "=", hash)
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db.updateTable("download_task").set({ source_release_id: releaseId }).where("id", "=", dt.id).execute();
|
||||
return dt.id;
|
||||
}
|
||||
|
||||
const dryRun = async (entityId: string, dir: string, extra: Partial<Parameters<typeof planImport>[1]> = {}) => {
|
||||
const r = await planImport(h.d, {
|
||||
sourcePaths: [src.path(dir)],
|
||||
mediaEntityId: entityId,
|
||||
action: "copy",
|
||||
trigger: "manual",
|
||||
actorType: "user",
|
||||
dryRun: true,
|
||||
...extra,
|
||||
});
|
||||
expect(r.plan, JSON.stringify(r)).toBeDefined();
|
||||
return r.plan!;
|
||||
};
|
||||
const mains = (plan: FrozenPlan) =>
|
||||
plan.organize.files
|
||||
.filter((f) => f.role === "main")
|
||||
.map((f) => ({ src: basename(f.src), dst: basename(f.dst), season: f.season, episodes: f.episodes }));
|
||||
|
||||
it("关联资源 `- 25` 已认作 S02E13:下载文件 `- 25` 整理命名与单元为 S02E13,沿用资源的判定", async () => {
|
||||
const { id } = await series("药屋少女的呢喃");
|
||||
const rel = await release(id, "[ANi] Kusuriya no Hitorigoto - 25 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]", {
|
||||
mark: "test:from_release",
|
||||
});
|
||||
expect(rel.resolution.canonical).toEqual({ season: 2, episodes: { start: 13, end: 13 } });
|
||||
const dtId = await completedTask(rel.id, "[ANi] Kusuriya no Hitorigoto - 25", ani(25));
|
||||
const r = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system", dryRun: true });
|
||||
expect(r.status, JSON.stringify(r)).toBe("queued");
|
||||
const plan = r.plan!;
|
||||
const [m] = mains(plan);
|
||||
expect(m).toMatchObject({ season: 2, episodes: { start: 13, end: 13 } });
|
||||
expect(m!.dst).toMatch(/S02E13/);
|
||||
expect(m!.dst).not.toMatch(/S01E25/);
|
||||
const fact = Object.values(plan.facts)[0]!;
|
||||
expect(fact).toMatchObject({ season: 2, episodes: { start: 13, end: 13 }, unitKeys: ["s2e13"] });
|
||||
expect(fact.episodeResolution).toMatchObject({
|
||||
method: "absolute_cross_season",
|
||||
source: { season: 1, episodes: { start: 25, end: 25 } },
|
||||
});
|
||||
expect(fact.episodeResolution!.evidence).toContain("test:from_release");
|
||||
});
|
||||
|
||||
it("季包 `- 13`…`- 25`(无季号)逐文件映射到 S02E01…S02E13", async () => {
|
||||
const { id } = await series("季包作品");
|
||||
const plan = await dryRun(id, "pack");
|
||||
const got = mains(plan).sort((a, b) => a.episodes!.start - b.episodes!.start);
|
||||
expect(got).toHaveLength(13);
|
||||
got.forEach((m, i) => {
|
||||
expect(m.season).toBe(2);
|
||||
expect(m.episodes).toEqual({ start: i + 1, end: i + 1 });
|
||||
expect(m.dst).toMatch(new RegExp(`S02E${String(i + 1).padStart(2, "0")}`));
|
||||
});
|
||||
expect(Object.values(plan.facts).every((f) => f.season === 2)).toBe(true);
|
||||
});
|
||||
|
||||
it("用户逐文件指定的季集优先,不被映射覆盖", async () => {
|
||||
const { id } = await series("指定集数作品");
|
||||
const file = src.path(`single/${ani(25)}`);
|
||||
const plan = await dryRun(id, "single", {
|
||||
fileOverrides: { [file]: { season: 1, episodes: { start: 7, end: 7 } } },
|
||||
});
|
||||
const [m] = mains(plan);
|
||||
expect(m).toMatchObject({ season: 1, episodes: { start: 7, end: 7 } });
|
||||
expect(m!.dst).toMatch(/S01E07/);
|
||||
expect(plan.facts[file]).toMatchObject({ season: 1, unitKeys: ["s1e7"] });
|
||||
expect(plan.facts[file]!.episodeResolution).toBeUndefined();
|
||||
});
|
||||
|
||||
it("文件集数待确认:不按原号命名,整理失败原因 episode_unresolved,并入关联资源的 episode_mapping 待处理", async () => {
|
||||
const { id } = await series("待确认作品");
|
||||
// 没有关联资源:只看计划
|
||||
const dry = await planImport(h.d, {
|
||||
sourcePaths: [src.path("over")],
|
||||
mediaEntityId: id,
|
||||
action: "copy",
|
||||
trigger: "manual",
|
||||
actorType: "user",
|
||||
dryRun: true,
|
||||
});
|
||||
expect(dry.status).toBe("failed");
|
||||
expect(dry.reason).toBe("episode_unresolved");
|
||||
expect(dry.plan!.organize.files).toHaveLength(0);
|
||||
expect(dry.plan!.organize.excluded).toEqual([
|
||||
expect.objectContaining({ reason: "episode_unresolved", detail: expect.stringContaining("S01E40") }),
|
||||
]);
|
||||
expect(Object.values(dry.plan!.unresolvedEpisodes ?? {})[0]).toMatchObject({
|
||||
method: "unresolved",
|
||||
source: { season: 1, episodes: { start: 40, end: 40 } },
|
||||
});
|
||||
|
||||
// 关联资源(同样待确认):记录失败 + 待处理
|
||||
const rel = await release(id, "[ANi] Kusuriya no Hitorigoto - 40 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]");
|
||||
expect(rel.resolution.method).toBe("unresolved");
|
||||
const dtId = await completedTask(rel.id, "[ANi] Kusuriya no Hitorigoto - 40", ani(40));
|
||||
const r = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system" });
|
||||
expect(r).toMatchObject({ status: "failed", reason: "episode_unresolved" });
|
||||
const files = await h.db
|
||||
.selectFrom("organization_file_result")
|
||||
.select(["target_path", "skip_reason"])
|
||||
.where("organization_record_id", "=", r.recordId!)
|
||||
.execute();
|
||||
expect(files.every((f) => !f.target_path?.includes("S01E40"))).toBe(true);
|
||||
expect(files.map((f) => f.skip_reason)).toContain("excluded:episode_unresolved");
|
||||
const decision = await h.db
|
||||
.selectFrom("decision")
|
||||
.select(["evidence", "status"])
|
||||
.where("kind", "=", "episode_mapping")
|
||||
.where("subject_id", "=", id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(decision.evidence).toMatchObject({ sourceReleaseIds: [rel.id] });
|
||||
});
|
||||
|
||||
it("有 active 规则 EP-12(→ 第 2 季)时整理按规则", async () => {
|
||||
const { id } = await series("规则作品");
|
||||
const rule = await h.db
|
||||
.insertInto("episode_mapping_rule")
|
||||
.values({
|
||||
media_entity_id: id,
|
||||
season: 1,
|
||||
release_group: "ANi",
|
||||
kind: "expression",
|
||||
expression: "EP-12",
|
||||
target_season: 2,
|
||||
created_by_type: "user",
|
||||
created_by_id: "admin",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const plan = await dryRun(id, "rule");
|
||||
const [m] = mains(plan);
|
||||
expect(m).toMatchObject({ season: 2, episodes: { start: 2, end: 2 } });
|
||||
expect(m!.dst).toMatch(/S02E02/);
|
||||
expect(Object.values(plan.facts)[0]!.episodeResolution).toMatchObject({ method: "rule", ruleId: rule.id });
|
||||
});
|
||||
|
||||
it("原号本来就对(S01E05):整理行为不变", async () => {
|
||||
const { id } = await series("原号作品");
|
||||
const plan = await dryRun(id, "plain");
|
||||
const [m] = mains(plan);
|
||||
expect(m).toMatchObject({ season: 1, episodes: { start: 5, end: 5 } });
|
||||
expect(m!.dst).toMatch(/S01E05/);
|
||||
const fact = Object.values(plan.facts)[0]!;
|
||||
expect(fact).toMatchObject({ season: 1, unitKeys: ["s1e5"] });
|
||||
expect(fact.episodeResolution).toMatchObject({ method: "raw" });
|
||||
});
|
||||
|
||||
it("刮削:未登记的 `- 25` 按作品映射成 S02E13 对集元数据;待确认的 `- 40` 不写分集 NFO", async () => {
|
||||
const { id } = await series("刮削作品");
|
||||
await h.db
|
||||
.updateTable("media_entity")
|
||||
.set({
|
||||
metadata: JSON.stringify({
|
||||
airStatus: { value: "Ended", source: "tmdb" },
|
||||
title: { value: "刮削作品", source: "tmdb", language: "zh-CN", at: new Date().toISOString(), locked: false },
|
||||
}),
|
||||
})
|
||||
.where("id", "=", id)
|
||||
.execute();
|
||||
await h.db
|
||||
.insertInto("content_unit")
|
||||
.values({
|
||||
media_entity_id: id,
|
||||
kind: "episode",
|
||||
season_number: 2,
|
||||
episode_number: 13,
|
||||
status: "confirmed",
|
||||
source: "tmdb",
|
||||
title: "第二季终集",
|
||||
})
|
||||
.execute();
|
||||
const dir = "刮削作品 (2023)";
|
||||
await mkdir(join(lib.root, dir), { recursive: true });
|
||||
for (const ep of [25, 40]) await writeFile(join(lib.root, dir, ani(ep)), "v");
|
||||
const q = await queueScrape(h.d, {
|
||||
rootId: `lib:${base.rootId}`,
|
||||
rootPath: lib.root,
|
||||
libraryRootId: base.rootId,
|
||||
path: dir,
|
||||
match: {
|
||||
mediaEntityId: id,
|
||||
provider: null,
|
||||
externalId: null,
|
||||
kind: "tv",
|
||||
title: "刮削作品",
|
||||
year: 2023,
|
||||
source: "chosen",
|
||||
},
|
||||
trigger: "manual",
|
||||
});
|
||||
const r = await runScrapeRecord(h.d, q.recordId);
|
||||
expect(r.files.find((f) => f.path === `${dir}/${ani(40)}`)).toMatchObject({
|
||||
action: "skipped",
|
||||
reason: "episode_unresolved",
|
||||
});
|
||||
const nfo = await readFile(join(lib.root, dir, ani(25).replace(/\.mkv$/, ".nfo")), "utf8");
|
||||
expect(nfo).toContain("<title>第二季终集</title>");
|
||||
expect(nfo).toMatch(/<season>2<\/season>\n {2}<episode>13<\/episode>/);
|
||||
});
|
||||
|
||||
it("季包选文件:订阅缺 S02E03,季包 `- 13`…`- 25` 只下 `- 15`", async () => {
|
||||
const { id } = await series("季包选文件作品");
|
||||
const sub = await h.db
|
||||
.insertInto("subscription")
|
||||
.values({ media_entity_id: id, media_kind: "series", mode: "continuous", status: "active" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const slot = await h.db
|
||||
.insertInto("acquisition_slot")
|
||||
.values({
|
||||
media_entity_id: id,
|
||||
subscription_id: sub.id,
|
||||
name: "1080p",
|
||||
hard_conditions: JSON.stringify({}),
|
||||
upgrade_threshold: JSON.stringify({}),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const names: string[] = [];
|
||||
for (let e = 13; e <= 25; e++) names.push(ani(e));
|
||||
const rel = await release(id, "[ANi] Kusuriya no Hitorigoto [13-25][1080P][Baha][WEB-DL]", { files: names });
|
||||
const plan = await h.db
|
||||
.insertInto("acquisition_plan")
|
||||
.values({
|
||||
slot_id: slot.id,
|
||||
source_release_id: rel.id,
|
||||
status: "reserved",
|
||||
evaluation: JSON.stringify({ instanceId: base.instanceId, savePath: cache.root, mediaEntityId: id }),
|
||||
payload_hash: `ph-${rel.id}`,
|
||||
idempotency_key: `ik-${rel.id}`,
|
||||
unit_keys: ["s2e3"],
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const s = await submitDownload(h.d, plan.id);
|
||||
expect(s.status, JSON.stringify(s)).toBe("submitted");
|
||||
const row = await h.db
|
||||
.selectFrom("acquisition_plan")
|
||||
.select(["file_selection", "unit_keys"])
|
||||
.where("id", "=", plan.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(row.file_selection).toMatchObject({ mode: "selected", selectedUnits: ["s2e3"] });
|
||||
expect(row.unit_keys).toEqual(["s2e3"]);
|
||||
const t = qb.get(rel.hash)!;
|
||||
const on = t.files.filter((f) => f.priority !== 0).map((f) => f.name.split("/").pop());
|
||||
expect(on).toEqual([ani(15)]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user