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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user