From c16f8a51e9c1f725c8527de669c9daf2cc4a5446 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 3 Oct 2026 20:38:50 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(organize-wizard):=20=E6=95=B4=E7=90=86?= =?UTF-8?q?=E5=90=91=E5=AF=BC=E5=8F=AA=E5=8F=91=E7=94=A8=E6=88=B7=E6=94=B9?= =?UTF-8?q?=E8=BF=87=E7=9A=84=E5=AD=A3=E9=9B=86=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E7=BB=95=E8=BF=87=E9=9B=86=E6=95=B0=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 行上新增 episodeTouched / mappedFor:只有行内改季集、批量季号、集号偏移、弹窗指定季号的行才发季集; 未改动的预填只作展示,交给服务端按最终作品映射 - 改选作品后,按旧作品映射的预填清空(未映射的解析值保留);补识别刷新未改动行的预填 - 识别结果新增 episodeUnresolved(集数待确认),向导里该行未决,需填季集或排除 - pg 测试:向导整理 `[ANi] X - 25` 不带季集 → S02E13,显式季集按用户值 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../src/modules/file-manager/identify.ts | 1 + .../wizard-episode-mapping.test.ts | 110 ++++++++++++++++++ .../components/organize-wizard/model.test.ts | 96 ++++++++++++++- .../app/components/organize-wizard/model.ts | 56 +++++++-- .../organize-wizard/organize-wizard.tsx | 11 +- packages/contracts/src/files.ts | 2 + 6 files changed, 259 insertions(+), 17 deletions(-) create mode 100644 apps/server/test/file-manager/wizard-episode-mapping.test.ts 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 为空) */ From b02b4a500f48c6c25dd2d8d88197c10bf1bd06a5 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 3 Oct 2026 20:38:50 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(identity):=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E6=96=B0=E5=BB=BA=E4=BD=9C=E5=93=81=E5=90=8E?= =?UTF-8?q?=E6=8E=92=E4=B8=80=E6=AC=A1=E5=85=83=E6=95=B0=E6=8D=AE=E5=88=B7?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bindAuto 经 createEntityFromTmdb 新建作品时,事务提交后入队 full refreshMetadata (幂等键 refreshMetadata:created:<作品>,与人工指定 / 整理接管同一做法)。新建档没有季表时 资源按原号放行(unverified),刷新补齐季表后重算为换算结果。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../sources-identity/service/resolve.ts | 33 ++++--- .../auto-created-entity-refresh.test.ts | 98 +++++++++++++++++++ 2 files changed, 117 insertions(+), 14 deletions(-) create mode 100644 apps/server/test/sources-identity/auto-created-entity-refresh.test.ts diff --git a/apps/server/src/modules/sources-identity/service/resolve.ts b/apps/server/src/modules/sources-identity/service/resolve.ts index 1ad8dab2..98b18813 100644 --- a/apps/server/src/modules/sources-identity/service/resolve.ts +++ b/apps/server/src/modules/sources-identity/service/resolve.ts @@ -35,6 +35,7 @@ import { looksLikeVariety, resolveEntityId, } from "./entities.ts"; +import { enqueueNewEntityRefresh } from "./entity-from-candidate.ts"; import { canonicalParsed, clearReleaseEpisodes, @@ -714,8 +715,10 @@ async function bindAuto( const site = ((release.raw as Record)?._site ?? {}) as { categoryName?: string }; const variety = looksLikeVariety(parsed, site.categoryName ?? release.category_raw); for (let attempt = 0; ; attempt++) { + // 本次新建的作品:事务提交后排一次 full 元数据刷新(季表、集单元),刷新完成会重算待确认 / 放行资源的集数 + let created: string | null = null; try { - return await ctx.db.transaction().execute(async (trx) => { + const result = await ctx.db.transaction().execute(async (trx) => { let entityId: string | null = null; if (best.candidate.localEntityId) entityId = await resolveEntityId(trx, best.candidate.localEntityId); if (!entityId && best.candidate.provider === "tmdb") { @@ -725,19 +728,19 @@ async function bindAuto( externalId: best.candidate.externalId, }); if (!entityId && tmdbDetail) { - entityId = ( - await createEntityFromTmdb(trx, tmdbDetail, { - level, - evidence: { - sourceReleaseId: release.id, - rules: best.evidence, - titleMatch: best.titleMatch, - yearMatch: best.yearMatch, - }, - actor: SYSTEM, - variety, - }) - ).entityId; + const r = await createEntityFromTmdb(trx, tmdbDetail, { + level, + evidence: { + sourceReleaseId: release.id, + rules: best.evidence, + titleMatch: best.titleMatch, + yearMatch: best.yearMatch, + }, + actor: SYSTEM, + variety, + }); + entityId = r.entityId; + if (r.created) created = r.entityId; } } if (!entityId) @@ -848,6 +851,8 @@ async function bindAuto( reason: resolution.reason, }; }); + if (created) await enqueueNewEntityRefresh(ctx, created); + return result; } catch (e) { if (e instanceof IdentityConflictError && attempt < 2) continue; throw e; diff --git a/apps/server/test/sources-identity/auto-created-entity-refresh.test.ts b/apps/server/test/sources-identity/auto-created-entity-refresh.test.ts new file mode 100644 index 00000000..ebf00fff --- /dev/null +++ b/apps/server/test/sources-identity/auto-created-entity-refresh.test.ts @@ -0,0 +1,98 @@ +import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts"; +import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts"; +import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts"; + +// 自动识别新建作品后排一次 full 元数据刷新:新建档没有季表,绝对集号资源按原号滞后放行(unverified); +// 刷新补齐季表后重算为换算结果。已有作品的再次识别不重复排刷新。 + +const pg = await pgAvailable(); + +const season = (n: number, eps: number, start: string) => ({ + season_number: n, + air_date: start, + names: { "zh-CN": `第 ${n} 季`, "en-US": `Season ${n}` }, + episodes: Array.from({ length: eps }, (_, k) => ({ + episode_number: k + 1, + air_date: new Date(Date.parse(start) + k * 7 * 86_400_000).toISOString().slice(0, 10), + names: { "zh-CN": `第 ${k + 1} 集`, "en-US": `Episode ${k + 1}` }, + })), +}); + +// 已完结:S1 = 12、S2 = 13(ANi 连续编号 25 → S02E13) +const SHOW: TmdbFixture = { + type: "tv", + id: 990101, + original_title: "Hoshikuzu Telepath", + original_language: "ja", + origin_country: ["JP"], + date: "2023-10-06", + titles: { "zh-CN": "星屑心灵感应", "en-US": "Hoshikuzu Telepath" }, + popularity: 30, + alternative_titles: [], + external_ids: {}, + rich: { status: "Ended" }, + seasons: [season(1, 12, "2023-10-06"), season(2, 13, "2025-01-10")], +}; + +describe.skipIf(!pg)("自动识别新建作品排元数据刷新", () => { + let h: Harness; + let tmdb: MockTmdb; + let siteId: string; + let n = 0; + + beforeAll(async () => { + tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, SHOW] }); + h = await createHarness("auto_created_entity_refresh", { tmdbUrl: tmdb.url }); + siteId = (await seedSite(h, { baseUrl: "https://acgrip.example/" })).site.id; + }); + afterAll(async () => { + await h?.close(); + await tmdb?.close(); + }); + + const resolve = async (title: string) => { + const r = await h.db + .insertInto("source_release") + .values({ site_id: siteId, site_release_id: String(810000 + ++n), title, subtitle: "星屑心灵感应" }) + .returningAll() + .executeTakeFirstOrThrow(); + const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual" }); + expect(res.outcome, JSON.stringify(res)).toBe("auto"); + return { id: r.id, entityId: res.mediaEntityId as string }; + }; + const resolution = async (id: string) => + ( + await h.db + .selectFrom("source_release") + .select("episode_resolution") + .where("id", "=", id) + .executeTakeFirstOrThrow() + ).episode_resolution as Record; + const refreshJobs = () => h.engine.enqueued.filter((e) => e.task === "refreshMetadata"); + + it("新建作品后入队一次 full 刷新;刷新后 unverified 的 `- 25` 重算为 S02E13;再次识别不重复入队", async () => { + const first = await resolve("[ANi] Hoshikuzu Telepath - 25 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]"); + expect(await resolution(first.id)).toMatchObject({ method: "unverified" }); + expect(refreshJobs()).toEqual([ + { + task: "refreshMetadata", + input: { mediaEntityId: first.entityId, full: true }, + key: `refreshMetadata:created:${first.entityId}`, + }, + ]); + + // 执行入队的刷新任务(同 refreshMetadata 任务处理器的调用) + const r = await refreshMetadata(h.rt, first.entityId, { full: true }); + expect(r.status).toBe("refreshed"); + expect(await resolution(first.id)).toMatchObject({ + method: "absolute_cross_season", + canonical: { season: 2, episodes: { start: 13, end: 13 } }, + }); + + const second = await resolve("[ANi] Hoshikuzu Telepath - 24 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]"); + expect(second.entityId).toBe(first.entityId); + expect(refreshJobs()).toHaveLength(1); + }); +}); From 93ca5eef2c1572b2868ec2000d55163be99e9060 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 3 Oct 2026 20:38:50 +0800 Subject: [PATCH 3/4] =?UTF-8?q?docs(runbook):=20=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E6=95=B4=E7=90=86=E5=90=91=E5=AF=BC=E7=9A=84=E5=AD=A3=E9=9B=86?= =?UTF-8?q?=E5=8F=91=E9=80=81=E8=A7=84=E5=88=99=E4=B8=8E=E6=96=B0=E5=BB=BA?= =?UTF-8?q?=E4=BD=9C=E5=93=81=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- docs/runbook/identity.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/runbook/identity.md b/docs/runbook/identity.md index 068ff281..644fb3de 100644 --- a/docs/runbook/identity.md +++ b/docs/runbook/identity.md @@ -158,6 +158,10 @@ select key, value->>'title' title, value->>'year' y, value->>'source' src from k 处理走 `POST /decisions/{id}/resolve` 的选项,由 sources-identity 直接执行(与身份类事项同一做法)。订阅详情有该作品 + 季的方式统计与待确认数;新建 / 编辑订阅时如果近期资源里有发布组用绝对集号,表单里提示一句。 +**整理向导**:识别结果里的季集只是按首个本地候选映射后的预填。向导只把用户改过的季集(行内输入、批量季号、集号偏移、弹窗「指定季号」)发给服务端,当作人工指定;没改过的由服务端按最终作品映射,所以改选作品后预填会清空、改由新作品映射。识别为集数待确认的行(`episodeUnresolved`)在向导里未决,填季集或排除后才能生成预览。 + +**新建作品的季表**:自动识别新建的作品(和人工指定、整理接管一样)在事务提交后排一次 full 元数据刷新(幂等键 `refreshMetadata:created:<作品>`);新建档没有季表时资源先按原号放行(unverified),刷新补齐季表后自动重算为换算结果。 + **智能体兜底**(批次 5):确定性流程(规则 → 原号校验 → 换算)都失败、开了「集数待确认」后,智能体在完整上下文里判断一次,产出一条结构化规则;写入后同类资源由规则自动处理,智能体不逐集调用。 - 何时触发:设置 → 智能体打开「自动调查」时,新开的 `episode_mapping` 事项和下载失败一样进后台调查(`decision.created` → `resolveDecision` → `autoInvestigate`),同一事项只跑一次。对话里也可以直接说「《X》第二季集数对不上」,走同样三个能力(Skill「集数映射」)。 From fa834a10bdc26210541b72939dc6aed0ef87424a Mon Sep 17 00:00:00 2001 From: root Date: Sat, 3 Oct 2026 20:59:52 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test(e2e):=20=E5=B8=B8=E8=A7=84=E5=89=A7?= =?UTF-8?q?=E9=9B=86=E7=BA=B5=E5=88=87=E6=8C=89=E6=96=B0=E5=BB=BA=E4=BD=9C?= =?UTF-8?q?=E5=93=81=20full=20=E5=88=B7=E6=96=B0=E5=90=8E=E7=9A=84?= =?UTF-8?q?=E5=AD=A3=E8=A1=A8=E6=96=AD=E8=A8=80=20S1=20=E9=9B=86=E5=8D=95?= =?UTF-8?q?=E5=85=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自动识别新建作品后会排一次 full 元数据刷新,S1 集单元全部来自 TMDB 季详情并转正式, 不再停留在「E02 临时」。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- apps/server/test/e2e/tv-series.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/server/test/e2e/tv-series.test.ts b/apps/server/test/e2e/tv-series.test.ts index f1f97b20..a319a61d 100644 --- a/apps/server/test/e2e/tv-series.test.ts +++ b/apps/server/test/e2e/tv-series.test.ts @@ -296,11 +296,9 @@ describe.skipIf(!hasPg)("E7 常规剧集纵切", () => { .where("season_number", "=", 1) .orderBy("episode_number") .execute(); - // DH-1:元数据刷新拉了 S1 季详情,对上 TMDB 的 E01 转正式;E02 不在 mock TMDB 的 S1 里,保持临时 - expect(eps).toEqual([ - { episode_number: 1, status: "confirmed" }, - { episode_number: 2, status: "provisional" }, - ]); + // 自动识别新建作品后排了一次 full 元数据刷新:S1 集单元全部来自 TMDB 季详情(mock 为 8 集)并转正式; + // 整季包本身没有建出季表以外的集 + expect(eps).toEqual(Array.from({ length: 8 }, (_, i) => ({ episode_number: i + 1, status: "confirmed" }))); // 第三集没出现、总集数未知:Slot 不满足,订阅继续追踪 const s = await slot(); expect(s.status).not.toBe("satisfied");