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 = { copy: "copy", move: "move", rename: "rename", in_place: "in_place", }; const ACTION_BACK: Record = { 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 = { 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): FileRole { return f.role === "main" ? "main" : SIDECAR_ROLE[f.kind]; } /** 计划动作 → 文件结果初始状态;hold 表示被阻塞冲突挡住,不执行,记为 skipped(skipReason=held) */ const PLANNED_STATUS: Record = { 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 & { 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(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, }; }