feat(mapping): centralize domain <-> contracts mappings (B5)

New @umt/mapping (depends on domain + contracts only): download status + pause reason,
evidence level / media kind, identity resolution status, slot status / spec columns /
evaluation, ParsedRelease <-> source_release.parsed, SiteRelease <-> source_release,
PathBinding, OrganizePlan -> organization_record / file results / organize.plan output.
Tables are Record<union, ...> so adding a value on either side fails typecheck or tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 19:20:30 +08:00
1 parent da8e53bbca
commit 42ecc93fa5
16 files changed
+1282 -1

No files matched your search

+177
View File
@@ -0,0 +1,177 @@
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" };
const ACTION_BACK: Record<OrganizationAction, OrganizeOperation | null> = {
copy: "copy",
move: "move",
rename: "rename",
// domain 不生成目录调整/硬链接计划
relocate: null,
hardlink: 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];
}
/** 逐文件状态 → 整理记录状态(全部完成后调用;仍有 queued/running 时为 running) */
export function organizationStatusFromFiles(statuses: readonly FileResultStatus[]): OrganizationStatus {
if (statuses.some((s) => s === "queued" || s === "running")) return "running";
const done = statuses.filter((s) => s !== "skipped");
// 全部跳过(或没有文件)视为成功:没有需要做的事
if (done.length === 0) return "succeeded";
const ok = done.filter((s) => s === "succeeded").length;
if (ok === done.length) return "succeeded";
if (done.every((s) => s === "cancelled")) return "cancelled";
return ok > 0 ? "partial" : "failed";
}
/** 整理计划 → 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,
};
}