feat(organize): 文件级标题粗核,混合包里明显不是本作品的视频降为待确认
- 同一批视频里文件名标题与作品标题 / 原名 / 别名明显不符(没有足够共同词)的排除为 title_mismatch,不按本作品命名入库; - 只在同批至少一个文件对得上作品时才判断,剧集只看带 SxxEyy 的文件,用户逐文件指定过的不核; - title_mismatch 算「本该入库却被排除」:记录部分完成、开待处理、Move 不移除下载任务。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
b40716d74f
commit
c532060c83
10 files changed
+283
-10
No files matched your search
@@ -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<string, string> = {
|
||||
episode_unresolved: "集数待确认",
|
||||
no_target_dir: "没有季目录",
|
||||
naming_failed: "命名失败",
|
||||
title_mismatch: "标题不是本作品",
|
||||
};
|
||||
|
||||
/** 登记资产/位置/绑定 + Slot/计划/下载任务 + 记录终态,同一事务;记录行锁作 fencing */
|
||||
export async function finalizeRecord(d: AoDeps, recordId: string): Promise<ExecuteResult> {
|
||||
const db = d.ctx.db;
|
||||
@@ -350,13 +358,14 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
||||
.where("id", "=", recordId)
|
||||
.execute();
|
||||
if (unresolved.length > 0) {
|
||||
const shown = unresolved
|
||||
.slice(0, 5)
|
||||
.map((f) => `${f.source_path.split("/").pop()}(${f.skip_reason?.replace(/^excluded:/, "")})`);
|
||||
const shown = unresolved.slice(0, 5).map((f) => {
|
||||
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",
|
||||
|
||||
@@ -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<PlanRes
|
||||
const episodes = mediaType === "tv" ? await planFileEpisodes(d, entity, rel, dt) : null;
|
||||
const mappedOverrides: Record<string, DomainFileOverride> = {};
|
||||
const unresolvedEpisodes: NonNullable<FrozenPlan["unresolvedEpisodes"]> = {};
|
||||
// 文件级标题粗核:混合包 / 合集里标题明显不是本作品的视频不按本作品命名入库(待确认 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<MediaEntityRow, "id" | "title" | "original_title" | "metadata">,
|
||||
mediaType: "movie" | "tv",
|
||||
videos: readonly { path: string }[],
|
||||
): Promise<Map<string, string>> {
|
||||
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<string, { value?: unknown } | undefined>;
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -410,6 +410,7 @@ export const EXCLUDE_REASON: Record<string, string> = {
|
||||
extra_content: "额外内容",
|
||||
no_target_dir: "没有目标目录",
|
||||
naming_failed: "命名失败",
|
||||
title_mismatch: "标题不是本作品",
|
||||
};
|
||||
|
||||
export const SKIP_REASON: Record<string, string> = {
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string> {
|
||||
const out = new Set<string>();
|
||||
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<string, string> {
|
||||
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<string, string>();
|
||||
if (!judged.some((j) => j.ok)) return out;
|
||||
for (const j of judged) if (!j.ok) out.set(j.path, j.title);
|
||||
return out;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string>(UNRESOLVED_EXCLUSIONS.map((r) => `excluded:${r}`));
|
||||
|
||||
/** 文件结果的 skip_reason 是否属于"本该入库却被排除" */
|
||||
|
||||
Reference in new issue
Block a user