diff --git a/apps/server/src/modules/file-manager/identify.ts b/apps/server/src/modules/file-manager/identify.ts index 7fc07240..1f968178 100644 --- a/apps/server/src/modules/file-manager/identify.ts +++ b/apps/server/src/modules/file-manager/identify.ts @@ -499,6 +499,7 @@ export async function identifyCore( suggestion: { mediaEntityId: best?.mediaEntityId ?? null, contentUnitId, contentUnit }, existing: { assetId: existing?.media_asset_id ?? null }, warnings, + ...(unresolved ? { episodeUnresolved: true } : {}), size, skipped: null, records, diff --git a/apps/server/test/file-manager/wizard-episode-mapping.test.ts b/apps/server/test/file-manager/wizard-episode-mapping.test.ts new file mode 100644 index 00000000..46529209 --- /dev/null +++ b/apps/server/test/file-manager/wizard-episode-mapping.test.ts @@ -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) => { + 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 }]); + }); +}); diff --git a/apps/web/app/components/organize-wizard/model.test.ts b/apps/web/app/components/organize-wizard/model.test.ts index 77bc254a..5e8d3053 100644 --- a/apps/web/app/components/organize-wizard/model.test.ts +++ b/apps/web/app/components/organize-wizard/model.test.ts @@ -4,15 +4,18 @@ import { applyGroupEntity, applyMinSize, applyOffsetTo, + applySeasonTo, defaultOptions, dialogRows, displayPath, downloadContentRef, groupRows, MIB, + mergeIdentified, nestSidecars, organizeRequest, pageOf, + patchRowEpisodes, previewStats, rowProblem, rowsFromIdentify, @@ -119,12 +122,13 @@ describe("识别结果 → 行", () => { it("剧集缺季号或集号非法时未决;做种路径标 H&R", () => { const rows = rowsFromIdentify([item("seed/a.mkv")], new Set(["seed"])); expect(rows[0]?.hnrBlocked).toBe(true); - expect(rowProblem({ ...(rows[0] as WizardRow), season: "" })).toMatch(/季号/); - expect(rowProblem({ ...(rows[0] as WizardRow), episodes: "1-x" })).toMatch(/集号/); + const touched = { ...(rows[0] as WizardRow), episodeTouched: true }; + expect(rowProblem({ ...touched, season: "" })).toMatch(/季号/); + expect(rowProblem({ ...touched, episodes: "1-x" })).toMatch(/集号/); }); it("只允许单集或连续集号,不让人工覆写被后端忽略", () => { - const r = rowsFromIdentify([item("show/a.mkv")], new Set())[0] as WizardRow; + const r = { ...(rowsFromIdentify([item("show/a.mkv")], new Set())[0] as WizardRow), episodeTouched: true }; expect(rowProblem({ ...r, episodes: "3,5" })).toBe("集号需要连续,如 3-5"); expect(wizardStats([{ ...r, episodes: "3,5" }]).pending).toBe(1); expect(rowProblem({ ...r, episodes: "4,3,4" })).toBeNull(); @@ -166,13 +170,15 @@ describe("批量设置", () => { }); describe("统一整理弹窗范围", () => { - it("指定作品覆盖整批,清除后恢复逐文件识别,季集解析保留", () => { + it("指定作品覆盖整批,清除后恢复逐文件识别与季集预填", () => { const rows = rowsFromIdentify([item("a.mkv"), item("b.mkv", { id: "e2" })], new Set()); const selected = dialogRows(rows, tmdbMovie, "", false); expect(selected.map((r) => r.entity)).toEqual([tmdbMovie, tmdbMovie]); expect(dialogRows(rows, null, "", false).map((r) => r.entity?.id)).toEqual(["e1", "e2"]); expect(rows.map((r) => r.entity?.id)).toEqual(["e1", "e2"]); - expect(selected.map((r) => r.episodes)).toEqual(["2", "2"]); + // 预填是按 e1 / e2 映射的,改选作品后不沿用 + expect(selected.map((r) => r.episodes)).toEqual(["", ""]); + expect(dialogRows(rows, null, "", false).map((r) => r.episodes)).toEqual(["2", "2"]); }); it("成功历史默认参与;开启跳过后未识别的成功文件也不阻挡预览", () => { @@ -228,7 +234,7 @@ describe("请求体", () => { { dryRun: true, idempotencyKey: "k-123456" }, ); expect(body.items).toEqual([ - { root: "lib:x", path: "a.mkv", mediaEntityId: "e1", season: 1, episodes: [2] }, + { root: "lib:x", path: "a.mkv", mediaEntityId: "e1" }, { root: "lib:x", path: "b.mkv", external: { provider: "tmdb", externalId: "872585", kind: "movie" } }, ]); expect(body.targetLibraryRootId).toBe("0e0e0e0e-0000-0000-0000-000000000000"); @@ -405,3 +411,81 @@ it("原地入库请求不携带旧搬运目标、分类来源与替换策略, expect(exec.naming).toBe(dry.naming); expect(exec.onExisting).toBe(dry.onExisting); }); + +describe("季集只在用户改过时发送(集数映射交给服务端)", () => { + const anime = (path: string, over: Partial = {}): FileIdentifyItem => ({ + ...item(path), + parsed: { ...item(path).parsed, season: 2, episodes: [13] }, + ...over, + }); + const body = (rows: WizardRow[]) => + organizeRequest(rows, defaultOptions(), { dryRun: true, idempotencyKey: "k-123456" }).items; + + it("未改动的行只带作品,不带季集;预填仍用于展示", () => { + const rows = dialogRows(rowsFromIdentify([anime("[ANi] X - 25.mkv")], new Set()), null, "", false); + expect(rows[0]).toMatchObject({ season: "2", episodes: "13", episodeTouched: false, mappedFor: "e1" }); + expect(rowProblem(rows[0] as WizardRow)).toBeNull(); + expect(body(rows)).toEqual([{ root: "lib:x", path: "[ANi] X - 25.mkv", mediaEntityId: "e1" }]); + }); + + it("行内改季或集、批量季号、集号偏移、弹窗季号都算用户指定,按显示值发送", () => { + const base = rowsFromIdentify([anime("a.mkv"), anime("b.mkv"), anime("c.mkv")], new Set()); + const [a, b, c] = base.map((r) => r.key) as [string, string, string]; + let rows = patchRowEpisodes(base, a, { season: "1", episodes: "13" }); + rows = applySeasonTo(rows, new Set([b]), "3"); + rows = applyOffsetTo(rows, new Set([c]), 1).rows; + expect(body(rows)).toEqual([ + { root: "lib:x", path: "a.mkv", mediaEntityId: "e1", season: 1, episodes: [13] }, + { root: "lib:x", path: "b.mkv", mediaEntityId: "e1", season: 3, episodes: [13] }, + { root: "lib:x", path: "c.mkv", mediaEntityId: "e1", season: 2, episodes: [14] }, + ]); + expect(body(dialogRows(base, null, "4", false)).map((x) => [x.season, x.episodes])).toEqual([ + [4, [13]], + [4, [13]], + [4, [13]], + ]); + // 改过的行补识别时保留用户值 + const merged = mergeIdentified(rows, [anime("a.mkv", { parsed: { ...anime("a.mkv").parsed, season: 5 } })]); + expect(merged[0]?.season).toBe("1"); + }); + + it("集数待确认的行未决,填季集或排除后放行;改选作品后不再按旧作品判定", () => { + const pending = anime("[ANi] X - 99.mkv", { + parsed: { ...anime("x").parsed, season: 1, episodes: [99] }, + episodeUnresolved: true, + warnings: ["集数对不上作品的季集,需要人工指定"], + }); + const rows = rowsFromIdentify([pending], new Set()); + const r = dialogRows(rows, null, "", false)[0] as WizardRow; + expect(rowProblem(r)).toMatch(/集数待确认/); + expect(wizardStats([r]).pending).toBe(1); + expect(rowProblem({ ...r, excluded: "manual" })).toBeNull(); + const fixed = patchRowEpisodes(rows, r.key, { season: "2", episodes: "13" }); + expect(rowProblem(dialogRows(fixed, null, "", false)[0] as WizardRow)).toBeNull(); + const other = dialogRows(rows, series, "", false)[0] as WizardRow; + expect(rowProblem(other)).toBeNull(); + expect(other).toMatchObject({ season: "", episodes: "" }); + expect(body([other])).toEqual([{ root: "lib:x", path: "[ANi] X - 99.mkv", mediaEntityId: "e9" }]); + }); + + it("改选作品后清空旧作品的映射预填;未映射的解析值保留;补识别刷新未改动行的预填", () => { + const rows = rowsFromIdentify([anime("a.mkv"), anime("b.mkv", { candidates: [] })], new Set()); + const changed = dialogRows(setRowEntity(rows, rows[0]?.key as string, series, false), series, "", false); + expect(changed.map((r) => [r.season, r.episodes])).toEqual([ + ["", ""], + ["2", "13"], + ]); + // 只改作品不改季集 → 都不发季集 + expect(body(changed).every((x) => x.season === undefined && x.episodes === undefined)).toBe(true); + // 改选后用户在清空的格里填集号 → 只按用户填的发 + const typed = patchRowEpisodes(rows, rows[0]?.key as string, { season: "", episodes: "7" }); + expect(body(dialogRows(typed, series, "", false))[0]).toEqual({ + root: "lib:x", + path: "a.mkv", + mediaEntityId: "e9", + episodes: [7], + }); + const merged = mergeIdentified(rows, [anime("a.mkv", { parsed: { ...anime("a.mkv").parsed, season: 3 } })]); + expect(merged[0]?.season).toBe("3"); + }); +}); diff --git a/apps/web/app/components/organize-wizard/model.ts b/apps/web/app/components/organize-wizard/model.ts index 186b3614..805aa934 100644 --- a/apps/web/app/components/organize-wizard/model.ts +++ b/apps/web/app/components/organize-wizard/model.ts @@ -44,6 +44,13 @@ export type WizardRow = { touched: boolean; season: string; episodes: string; + /** + * 用户改过季 / 集(行内输入、批量季号、集号偏移、弹窗指定季号)。只有改过的行才把季集发给服务端; + * 没改过的由服务端按最终作品做集数映射(docs/runbook/identity.md「集数映射怎么用」)。 + */ + episodeTouched: boolean; + /** 识别结果的季集预填是按哪个作品映射的(entityKey);"" = 未映射的文件名解析值 */ + mappedFor: string; slotId: string; excluded: ExcludeReason | null; /** 同目录分组键 */ @@ -56,6 +63,7 @@ export function rowProblem(r: WizardRow): string | null { if (r.excluded) return null; if (!r.entity) return "未选择作品"; if (isMovieKind(r.entity.kind)) return null; + if (!r.episodeTouched) return episodeUnresolved(r) ? "集数待确认,请填写季集或排除" : null; const eps = parseEpisodes(r.episodes); if (eps === null) return "集号格式不对,请填写单集或连续区间,如 3、3-5"; const unique = [...new Set(eps)].sort((a, b) => a - b); @@ -66,6 +74,13 @@ export function rowProblem(r: WizardRow): string | null { return null; } +/** 识别结果对当前作品是「集数待确认」(映射不出季集),且用户还没指定 */ +export const episodeUnresolved = (r: WizardRow) => + !r.episodeTouched && !!r.item.episodeUnresolved && !!r.mappedFor && r.mappedFor === entityKey(r.entity); + +/** 识别项上季集预填对应的作品:服务端只按首个本地候选映射 */ +const mappedForOf = (it: FileIdentifyItem) => it.candidates[0]?.mediaEntityId ?? ""; + /** 识别项 → 行:高置信本地候选(或服务端建议)自动选中;低于最小体积的项预先排除 */ export function rowsFromIdentify(items: FileIdentifyItem[], hnrPaths: Set): WizardRow[] { return items.map((it) => { @@ -83,6 +98,8 @@ export function rowsFromIdentify(items: FileIdentifyItem[], hnrPaths: Set, e: WizardEnt return rows.map((r) => (keys.has(r.key) && !r.excluded ? { ...r, entity: e, touched: true, slotId: "" } : r)); } +/** 行内改季号 / 集号:标记为用户指定 */ +export function patchRowEpisodes( + rows: WizardRow[], + key: string, + patch: Partial>, +): WizardRow[] { + return rows.map((r) => (r.key === key ? { ...r, ...patch, episodeTouched: true } : r)); +} + /** 批量:季号应用到选中行 */ export function applySeasonTo(rows: WizardRow[], keys: Set, season: string): WizardRow[] { - return rows.map((r) => (keys.has(r.key) && !r.excluded ? { ...r, season: season.trim() } : r)); + return rows.map((r) => (keys.has(r.key) && !r.excluded ? { ...r, season: season.trim(), episodeTouched: true } : r)); } /** 批量:集号偏移 ±n(选中行);偏移后小于 1 的行不改并计入 failed */ @@ -137,7 +163,7 @@ export function applyOffsetTo(rows: WizardRow[], keys: Set, n: number): failed++; return r; } - return { ...r, episodes: formatEpisodes(next) }; + return { ...r, episodes: formatEpisodes(next), episodeTouched: true }; }); return { rows: out, failed }; } @@ -169,12 +195,14 @@ export function mergeIdentified(rows: WizardRow[], items: FileIdentifyItem[]): W const it = byKey.get(r.key); if (!it) return r; const f = fresh.get(r.key); + if (r.episodeTouched) return { ...r, item: it, entity: r.entity ?? f?.entity ?? null }; return { ...r, item: it, entity: r.entity ?? f?.entity ?? null, - season: r.season || f?.season || "", - episodes: r.episodes || f?.episodes || "", + season: f?.season ?? "", + episodes: f?.episodes ?? "", + mappedFor: f?.mappedFor ?? "", }; }); } @@ -190,6 +218,15 @@ export function wizardStats(rows: WizardRow[]): WizardStats { }; } +/** + * 最终作品与季集预填所依据的作品不同(改选了作品)时,未经用户修改的预填不再沿用旧作品的映射: + * 清空,交给服务端按新作品映射。未映射的文件名解析值(mappedFor 为空)保留作参考。 + */ +export function withEntityPrefill(row: WizardRow): WizardRow { + if (row.episodeTouched || !row.mappedFor || row.mappedFor === entityKey(row.entity)) return row; + return { ...row, season: "", episodes: "", mappedFor: "" }; +} + /** 共用弹窗的作品、季号作用于整个本次范围;成功历史跳过独立于目标冲突。 */ export function dialogRows( rows: WizardRow[], @@ -202,13 +239,13 @@ export function dialogRows( !row.excluded && skipSuccessfulRecords && (row.item.records?.successfulCount ?? 0) > 0 ? ("successful_record" as const) : row.excluded; - return { + const next = withEntityPrefill({ ...row, excluded, entity: manual ?? row.entity, slotId: manual && entityKey(manual) !== entityKey(row.entity) ? "" : row.slotId, - season: season.trim() || row.season, - }; + }); + return season.trim() ? { ...next, season: season.trim(), episodeTouched: true } : next; }); } @@ -262,8 +299,9 @@ export function organizeItems(rows: WizardRow[], o: WizardOptions): FilesOrganiz const item: FilesOrganizeItem = { root: r.root, path: r.path }; if (e.id) item.mediaEntityId = e.id; else if (e.external) item.external = e.external; - if (!movie && r.season.trim()) item.season = Number(r.season.trim()); - if (!movie && eps?.length) item.episodes = eps; + // 只发用户指定过的季集;未改动的预填只是展示,由服务端按最终作品映射(否则会被当作人工指定而跳过映射) + if (!movie && r.episodeTouched && r.season.trim()) item.season = Number(r.season.trim()); + if (!movie && r.episodeTouched && eps?.length) item.episodes = eps; if (r.slotId && e.id) item.slotId = r.slotId; if (o.action !== "in_place" && o.target.mode === "dir") item.targetDir = o.target.ref; return item; diff --git a/apps/web/app/components/organize-wizard/organize-wizard.tsx b/apps/web/app/components/organize-wizard/organize-wizard.tsx index c67d0b43..51a7c2cc 100644 --- a/apps/web/app/components/organize-wizard/organize-wizard.tsx +++ b/apps/web/app/components/organize-wizard/organize-wizard.tsx @@ -38,6 +38,7 @@ import { MIB, mergeIdentified, organizeRequest, + patchRowEpisodes, previewStats, rowProblem, rowsFromIdentify, @@ -413,7 +414,10 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on value={r.season} disabled={!!season || !!r.excluded} className="h-8 w-16" - onChange={(e) => setRows((rs) => rs.map((x) => (x.key === r.key ? { ...x, season: e.target.value } : x)))} + placeholder="自动" + onChange={(e) => + setRows((rs) => patchRowEpisodes(rs, r.key, { season: e.target.value, episodes: r.episodes })) + } /> ), }, @@ -429,7 +433,10 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on value={r.episodes} disabled={!!r.excluded} className="h-8 w-20" - onChange={(e) => setRows((rs) => rs.map((x) => (x.key === r.key ? { ...x, episodes: e.target.value } : x)))} + placeholder="自动" + onChange={(e) => + setRows((rs) => patchRowEpisodes(rs, r.key, { season: r.season, episodes: e.target.value })) + } /> ), }, diff --git a/packages/contracts/src/files.ts b/packages/contracts/src/files.ts index b7387cd0..25f19601 100644 --- a/packages/contracts/src/files.ts +++ b/packages/contracts/src/files.ts @@ -650,6 +650,8 @@ export const FileIdentifyItem = z.object({ existing: z.object({ assetId: Id.nullable() }), /** TMDB 不可用等降级提示 */ warnings: z.array(z.string()), + /** 按首个本地候选做集数映射时映射不出季集(集数待确认):parsed 是未映射的解析值,需要人工指定季集 */ + episodeUnresolved: z.boolean().optional(), /** W9-H(G2):文件大小 */ size: NonNegInt.optional(), /** W9-H(G2):below_min_size = 低于最小体积,未识别(candidates 为空) */