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:
rootandClaude Opus 5.5 committed 2026-10-03 23:33:41 +08:00
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,
);
}