From c532060c835d0d5dc530c40c97b50313a16673ae Mon Sep 17 00:00:00 2001 From: root Date: Sat, 3 Oct 2026 23:33:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(organize):=20=E6=96=87=E4=BB=B6=E7=BA=A7?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E7=B2=97=E6=A0=B8,=E6=B7=B7=E5=90=88?= =?UTF-8?q?=E5=8C=85=E9=87=8C=E6=98=8E=E6=98=BE=E4=B8=8D=E6=98=AF=E6=9C=AC?= =?UTF-8?q?=E4=BD=9C=E5=93=81=E7=9A=84=E8=A7=86=E9=A2=91=E9=99=8D=E4=B8=BA?= =?UTF-8?q?=E5=BE=85=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 同一批视频里文件名标题与作品标题 / 原名 / 别名明显不符(没有足够共同词)的排除为 title_mismatch,不按本作品命名入库; - 只在同批至少一个文件对得上作品时才判断,剧集只看带 SxxEyy 的文件,用户逐文件指定过的不核; - title_mismatch 算「本该入库却被排除」:记录部分完成、开待处理、Move 不移除下载任务。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../services/organize-finalize.ts | 17 +++- .../services/organize-plan.ts | 43 ++++++++++ .../organize-title-check.test.ts | 84 +++++++++++++++++++ .../app/components/organize-wizard/model.ts | 1 + docs/runbook/organize.md | 4 + packages/domain/src/organize/index.ts | 1 + packages/domain/src/organize/plan.ts | 10 ++- packages/domain/src/organize/title-check.ts | 77 +++++++++++++++++ packages/domain/test/organize.test.ts | 47 +++++++++++ packages/mapping/src/organize.ts | 9 +- 10 files changed, 283 insertions(+), 10 deletions(-) create mode 100644 apps/server/test/acquisition-organize/organize-title-check.test.ts create mode 100644 packages/domain/src/organize/title-check.ts diff --git a/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts b/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts index ffe65b7e..fae3c190 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts @@ -20,6 +20,14 @@ import { scrapeAfterImport } from "./scrape.ts"; import { slotComplete as sharedSlotComplete } from "./subscriptions.ts"; import { downloadTasksUnder } from "./takeover.ts"; +/** 被排除待确认的视频原因(待处理摘要里显示) */ +const EXCLUDED_TEXT: Record = { + episode_unresolved: "集数待确认", + no_target_dir: "没有季目录", + naming_failed: "命名失败", + title_mismatch: "标题不是本作品", +}; + /** 登记资产/位置/绑定 + Slot/计划/下载任务 + 记录终态,同一事务;记录行锁作 fencing */ export async function finalizeRecord(d: AoDeps, recordId: string): Promise { const db = d.ctx.db; @@ -350,13 +358,14 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise { + const code = f.skip_reason?.replace(/^excluded:/, "") ?? ""; + return `${f.source_path.split("/").pop()}(${EXCLUDED_TEXT[code] ?? code})`; + }); await upsertDecision(d, trx, { kind: "other", title: `有 ${unresolved.length} 个视频没有整理`, - summary: `季集、季目录或命名没能确定,文件留在原处${ + summary: `季集、季目录、命名没能确定,或文件标题不是本作品,文件留在原处${ rec.action === "move" ? ",下载任务保留" : "" }:${shown.join("、")}${unresolved.length > shown.length ? " 等" : ""}`, subjectType: "organization_record", diff --git a/apps/server/src/modules/acquisition-organize/services/organize-plan.ts b/apps/server/src/modules/acquisition-organize/services/organize-plan.ts index 4f6c44c6..3b30e6e0 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-plan.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-plan.ts @@ -24,6 +24,7 @@ import { resolveTargetDirectory, type SourceFile, sidecarStem, + titleMismatches, } from "@umt/domain"; import { organizePlanToFileResults, toDomainPathBinding } from "@umt/mapping"; import { @@ -637,9 +638,22 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise = {}; const unresolvedEpisodes: NonNullable = {}; + // 文件级标题粗核:混合包 / 合集里标题明显不是本作品的视频不按本作品命名入库(待确认 title_mismatch); + // 用户逐文件指定过的不核 + const mismatched = await fileTitleMismatches( + d, + entity, + mediaType, + files.filter((f) => !overrideFor(req.fileOverrides, f.path) && fileKindOf(basename(f.path)) === "video"), + ); for (const f of files) { let parsed = parseReleasePath(segmentsWithin(f.path, sourceRoot)); if (parsed.fileKind !== "video") continue; + const wrongTitle = mismatched.get(f.path); + if (wrongTitle !== undefined) { + mappedOverrides[f.path] = { exclude: { reason: "title_mismatch", detail: wrongTitle } }; + continue; + } const ov = overrideFor(req.fileOverrides, f.path); let resolution: EpisodeResolution | undefined; if (mediaType === "tv" && (ov?.season !== undefined || ov?.episodes)) { @@ -1221,3 +1235,32 @@ export async function emitRecord(d: AoDeps, exec: Exec, recordId: string): Promi mediaEntityId: r.media_entity_id, }); } + +/** 作品标题 / 原名 / 别名 → 这批视频里标题明显不是本作品的(路径 → 文件标题) */ +async function fileTitleMismatches( + d: AoDeps, + entity: Pick, + mediaType: "movie" | "tv", + videos: readonly { path: string }[], +): Promise> { + if (videos.length < 2) return new Map(); + const aliases = await d.ctx.db + .selectFrom("title_alias") + .select("alias") + .where("media_entity_id", "=", entity.id) + .where("content_unit_id", "is", null) + .execute(); + const meta = (entity.metadata ?? {}) as Record; + const names = [ + entity.title, + entity.original_title, + meta.title?.value, + meta.originalTitle?.value, + ...aliases.map((a) => a.alias), + ].filter((n): n is string => typeof n === "string" && n.trim().length > 0); + return titleMismatches( + videos.map((v) => ({ path: v.path, name: basename(v.path) })), + names, + mediaType, + ); +} diff --git a/apps/server/test/acquisition-organize/organize-title-check.test.ts b/apps/server/test/acquisition-organize/organize-title-check.test.ts new file mode 100644 index 00000000..83f3876d --- /dev/null +++ b/apps/server/test/acquisition-organize/organize-title-check.test.ts @@ -0,0 +1,84 @@ +import { existsSync } from "node:fs"; +import { createTempTree, type TempTree } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts"; +import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts"; + +// 文件级标题粗核:合集 / 混合包里标题明显不是本作品的视频不按本作品命名入库, +// 降为待确认(排除原因 title_mismatch),记录部分完成并开待处理;用户逐文件指定过的不核。 + +const hasPg = await pgAvailable(); +const SIZE = 200_000; +const PACK = "Nolan.Pack"; + +describe.skipIf(!hasPg)("整理:文件标题明显不是本作品时降为待确认", () => { + let h: Harness; + let lib: TempTree; + let src: TempTree; + let entity = ""; + + beforeAll(async () => { + lib = await createTempTree({}, { sentinel: true, prefix: "umt-tclib-" }); + src = await createTempTree( + { + [`${PACK}/Inception.2010.1080p.BluRay.x264-GRP.mkv`]: SIZE, + [`${PACK}/Interstellar.2014.1080p.BluRay.x264-GRP.mkv`]: SIZE, + }, + { prefix: "umt-tcsrc-" }, + ); + h = await createHarness("title_check"); + await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" }); + await h.setSettings("organize", { scrapeAfterImport: false }); + entity = await seedEntity(h.db, { kind: "movie", title: "盗梦空间", year: 2010 }); + await h.db.updateTable("media_entity").set({ original_title: "Inception" }).where("id", "=", entity).execute(); + }, 60_000); + afterAll(async () => { + await h?.close(); + for (const t of [lib, src]) await t?.cleanup(); + }); + + it("合集里的别的电影:排除 title_mismatch,不入库,记录部分完成并开待处理", async () => { + const plan = await planImport(h.d, { + sourcePaths: [src.path(PACK)], + mediaEntityId: entity, + action: "copy", + trigger: "auto", + actorType: "system", + }); + expect(plan.status).toBe("running"); + const ex = await executeRecord(h.d, plan.recordId!); + expect(ex.status).toBe("partial"); + const files = await h.db + .selectFrom("organization_file_result") + .select(["source_path", "status", "skip_reason", "target_path"]) + .where("organization_record_id", "=", plan.recordId!) + .execute(); + const other = files.find((f) => f.source_path.includes("Interstellar"))!; + expect(other).toMatchObject({ status: "skipped", skip_reason: "excluded:title_mismatch", target_path: null }); + const mine = files.find((f) => f.source_path.includes("Inception"))!; + expect(mine.status).toBe("succeeded"); + expect(existsSync(src.path(`${PACK}/Interstellar.2014.1080p.BluRay.x264-GRP.mkv`))).toBe(true); + const decision = await h.db + .selectFrom("decision") + .select(["summary", "status"]) + .where("dedupe_key", "=", `organize_excluded:${plan.recordId}`) + .executeTakeFirstOrThrow(); + expect(decision.status).toBe("open"); + expect(decision.summary).toContain("标题不是本作品"); + }); + + it("用户逐文件指定过的不核", async () => { + const plan = await planImport(h.d, { + sourcePaths: [src.path(PACK)], + mediaEntityId: entity, + action: "copy", + trigger: "manual", + actorType: "user", + dryRun: true, + fileOverrides: { + [src.path(`${PACK}/Interstellar.2014.1080p.BluRay.x264-GRP.mkv`)]: { contentUnitIds: [] }, + }, + }); + expect(plan.plan!.organize.excluded.some((x) => x.reason === "title_mismatch")).toBe(false); + }); +}); diff --git a/apps/web/app/components/organize-wizard/model.ts b/apps/web/app/components/organize-wizard/model.ts index 805aa934..07b29b5e 100644 --- a/apps/web/app/components/organize-wizard/model.ts +++ b/apps/web/app/components/organize-wizard/model.ts @@ -410,6 +410,7 @@ export const EXCLUDE_REASON: Record = { extra_content: "额外内容", no_target_dir: "没有目标目录", naming_failed: "命名失败", + title_mismatch: "标题不是本作品", }; export const SKIP_REASON: Record = { diff --git a/docs/runbook/organize.md b/docs/runbook/organize.md index ab0a0067..cc94127f 100644 --- a/docs/runbook/organize.md +++ b/docs/runbook/organize.md @@ -53,6 +53,10 @@ ## 收尾核验与资产登记 - 目标路径已有位置时:本次是「替换」(向导 / 待处理选替换,或洗版选中的旧版本),或旧位置属于别的作品,退役旧位置(没有别的位置时旧资产退役)并新建资产,大小、校验、版本信息都是新文件的;否则沿用同一资产。沿用的位置之前被对账判为缺失 / 不可用时恢复可用,资产事实换成这次入库的文件。 +- 文件级标题粗核:同一批视频(合集、混合包)里,文件名解析出的标题和作品标题 / 原名 / 别名明显不是同一部的(没有足够共同词; + 剧集只看带 SxxEyy 的文件),排除为待确认 `title_mismatch`,不按本作品命名入库,记录部分完成并开待处理「有 N 个视频没有整理」, + Move 时下载任务保留。只在同一批里至少有一个文件对得上作品时才判断别的文件(全都对不上多半是作品名的另一种写法,交给资源级识别); + 用户在向导里逐文件指定过的不核。 - ffprobe 判定「不是视频」(没有视频流,伪装或损坏)的文件:核验失败,已拷 / 搬进库的新文件和它的字幕等附属文件移入回收站(Move 时源已删,回收站里是唯一一份),附属文件结果记跳过 `main_failed`,开待处理「N 个文件不是视频,没有入库」。原地入库不动原文件;洗版替换照旧回滚旧文件。 关联迁移 `0060_manual_acquisition`,已于 2026-10-01 随 v0.3.10 发布执行。业务依据与验收见 [MP 对照 §5.6](../porting/moviepilot-v3-comparison.md#56-本轮实现与验收已部署-v0310)。 diff --git a/packages/domain/src/organize/index.ts b/packages/domain/src/organize/index.ts index 344304bf..b615fc24 100644 --- a/packages/domain/src/organize/index.ts +++ b/packages/domain/src/organize/index.ts @@ -17,4 +17,5 @@ export { } from "./plan.ts"; export * from "./replacement.ts"; export { type DefaultSubtitleLang, sidecarLanguageSuffix, sidecarStem } from "./sidecars.ts"; +export { type TitleCheckFile, titleMatchesNames, titleMismatches } from "./title-check.ts"; export { findOtherVersions } from "./versions.ts"; diff --git a/packages/domain/src/organize/plan.ts b/packages/domain/src/organize/plan.ts index 82ede68a..7f1a4000 100644 --- a/packages/domain/src/organize/plan.ts +++ b/packages/domain/src/organize/plan.ts @@ -69,9 +69,9 @@ export interface FileOverride { episodes?: { start: number; end: number }; /** * 不入库(调用方按作品集数映射判定:集数待确认 episode_unresolved、映射规则标为特别篇 / 花絮 / 忽略 special_extra); - * 只按精确路径生效,目录前缀覆写不带它 + * 只按精确路径生效,目录前缀覆写不带它。title_mismatch = 文件标题明显不是本作品(混合包里别的作品),电影也生效 */ - exclude?: { reason: "episode_unresolved" | "special_extra"; detail?: string }; + exclude?: { reason: "episode_unresolved" | "special_extra" | "title_mismatch"; detail?: string }; } /** 源路径的覆写:精确命中优先,否则取最长的目录前缀 */ @@ -121,7 +121,8 @@ export interface ExcludedFile { | "special_extra" | "extra_content" | "no_target_dir" - | "naming_failed"; + | "naming_failed" + | "title_mismatch"; detail?: string; } @@ -232,7 +233,8 @@ export function planOrganize(input: OrganizeInput): OrganizePlan { } const parsed = parseReleasePath(segmentsFor(f.path, input.sourceRoot), input.parse ?? {}); const ov = input.mediaType === "tv" ? overrideFor(input.overrides, f.path) : undefined; - const ex = input.mediaType === "tv" ? input.overrides?.[f.path]?.exclude : undefined; + const exact = input.overrides?.[f.path]?.exclude; + const ex = input.mediaType === "tv" || exact?.reason === "title_mismatch" ? exact : undefined; if (ex) { excluded.push({ src: f.path, kind, reason: ex.reason, ...(ex.detail ? { detail: ex.detail } : {}) }); continue; diff --git a/packages/domain/src/organize/title-check.ts b/packages/domain/src/organize/title-check.ts new file mode 100644 index 00000000..e3b92dbe --- /dev/null +++ b/packages/domain/src/organize/title-check.ts @@ -0,0 +1,77 @@ +// 整理的文件级标题粗核(审视 ④ 风险 7):资源级认定了作品,但混合包 / 合集里混着别的作品时, +// 按文件名解析出的标题和作品标题 / 别名对一下,明显不是同一部的不按本作品命名入库(降为待确认 title_mismatch)。 +// 只拦明显不同的: +// - 只比文件名本身(不看目录名);文件名里解析不出标题的不判断; +// - 剧集只看带 SxxEyy 的文件(只有集标题的文件名常被解析成「标题」); +// - 同一批里至少有一个文件的标题对得上作品才判断别的文件(全都对不上时多半是作品名的另一种写法, +// 例如罗马音、英文译名,交给资源级识别,不拦); +// - 「对得上」= 有足够多的共同词:拉丁词(≥3 个字母,去掉常见虚词)或中日韩相邻两字, +// 文件标题的词至少一半出现在作品某个名字里。 +import { parseRelease } from "../release-parser/index.ts"; + +const STOP = new Set(["the", "and", "of", "a", "an", "no", "to", "in", "on", "season", "part", "complete", "series"]); +const CJK = /[぀-ヿ㐀-䶿一-鿿가-힯]/; + +function tokensOf(s: string): Set { + const out = new Set(); + const t = s.normalize("NFKC").toLowerCase(); + for (const w of t.match(/[a-z0-9]+(?:'[a-z]+)?/g) ?? []) { + const word = w.replace(/'[a-z]+$/, ""); + if (word.length >= 3 && !STOP.has(word) && !/^\d+$/.test(word)) out.add(word); + } + // 中日韩:按连续段取相邻两字(单字段整段算一个) + for (const run of t.match(new RegExp(`${CJK.source}+`, "gu")) ?? []) { + const chars = [...run]; + if (chars.length === 1) out.add(run); + for (let i = 0; i + 1 < chars.length; i++) out.add(chars[i]! + chars[i + 1]!); + } + return out; +} + +/** 一个文件标题和作品名字对得上(共同词占文件标题的一半以上);文件标题没有可比的词时返回 null */ +export function titleMatchesNames(fileTitle: string, names: readonly string[]): boolean | null { + const ft = tokensOf(fileTitle); + if (!ft.size) return null; + for (const n of names) { + const nt = tokensOf(n); + if (!nt.size) continue; + let shared = 0; + for (const w of ft) if (nt.has(w)) shared++; + if (shared > 0 && shared * 2 >= ft.size) return true; + } + return false; +} + +export interface TitleCheckFile { + path: string; + /** 文件名(不含目录) */ + name: string; +} + +/** + * 一批视频文件里标题明显不是本作品的(混合包里别的作品)。返回 路径 → 解析出的文件标题。 + * names:作品标题、原名、别名。 + */ +export function titleMismatches( + files: readonly TitleCheckFile[], + names: readonly string[], + mediaType: "movie" | "tv", +): Map { + const usable = names.filter((n) => n?.trim()); + const judged: { path: string; title: string; ok: boolean }[] = []; + for (const f of files) { + const p = parseRelease(f.name, { isFile: true }); + if (mediaType === "tv" && (p.season === undefined || p.seasonInferred || !p.episodes)) continue; + const titles = [ + ...new Set([p.titleCn, p.titleEn, p.title, ...(p.altTitles ?? [])].filter((t): t is string => !!t)), + ]; + if (!titles.length) continue; + const results = titles.map((t) => titleMatchesNames(t, usable)); + if (results.every((r) => r === null)) continue; + judged.push({ path: f.path, title: titles[0]!, ok: results.some((r) => r === true) }); + } + const out = new Map(); + if (!judged.some((j) => j.ok)) return out; + for (const j of judged) if (!j.ok) out.set(j.path, j.title); + return out; +} diff --git a/packages/domain/test/organize.test.ts b/packages/domain/test/organize.test.ts index dcd0b360..307eff4e 100644 --- a/packages/domain/test/organize.test.ts +++ b/packages/domain/test/organize.test.ts @@ -5,6 +5,8 @@ import { planOrganize, sidecarLanguageSuffix, sidecarStem, + titleMatchesNames, + titleMismatches, } from "../src/index.ts"; const MiB = 1024 ** 2; @@ -407,3 +409,48 @@ describe("原地入库计划", () => { expect(p.requiresApproval).toBe(false); }); }); + +describe("文件级标题粗核", () => { + const names = ["盗梦空间", "Inception"]; + it("合集里别的电影判为不符;对得上的、解析不出标题的不拦", () => { + const r = titleMismatches( + [ + { path: "a", name: "Inception.2010.1080p.BluRay.x264-GRP.mkv" }, + { path: "b", name: "Interstellar.2014.1080p.BluRay.x264-GRP.mkv" }, + { path: "c", name: "盗梦空间.2010.2160p.mkv" }, + ], + names, + "movie", + ); + expect([...r]).toEqual([["b", "Interstellar"]]); + }); + it("全都对不上时不拦(多半是作品名的另一种写法)", () => { + expect(titleMismatches([{ path: "a", name: "Sousou.no.Frieren.S01E01.mkv" }], ["葬送的芙莉莲"], "tv").size).toBe(0); + expect( + titleMismatches( + [ + { path: "a", name: "Frieren.S01E01.1080p.WEB-DL.mkv" }, + { path: "b", name: "Sousou.no.Frieren.S01E02.1080p.WEB-DL.mkv" }, + ], + ["葬送的芙莉莲", "Frieren: Beyond Journey's End"], + "tv", + ).size, + ).toBe(0); + }); + it("剧集只看带 SxxEyy 的文件:只有集标题的文件名不拦", () => { + const r = titleMismatches( + [ + { path: "a", name: "Severance.S01E01.1080p.WEB-DL.H264-GRP.mkv" }, + { path: "b", name: "Other.Show.S01E01.1080p.WEB-DL.mkv" }, + { path: "c", name: "01 - The Beginning.mkv" }, + ], + ["人生切割术", "Severance"], + "tv", + ); + expect([...r.keys()]).toEqual(["b"]); + }); + it("中文标题按相邻两字比较:同系列相近的标题不拦", () => { + expect(titleMatchesNames("哈利波特与密室", ["哈利波特与魔法石"])).toBe(true); + expect(titleMatchesNames("星际穿越", ["盗梦空间"])).toBe(false); + }); +}); diff --git a/packages/mapping/src/organize.ts b/packages/mapping/src/organize.ts index 8a3a99e8..cfee7ea2 100644 --- a/packages/mapping/src/organize.ts +++ b/packages/mapping/src/organize.ts @@ -143,10 +143,15 @@ export function organizePlanToFileResults( } /** - * 被排除但"本该入库"的视频:季集没定下来、没有对应季目录、命名缺字段。文件留在原处没人管, + * 被排除但"本该入库"的视频:季集没定下来、没有对应季目录、命名缺字段、文件标题明显不是本作品(待确认)。文件留在原处没人管, * 不能算整理成功(Move 成功后会移除下载任务)。样例、过小、已在库等预期跳过不在此列。 */ -export const UNRESOLVED_EXCLUSIONS = ["episode_unresolved", "no_target_dir", "naming_failed"] as const; +export const UNRESOLVED_EXCLUSIONS = [ + "episode_unresolved", + "no_target_dir", + "naming_failed", + "title_mismatch", +] as const; const UNRESOLVED_SKIP = new Set(UNRESOLVED_EXCLUSIONS.map((r) => `excluded:${r}`)); /** 文件结果的 skip_reason 是否属于"本该入库却被排除" */