- 同一批视频里文件名标题与作品标题 / 原名 / 别名明显不符(没有足够共同词)的排除为 title_mismatch,不按本作品命名入库; - 只在同批至少一个文件对得上作品时才判断,剧集只看带 SxxEyy 的文件,用户逐文件指定过的不核; - title_mismatch 算「本该入库却被排除」:记录部分完成、开待处理、Move 不移除下载任务。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
215 lines
7.2 KiB
TypeScript
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,
|
|
};
|
|
}
|