fix(organize): resume original imports after episode corrections
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user