fix(organize): resume original imports after episode corrections

This commit is contained in:
root committed 2026-10-13 16:50:51 +08:00
1 parent 679389f854
commit e874247e7c
17 files changed
+702 -26

No files matched your search

@@ -0,0 +1,406 @@
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
executeRecord,
type FrozenPlan,
planImport,
} from "../../src/modules/acquisition-organize/services/organize.ts";
import {
resumeEpisodeOrganize,
sweepEpisodeOrganize,
} from "../../src/modules/acquisition-organize/services/organize-episode-recovery.ts";
import { checkRecovery } from "../../src/modules/agent/investigate.ts";
import { reresolveEntityEpisodes } from "../../src/modules/sources-identity/service/episode-resolution.ts";
import { createFmHarness, type FmHarness, pgAvailable, seedDownload, seedEntity } from "../file-manager/harness.ts";
import { setWordsText } from "../support/identify-words.ts";
import { seedRelease } from "./harness.ts";
describe.skipIf(!(await pgAvailable()))("季集事实补齐后继续原整理", () => {
let h: FmHarness;
beforeAll(async () => {
h = await createFmHarness("episode_resume", {}, { runTransfers: false });
});
afterAll(async () => {
await h?.close();
});
async function show(title: string) {
const id = await seedEntity(h.db, { title, kind: "series", year: 2024 });
await h.db
.updateTable("media_entity")
.set({ metadata: JSON.stringify({ airStatus: { value: "Ended", source: "tmdb" } }) })
.where("id", "=", id)
.execute();
await h.db
.insertInto("content_unit")
.values({
media_entity_id: id,
kind: "season",
season_number: 1,
episode_total: 12,
status: "confirmed",
source: "tmdb",
})
.execute();
return id;
}
it("按路径整理无需订阅也保留全部文件样本,修正规则后恢复,重放不重复排队", async () => {
const entity = await show("Episode Resume");
const paths = ["Episode.Resume.S02E01.1080p.mkv", "Episode.Resume.S02E02.1080p.mkv"].map((name) =>
join(h.dl, name),
);
for (const path of paths) await writeFile(path, path.padEnd(128, "x"));
const original = await planImport(h.module.deps().ao, {
sourcePaths: paths,
mediaEntityId: entity,
trigger: "manual",
actorType: "user",
action: "copy",
targetDir: join(h.lib, "preferred"),
namingMode: "keep",
scrapeAfterImport: false,
minSizeBytes: 0,
});
expect(original.status).toBe("failed");
const decision = await h.db
.selectFrom("decision")
.selectAll()
.where("kind", "=", "episode_mapping")
.where("subject_id", "=", entity)
.executeTakeFirstOrThrow();
expect((decision.evidence as { fileSamples: unknown[] }).fileSamples).toHaveLength(2);
expect(
(
await checkRecovery(h.module.deps().ao.ctx, [
{ capabilityId: "episode_mapping.apply_rule", input: { decisionId: decision.id }, status: "succeeded" },
])
).state,
).toBe("pending");
expect(await resumeEpisodeOrganize(h.module.deps().ao, entity)).toEqual({ resumed: 0, waiting: 1 });
await setWordsText(h.db, entity, "S02 >> S01 EP\n");
await reresolveEntityEpisodes(h.module.deps().ao.ctx, h.db, entity);
expect(
await h.db.selectFrom("outbox_event").select("id").where("type", "=", "media.episode_mapping_changed").execute(),
).not.toHaveLength(0);
const concurrent = await Promise.all([
resumeEpisodeOrganize(h.module.deps().ao, entity),
resumeEpisodeOrganize(h.module.deps().ao, entity),
]);
expect(concurrent.every((r) => r.waiting === 0)).toBe(true);
expect(
await h.db
.selectFrom("organization_record")
.select("id")
.where("media_entity_id", "=", entity)
.where("id", "!=", original.recordId!)
.execute(),
).toHaveLength(1);
expect(
h.engine.enqueued.filter(
(e) =>
e.task === "verifyAndImport" &&
(e.input as { recordId?: string }).recordId &&
(e.input as { recordId?: string }).recordId !== original.recordId,
),
).toHaveLength(1);
const next = await h.db
.selectFrom("organization_record")
.selectAll()
.where("media_entity_id", "=", entity)
.where("id", "!=", original.recordId!)
.executeTakeFirstOrThrow();
const plan = next.plan as unknown as FrozenPlan;
expect(plan.request).toMatchObject({
action: "copy",
targetDir: join(h.lib, "preferred"),
namingMode: "keep",
scrapeAfterImport: false,
});
expect(plan.retryOfRecordId).toBe(original.recordId);
expect(await resumeEpisodeOrganize(h.module.deps().ao, entity)).toEqual({ resumed: 0, waiting: 0 });
expect((await executeRecord(h.module.deps().ao, next.id)).status).toBe("succeeded");
const future = await planImport(h.module.deps().ao, {
sourcePaths: [paths[0]!],
mediaEntityId: entity,
trigger: "manual",
actorType: "user",
dryRun: true,
minSizeBytes: 0,
});
expect(Object.values(future.plan!.facts)[0]!.episodeResolution).toMatchObject({
method: "rule",
canonical: { season: 1, episodes: { start: 1, end: 1 } },
});
});
it("部分移动仅继续剩余文件,已入库内容保持原样", async () => {
const entity = await show("Partial Resume");
const first = join(h.dl, "Partial.Resume.S01E01.1080p.mkv");
const second = join(h.dl, "Partial.Resume.S02E02.1080p.mkv");
await writeFile(first, "first".padEnd(128, "x"));
await writeFile(second, "second".padEnd(128, "y"));
const original = await planImport(h.module.deps().ao, {
sourcePaths: [first, second],
mediaEntityId: entity,
trigger: "manual",
actorType: "user",
action: "move",
scrapeAfterImport: false,
minSizeBytes: 0,
});
expect((await executeRecord(h.module.deps().ao, original.recordId!)).status).toBe("partial");
const decision = await h.db
.selectFrom("decision")
.select("evidence")
.where("kind", "=", "episode_mapping")
.where("subject_id", "=", entity)
.executeTakeFirstOrThrow();
expect((decision.evidence as { fileSamples: { path: string }[] }).fileSamples.map((f) => f.path).sort()).toEqual(
[first, second].sort(),
);
await expect(access(first)).rejects.toThrow();
const record = await h.db
.selectFrom("organization_record")
.select("plan")
.where("id", "=", original.recordId!)
.executeTakeFirstOrThrow();
const target = (record.plan as unknown as FrozenPlan).organize.files.find((f) => f.src === first)!.dst;
const content = await readFile(target, "utf8");
await setWordsText(h.db, entity, "S02 >> S01 EP\n");
expect(await resumeEpisodeOrganize(h.module.deps().ao, entity)).toEqual({ resumed: 1, waiting: 0 });
const next = await h.db
.selectFrom("organization_record")
.selectAll()
.where("media_entity_id", "=", entity)
.where("id", "!=", original.recordId!)
.executeTakeFirstOrThrow();
expect((next.plan as unknown as FrozenPlan).request?.sourcePaths).toEqual([second]);
expect((await executeRecord(h.module.deps().ao, next.id)).status).toBe("succeeded");
expect(await readFile(target, "utf8")).toBe(content);
expect(
await h.db
.selectFrom("media_asset")
.select("id")
.where("media_entity_id", "=", entity)
.where("status", "=", "active")
.execute(),
).toHaveLength(2);
});
it("下载任务部分移动后仍可恢复,不重新检查已经移走的源文件", async () => {
const entity = await show("Download Resume");
const name = "Download.Resume";
const dir = join(h.dl, name);
await mkdir(dir);
const paths = ["Download.Resume.S01E01.1080p.mkv", "Download.Resume.S02E02.1080p.mkv"].map((f) => join(dir, f));
for (const path of paths) await writeFile(path, path.padEnd(128, "x"));
const dt = await seedDownload(h.db, h, { name, status: "completed" });
const site = await h.db
.insertInto("site")
.values({ key: "resume", name: "恢复测试站", adapter: "nexusphp", base_url: "https://pt.example.test" })
.returning("id")
.executeTakeFirstOrThrow();
const release = await seedRelease(h.db, {
siteId: site.id,
entityId: entity,
title: name,
size: 256,
hash: "a".repeat(40),
hr: false,
});
await h.db
.updateTable("download_task")
.set({
media_entity_id: entity,
source_release_id: release,
file_list: JSON.stringify(paths.map((p) => ({ path: p.slice(h.dl.length + 1), size: 128 }))),
})
.where("id", "=", dt)
.execute();
const original = await planImport(h.module.deps().ao, {
downloadTaskId: dt,
mediaEntityId: entity,
trigger: "manual",
actorType: "user",
action: "move",
scrapeAfterImport: false,
minSizeBytes: 0,
});
expect((await executeRecord(h.module.deps().ao, original.recordId!)).status).toBe("partial");
await expect(access(paths[0]!)).rejects.toThrow();
await setWordsText(h.db, entity, "S02 >> S01 EP\n");
expect(await resumeEpisodeOrganize(h.module.deps().ao, entity)).toEqual({ resumed: 1, waiting: 0 });
const next = await h.db
.selectFrom("organization_record")
.selectAll()
.where("download_task_id", "=", dt)
.where("id", "!=", original.recordId!)
.executeTakeFirstOrThrow();
expect((next.plan as unknown as FrozenPlan).request).toMatchObject({
downloadTaskId: dt,
sourcePaths: [paths[1]!],
});
expect((await executeRecord(h.module.deps().ao, next.id)).status).toBe("succeeded");
expect(
await h.db
.selectFrom("media_asset")
.select("id")
.where("media_entity_id", "=", entity)
.where("status", "=", "active")
.execute(),
).toHaveLength(2);
});
it("补齐在播季表后沿用原资源发布时间,恢复正确的绝对集号换算", async () => {
const entity = await show("Dated Resume");
const name = "Dated.Resume.S02E13.1080p.mkv";
const path = join(h.dl, name);
await writeFile(path, "dated".padEnd(128, "x"));
const dt = await seedDownload(h.db, h, { name, status: "completed" });
const site = await h.db
.insertInto("site")
.values({
key: "dated-resume",
name: "发布时间测试站",
adapter: "nexusphp",
base_url: "https://dated.example.test",
})
.returning("id")
.executeTakeFirstOrThrow();
const release = await seedRelease(h.db, {
siteId: site.id,
entityId: entity,
title: name,
size: 128,
hash: "b".repeat(40),
});
await h.db
.updateTable("source_release")
.set({ source_published_at: new Date("2026-01-08T16:30:00Z") })
.where("id", "=", release)
.execute();
await h.db
.updateTable("download_task")
.set({
media_entity_id: entity,
source_release_id: release,
file_list: JSON.stringify([{ path: name, size: 128 }]),
})
.where("id", "=", dt)
.execute();
const original = await planImport(h.module.deps().ao, {
downloadTaskId: dt,
mediaEntityId: entity,
trigger: "manual",
actorType: "user",
action: "copy",
scrapeAfterImport: false,
minSizeBytes: 0,
});
expect(original.status).toBe("failed");
await h.db
.updateTable("media_entity")
.set({ metadata: JSON.stringify({ airStatus: "Returning Series" }) })
.where("id", "=", entity)
.execute();
const season = await h.db
.insertInto("content_unit")
.values({
media_entity_id: entity,
kind: "season",
season_number: 2,
episode_total: 12,
status: "confirmed",
source: "tmdb",
})
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("content_unit")
.values({
media_entity_id: entity,
kind: "episode",
parent_unit_id: season.id,
season_number: 2,
episode_number: 1,
air_date: "2026-01-08",
status: "confirmed",
source: "tmdb",
})
.execute();
expect(await resumeEpisodeOrganize(h.module.deps().ao, entity)).toEqual({ resumed: 1, waiting: 0 });
const next = await h.db
.selectFrom("organization_record")
.selectAll()
.where("download_task_id", "=", dt)
.where("id", "!=", original.recordId!)
.executeTakeFirstOrThrow();
expect(Object.values((next.plan as unknown as FrozenPlan).facts)[0]!.episodeResolution).toMatchObject({
method: "absolute_in_season",
canonical: { season: 2, episodes: { start: 1, end: 1 } },
});
expect((await executeRecord(h.module.deps().ao, next.id)).status).toBe("succeeded");
});
it("暂停期间修正规则,启用后只恢复原目录的未解决文件", async () => {
const entity = await show("Directory Resume");
const dir = join(h.dl, "Directory.Resume");
await mkdir(dir);
const first = join(dir, "Directory.Resume.S01E01.1080p.mkv");
const second = join(dir, "Directory.Resume.S02E02.1080p.mkv");
for (const path of [first, second]) await writeFile(path, path.padEnd(128, "x"));
const original = await planImport(h.module.deps().ao, {
sourcePaths: [dir],
mediaEntityId: entity,
trigger: "manual",
actorType: "user",
action: "copy",
scrapeAfterImport: false,
minSizeBytes: 0,
});
expect((await executeRecord(h.module.deps().ao, original.recordId!)).status).toBe("partial");
const added = join(dir, "Directory.Resume.S01E03.1080p.mkv");
await writeFile(added, "later".padEnd(128, "x"));
await h.db
.insertInto("settings")
.values({ key: "automation", value: JSON.stringify({ enabled: false }) })
.onConflict((oc) => oc.column("key").doUpdateSet({ value: JSON.stringify({ enabled: false }) }))
.execute();
await setWordsText(h.db, entity, "S02 >> S01 EP\n");
await reresolveEntityEpisodes(h.module.deps().ao.ctx, h.db, entity);
expect(await resumeEpisodeOrganize(h.module.deps().ao, entity)).toEqual({ resumed: 0, waiting: 0 });
expect(await sweepEpisodeOrganize(h.module.deps().ao)).toBe(0);
await h.db
.updateTable("settings")
.set({ value: JSON.stringify({ enabled: true }) })
.where("key", "=", "automation")
.execute();
expect(await sweepEpisodeOrganize(h.module.deps().ao)).toBe(1);
expect(
h.engine.enqueued.some(
(e) => e.task === "verifyAndImport" && (e.input as { mediaEntityId?: string }).mediaEntityId === entity,
),
).toBe(true);
expect(await resumeEpisodeOrganize(h.module.deps().ao, entity)).toEqual({ resumed: 1, waiting: 0 });
const next = await h.db
.selectFrom("organization_record")
.selectAll()
.where("media_entity_id", "=", entity)
.where("id", "!=", original.recordId!)
.executeTakeFirstOrThrow();
expect((next.plan as unknown as FrozenPlan).request?.sourcePaths).toEqual([second]);
expect((await executeRecord(h.module.deps().ao, next.id)).status).toBe("succeeded");
expect(
await h.db
.selectFrom("media_asset")
.select("id")
.where("media_entity_id", "=", entity)
.where("status", "=", "active")
.execute(),
).toHaveLength(2);
expect(await readFile(added, "utf8")).toBe("later".padEnd(128, "x"));
expect(await sweepEpisodeOrganize(h.module.deps().ao)).toBe(0);
});
});
@@ -264,6 +264,25 @@ describe.skipIf(!pg)("整理真实文件的季集映射闭环", () => {
).rejects.toThrow("已对上的资源");
});
it("首次混合整理保留已对上的文件,纯文件偏移也要通过回归保护", async () => {
const entityId = await series();
await settleOrganizeEpisodeDecisions(h.ctx, h.db, {
recordId: randomUUID(),
entityId,
files: [await file(entityId, 1), await file(entityId, 40)],
});
const d = (await pending(entityId))[0]!;
expect(episodeFileSamples(d.evidence)).toHaveLength(2);
const simulation = await simulateForAgent(h.db, { decisionId: d.id, line: "S01 @ANi >> S02 EP-30" }, null);
expect(simulation.summary).toMatchObject({ samples: 2, resolved: 1, regressions: 1, ok: false });
await expect(
applyAgentEpisodeRule(h.ctx, { decisionId: d.id, line: simulation.line, reasons: ["同批文件"] }, ACTOR, null),
).rejects.toThrow("已对上的文件");
expect(
await h.db.selectFrom("identify_words").select("id").where("media_entity_id", "=", entityId).execute(),
).toHaveLength(0);
});
it("保存部分有效的映射保留未决文件与已解决样本,不误关闭事项", async () => {
const entityId = await series();
const files = [await file(entityId, 40), await file(entityId, 60)];