fix(organize-wizard): 整理向导只发用户改过的季集,不再绕过集数映射

- 行上新增 episodeTouched / mappedFor:只有行内改季集、批量季号、集号偏移、弹窗指定季号的行才发季集;
  未改动的预填只作展示,交给服务端按最终作品映射
- 改选作品后,按旧作品映射的预填清空(未映射的解析值保留);补识别刷新未改动行的预填
- 识别结果新增 episodeUnresolved(集数待确认),向导里该行未决,需填季集或排除
- pg 测试:向导整理 `[ANi] X - 25` 不带季集 → S02E13,显式季集按用户值

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-03 20:38:50 +08:00
1 parent 00045a24cb
commit c16f8a51e9
6 files changed
+259 -17

No files matched your search

@@ -0,0 +1,110 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts";
import { createFmHarness, type FmHarness, key, pgAvailable, READ_KEY, seedEntity } from "./harness.ts";
// 整理向导不绕过集数映射:向导只发用户改过的季集;未带季集的项由服务端按最终作品映射(`- 25` → S02E13),
// 带了季集的按人工指定执行。集数待确认的识别项带 episodeUnresolved,向导据此标为未决。
const skip = !(await pgAvailable());
describe.skipIf(skip)("整理向导执行时按作品映射集数", () => {
let h: FmHarness;
let show: string;
const auto = "[ANi] Kusuriya no Hitorigoto - 25 [1080P].mkv";
const manual = "[ANi] Kusuriya no Hitorigoto - 25 [720P].mkv";
const pending = "[ANi] Kusuriya no Hitorigoto - 40 [1080P].mkv";
beforeAll(async () => {
h = await createFmHarness("wizard_episode_mapping");
show = await seedEntity(h.db, { kind: "series", title: "Kusuriya no Hitorigoto", year: 2023 });
await h.db
.updateTable("media_entity")
.set({ metadata: JSON.stringify({ airStatus: { value: "Ended", source: "tmdb" } }) })
.where("id", "=", show)
.execute();
for (const [season, total] of [
[1, 12],
[2, 13],
] as const) {
await h.db
.insertInto("content_unit")
.values({
media_entity_id: show,
kind: "season",
season_number: season,
episode_total: total,
status: "confirmed",
source: "tmdb",
})
.execute();
}
await writeFile(join(h.dl, auto), "a".repeat(64));
await writeFile(join(h.dl, manual), "m".repeat(64));
await writeFile(join(h.dl, pending), "p".repeat(64));
});
afterAll(async () => h?.close());
const organize = async (item: Record<string, unknown>) => {
const items = [{ root: h.dlRoot, mediaEntityId: show, ...item }];
const dry = await h.api("POST", "/files/organize", { idempotencyKey: key(), action: "copy", dryRun: true, items });
expect(dry.status).toBe(200);
const group = dry.body.data.groups[0];
expect(group.error).toBeNull();
const exec = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
action: "copy",
dryRun: false,
items,
planIds: [group.planId],
});
expect(exec.status).toBe(200);
expect(exec.body.data.recordIds).toHaveLength(1);
const result = await executeRecord(h.module.deps().ao, exec.body.data.recordIds[0]);
expect(result.status).toBe("succeeded");
return group.files[0] as { target: string };
};
const unitsOf = async (target: string) =>
h.db
.selectFrom("asset_location as l")
.innerJoin("asset_unit_coverage as au", "au.media_asset_id", "l.media_asset_id")
.innerJoin("content_unit as u", "u.id", "au.content_unit_id")
.select(["u.season_number", "u.episode_number"])
.where("l.relative_path", "like", `%${target.split("/").pop()}`)
.execute();
it("识别:集数待确认的项带 episodeUnresolved,能映射的不带", async () => {
const r = await h.api(
"POST",
"/files/identify",
{
items: [
{ root: h.dlRoot, path: auto },
{ root: h.dlRoot, path: pending },
],
minSizeBytes: 0,
},
READ_KEY,
);
expect(r.status).toBe(200);
const [ok, bad] = r.body.data.items;
expect(ok.parsed).toMatchObject({ season: 2, episodes: [13] });
expect(ok.episodeUnresolved).toBeUndefined();
expect(bad.episodeUnresolved).toBe(true);
});
it("不改季集直接执行:`- 25` 按作品季表入库为 S02E13", async () => {
const file = await organize({ path: auto });
expect(file.target).toMatch(/Season 2\/.*S02E13/);
expect(await readFile(file.target, "utf8")).toBe("a".repeat(64));
expect(await unitsOf(file.target)).toEqual([{ season_number: 2, episode_number: 13 }]);
});
it("用户显式改季集:按用户值入库", async () => {
const file = await organize({ path: manual, season: 2, episodes: [12] });
expect(file.target).toMatch(/Season 2\/.*S02E12/);
expect(await unitsOf(file.target)).toEqual([{ season_number: 2, episode_number: 12 }]);
});
});