Files
UHD_Media_Tool/packages/mapping/src/organize.ts
T
rootandClaude Opus 5.5 c532060c83 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
2026-10-03 23:33:41 +08:00

215 lines
7.2 KiB
TypeScript

import type {
ActorType,
FileResultStatus,
FileRole,
JsonObject,
OrganizationAction,
OrganizationFileResultCreate,
OrganizationRecordCreate,
OrganizationStatus,
OrganizationTrigger,
OrganizePlanOutput,
} from "@umt/contracts";
import type {
ExcludedFile,
OrganizeFileKind,
OrganizeOperation,
OrganizePlan,
PlannedAction,
PlannedFile,
} from "@umt/domain";
// 整理计划(domain.planOrganize)→ organization_record / organization_file_result,以及 organize.plan 能力输出。
const OPERATION: Record<OrganizeOperation, OrganizationAction> = {
copy: "copy",
move: "move",
rename: "rename",
in_place: "in_place",
};
const ACTION_BACK: Record<OrganizationAction, OrganizeOperation | null> = {
copy: "copy",
in_place: "in_place",
move: "move",
rename: "rename",
// domain 不生成目录调整/硬链接计划
relocate: null,
hardlink: null,
// 文件管理的删除/还原记录不对应整理计划
trash: null,
delete: null,
restore: null,
};
export const DOMAIN_ORGANIZE_OPERATIONS = Object.keys(OPERATION) as OrganizeOperation[];
export const toOrganizationAction = (op: OrganizeOperation): OrganizationAction => OPERATION[op];
export const toOrganizeOperation = (a: OrganizationAction): OrganizeOperation | null => ACTION_BACK[a];
const SIDECAR_ROLE: Record<OrganizeFileKind, FileRole> = {
video: "main",
subtitle: "subtitle",
audio: "audio",
nfo: "nfo",
other: "other",
};
export const DOMAIN_FILE_KINDS = Object.keys(SIDECAR_ROLE) as OrganizeFileKind[];
export function toFileRole(f: Pick<PlannedFile, "role" | "kind">): FileRole {
return f.role === "main" ? "main" : SIDECAR_ROLE[f.kind];
}
/** 计划动作 → 文件结果初始状态;hold 表示被阻塞冲突挡住,不执行,记为 skipped(skipReason=held) */
const PLANNED_STATUS: Record<PlannedAction, FileResultStatus> = {
transfer: "queued",
replace: "queued",
skip: "skipped",
hold: "skipped",
};
export const DOMAIN_PLANNED_ACTIONS = Object.keys(PLANNED_STATUS) as PlannedAction[];
export interface OrganizeRecordContext {
trigger: OrganizationTrigger;
actorType: ActorType;
actorId?: string | null;
taskId?: string | null;
mediaEntityId?: string | null;
contentUnitId?: string | null;
downloadTaskId?: string | null;
sourceDownloaderInstanceId?: string | null;
/** 源根路径(执行进程本地视图) */
sourcePath?: string | null;
targetLibraryRootId?: string | null;
targetPath?: string | null;
/** 冻结计划的指纹(payloadHash) */
planFingerprint?: string | null;
}
const toJson = (v: unknown) => JSON.parse(JSON.stringify(v)) as JsonObject;
/** 整理计划 → organization_record 新建输入(源路径按本地视图记录,计划整体冻结进 plan 列) */
export function organizePlanToRecord(plan: OrganizePlan, ctx: OrganizeRecordContext): OrganizationRecordCreate {
const { trigger, actorType, ...rest } = ctx;
const optional = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined));
return {
trigger,
actorType,
...optional,
sourcePathView: "local",
action: OPERATION[plan.operation],
plan: toJson(plan),
};
}
/** 文件结果草稿:organizationRecordId 在记录写入后由调用方补上 */
export type OrganizationFileResultDraft = Omit<OrganizationFileResultCreate, "organizationRecordId"> & {
status: FileResultStatus;
skipReason: string | null;
};
/**
* 整理计划 → 逐文件结果草稿。计划内文件按顺序编号,排除的文件(样例、孤立附属…)追加在后面并记为 skipped,
* 让整理记录能解释"为什么没整理这个文件"。
*/
export function organizePlanToFileResults(
plan: OrganizePlan,
opts: { targetLibraryRootId?: string | null } = {},
): OrganizationFileResultDraft[] {
const mode = OPERATION[plan.operation];
const planned = plan.files.map(
(f, i): OrganizationFileResultDraft => ({
ordinal: i,
role: toFileRole(f),
mode,
sourcePath: f.src,
sourceSizeBytes: f.size,
targetLibraryRootId: opts.targetLibraryRootId ?? null,
targetPath: f.dst,
status: PLANNED_STATUS[f.action],
skipReason: f.action === "skip" ? (f.skipReason ?? "skipped") : f.action === "hold" ? "held" : null,
}),
);
const excluded = plan.excluded.map(
(x: ExcludedFile, i): OrganizationFileResultDraft => ({
ordinal: planned.length + i,
role: x.kind === "video" ? "main" : SIDECAR_ROLE[x.kind],
mode,
sourcePath: x.src,
targetPath: null,
status: "skipped",
skipReason: `excluded:${x.reason}`,
}),
);
return [...planned, ...excluded];
}
/**
* 被排除但"本该入库"的视频:季集没定下来、没有对应季目录、命名缺字段、文件标题明显不是本作品(待确认)。文件留在原处没人管,
* 不能算整理成功(Move 成功后会移除下载任务)。样例、过小、已在库等预期跳过不在此列。
*/
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 是否属于"本该入库却被排除" */
export const isUnresolvedExclusion = (skipReason: string | null | undefined): boolean =>
!!skipReason && UNRESOLVED_SKIP.has(skipReason);
/**
* 逐文件状态 → 整理记录状态(全部完成后调用;仍有 queued/running 时为 running)。
* unresolved:被排除的待确认视频数(isUnresolvedExclusion),有则成功降为 partial。
*/
export function organizationStatusFromFiles(
statuses: readonly FileResultStatus[],
opts: { unresolved?: number } = {},
): OrganizationStatus {
if (statuses.some((s) => s === "queued" || s === "running")) return "running";
const done = statuses.filter((s) => s !== "skipped");
const ok = done.filter((s) => s === "succeeded").length;
// 全部跳过(或没有文件)视为成功:没有需要做的事
const base: OrganizationStatus =
done.length === 0 || ok === done.length
? "succeeded"
: done.every((s) => s === "cancelled")
? "cancelled"
: ok > 0
? "partial"
: "failed";
return base === "succeeded" && (opts.unresolved ?? 0) > 0 ? "partial" : base;
}
/** 整理计划 → organize.plan 能力输出(planId / payloadHash 由保存计划的一方给出) */
export function organizePlanToCapabilityOutput(
plan: OrganizePlan,
saved: { planId: string; payloadHash: string },
): OrganizePlanOutput {
const action = OPERATION[plan.operation];
return {
planId: saved.planId,
payloadHash: saved.payloadHash,
files: [
...plan.files.map((f) => ({
sourcePath: f.src,
targetPath: f.dst,
action,
skipReason: f.action === "skip" ? (f.skipReason ?? "skipped") : f.action === "hold" ? "held" : null,
})),
...plan.excluded.map((x) => ({
sourcePath: x.src,
targetPath: null,
action,
skipReason: `excluded:${x.reason}`,
})),
],
conflicts: plan.conflicts.map((c) => ({
code: c.kind,
message: c.detail ?? (c.blocking ? `${c.kind}(需要处理后才能执行)` : c.kind),
path: c.dst ?? c.src ?? null,
})),
requiresApproval: plan.requiresApproval,
};
}