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:
1 parent
da8e53bbca
commit
42ecc93fa5
16 files changed
+1282
-1
No files matched your search
@@ -0,0 +1,87 @@
|
||||
import type { DownloadStatus, PauseReason } from "@umt/contracts";
|
||||
import type { DownloadTaskStatus as DomainDownloadStatus } from "@umt/domain";
|
||||
|
||||
// 下载状态:domain 只描述"下载器里看到的状态"(mapQbState 的结果),
|
||||
// contracts / download_task.status 还叠加业务阶段(submitting / stalled / importing / imported)和 pauseReason。
|
||||
|
||||
export type { DomainDownloadStatus };
|
||||
|
||||
export interface ContractDownloadState {
|
||||
status: DownloadStatus;
|
||||
pauseReason: PauseReason | null;
|
||||
}
|
||||
|
||||
const TO_CONTRACT: Record<DomainDownloadStatus, ContractDownloadState> = {
|
||||
queued: { status: "queued", pauseReason: null },
|
||||
downloading: { status: "downloading", pauseReason: null },
|
||||
paused_by_threshold: { status: "paused", pauseReason: "threshold" },
|
||||
paused_by_user: { status: "paused", pauseReason: "user" },
|
||||
completed: { status: "completed", pauseReason: null },
|
||||
error: { status: "failed", pauseReason: null },
|
||||
missing_files: { status: "missing", pauseReason: null },
|
||||
removed: { status: "removed", pauseReason: null },
|
||||
};
|
||||
|
||||
/** contracts 状态 → 下载器层面的 domain 状态(业务叠加态折回其底层下载状态) */
|
||||
const TO_DOMAIN: Record<DownloadStatus, DomainDownloadStatus | "by_pause_reason"> = {
|
||||
queued: "queued",
|
||||
// 提交中:下载器里还没有/刚出现,按排队处理
|
||||
submitting: "queued",
|
||||
downloading: "downloading",
|
||||
paused: "by_pause_reason",
|
||||
// 无进展是业务判定(detectNoProgress),下载器里仍是 downloading
|
||||
stalled: "downloading",
|
||||
completed: "completed",
|
||||
// 入库阶段:下载本身已完成
|
||||
importing: "completed",
|
||||
imported: "completed",
|
||||
failed: "error",
|
||||
removed: "removed",
|
||||
missing: "missing_files",
|
||||
};
|
||||
|
||||
/** 暂停原因 → domain 状态:只有 threshold 会被阈值恢复自动恢复,其余都按用户暂停对待(REQ §8.4.1) */
|
||||
const PAUSE_TO_DOMAIN: Record<PauseReason, DomainDownloadStatus> = {
|
||||
threshold: "paused_by_threshold",
|
||||
user: "paused_by_user",
|
||||
policy: "paused_by_user",
|
||||
agent: "paused_by_user",
|
||||
};
|
||||
|
||||
export const DOMAIN_DOWNLOAD_STATUSES = Object.keys(TO_CONTRACT) as DomainDownloadStatus[];
|
||||
|
||||
export function toContractDownloadState(s: DomainDownloadStatus): ContractDownloadState {
|
||||
return { ...TO_CONTRACT[s] };
|
||||
}
|
||||
|
||||
export function toDomainDownloadStatus(status: DownloadStatus, pauseReason: PauseReason | null): DomainDownloadStatus {
|
||||
const d = TO_DOMAIN[status];
|
||||
if (d !== "by_pause_reason") return d;
|
||||
return PAUSE_TO_DOMAIN[pauseReason ?? "user"];
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步轮询时合并"下载器观察"与"当前业务状态":
|
||||
* - 业务叠加态在其底层下载状态不变时保留(importing/imported 遇 completed、stalled 遇 downloading、submitting 遇 queued);
|
||||
* - 暂停原因为 policy/agent 且下载器仍是暂停时保留原因,不被改写成 user;
|
||||
* - 其余以观察为准。
|
||||
*/
|
||||
export function mergeObservedDownloadState(
|
||||
current: ContractDownloadState,
|
||||
observed: DomainDownloadStatus,
|
||||
): ContractDownloadState {
|
||||
const next = TO_CONTRACT[observed];
|
||||
const keep =
|
||||
((current.status === "importing" || current.status === "imported") && observed === "completed") ||
|
||||
(current.status === "stalled" && observed === "downloading") ||
|
||||
(current.status === "submitting" && observed === "queued");
|
||||
if (keep) return { ...current };
|
||||
if (
|
||||
current.status === "paused" &&
|
||||
observed === "paused_by_user" &&
|
||||
(current.pauseReason === "policy" || current.pauseReason === "agent")
|
||||
) {
|
||||
return { ...current };
|
||||
}
|
||||
return { ...next };
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { EvidenceLevel as ContractEvidenceLevel, ExternalEntityType, MediaKind } from "@umt/contracts";
|
||||
import type { Confidence, EvidenceLevel as DomainMatchLevel } from "@umt/domain";
|
||||
|
||||
// 证据等级:domain 的 EvidenceLevel 实为"匹配等级"(含 rejected),这里以 DomainMatchLevel 称呼;
|
||||
// contracts 的 EvidenceLevel 是落库/展示用的证据等级(含 manual、needs_review)。
|
||||
|
||||
export type { DomainMatchLevel };
|
||||
|
||||
const LEVEL_TO_CONTRACT: Record<DomainMatchLevel, ContractEvidenceLevel | null> = {
|
||||
rule_confirmed: "rule_confirmed",
|
||||
multi_evidence: "multi_evidence",
|
||||
needs_confirm: "needs_review",
|
||||
// 被否决的候选不形成证据记录
|
||||
rejected: null,
|
||||
};
|
||||
|
||||
const LEVEL_TO_DOMAIN: Record<ContractEvidenceLevel, Confidence> = {
|
||||
// 人工确认按最强规则证据参与排序
|
||||
manual: "rule_confirmed",
|
||||
rule_confirmed: "rule_confirmed",
|
||||
multi_evidence: "multi_evidence",
|
||||
needs_review: "needs_confirm",
|
||||
};
|
||||
|
||||
export const DOMAIN_MATCH_LEVELS = Object.keys(LEVEL_TO_CONTRACT) as DomainMatchLevel[];
|
||||
|
||||
/** domain 匹配等级 / 解析置信度 → contracts 证据等级;rejected 返回 null */
|
||||
export function toContractEvidenceLevel(level: DomainMatchLevel): ContractEvidenceLevel | null {
|
||||
return LEVEL_TO_CONTRACT[level];
|
||||
}
|
||||
|
||||
export function toDomainConfidence(level: ContractEvidenceLevel): Confidence {
|
||||
return LEVEL_TO_DOMAIN[level];
|
||||
}
|
||||
|
||||
// ── 媒体类型:domain 只分 movie / tv ──
|
||||
|
||||
export type DomainMediaType = "movie" | "tv";
|
||||
|
||||
const MEDIA_TO_DOMAIN: Record<MediaKind, DomainMediaType | null> = {
|
||||
movie: "movie",
|
||||
series: "tv",
|
||||
// 综艺按剧集规则(季/期)处理
|
||||
variety: "tv",
|
||||
collection: null,
|
||||
other: null,
|
||||
};
|
||||
|
||||
export function toDomainMediaType(kind: MediaKind): DomainMediaType | null {
|
||||
return MEDIA_TO_DOMAIN[kind];
|
||||
}
|
||||
|
||||
/** domain → contracts 媒体类型;tv 默认落为 series,综艺由调用方按来源分类显式指定 */
|
||||
export function toContractMediaKind(t: DomainMediaType, opts: { variety?: boolean } = {}): MediaKind {
|
||||
return t === "movie" ? "movie" : opts.variety ? "variety" : "series";
|
||||
}
|
||||
|
||||
const ENTITY_TYPE_TO_DOMAIN: Record<ExternalEntityType, DomainMediaType | null> = {
|
||||
movie: "movie",
|
||||
tv: "tv",
|
||||
season: null,
|
||||
episode: null,
|
||||
collection: null,
|
||||
person: null,
|
||||
};
|
||||
|
||||
export function toDomainEntityType(t: ExternalEntityType): DomainMediaType | null {
|
||||
return ENTITY_TYPE_TO_DOMAIN[t];
|
||||
}
|
||||
|
||||
export function toContractEntityType(t: DomainMediaType): ExternalEntityType {
|
||||
return t;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type {
|
||||
ExternalIdentity,
|
||||
ExternalIdentityCreate,
|
||||
IdentityResolveOutput,
|
||||
IdentityStatus,
|
||||
MediaEntity,
|
||||
ResolutionStatus,
|
||||
} from "@umt/contracts";
|
||||
import type { IdentityCandidate, IdentityResolution, RankedCandidate } from "@umt/domain";
|
||||
import { toContractEvidenceLevel, toDomainMediaType } from "./evidence.ts";
|
||||
|
||||
// 身份解析:domain.resolveIdentity(auto / ambiguous / no_match)→ contracts 的能力输出状态
|
||||
// 与 source_release.resolution_status。
|
||||
|
||||
export type DomainResolutionStatus = IdentityResolution["status"];
|
||||
export type ContractResolveStatus = IdentityResolveOutput["status"];
|
||||
|
||||
export interface ResolveStatusOptions {
|
||||
/**
|
||||
* auto 时是否可直接接受(automation 总开关/授权/人工锁都允许)。
|
||||
* false 时产出 proposed:证据足够但需要确认,进待识别池。
|
||||
*/
|
||||
autoAccept: boolean;
|
||||
}
|
||||
|
||||
const RESOLVE_STATUS: Record<DomainResolutionStatus, (o: ResolveStatusOptions) => ContractResolveStatus> = {
|
||||
auto: (o) => (o.autoAccept ? "resolved" : "proposed"),
|
||||
ambiguous: () => "ambiguous",
|
||||
no_match: () => "unidentifiable",
|
||||
};
|
||||
|
||||
export const DOMAIN_RESOLUTION_STATUSES = Object.keys(RESOLVE_STATUS) as DomainResolutionStatus[];
|
||||
|
||||
export function toContractResolveStatus(
|
||||
status: DomainResolutionStatus,
|
||||
opts: ResolveStatusOptions,
|
||||
): ContractResolveStatus {
|
||||
return RESOLVE_STATUS[status](opts);
|
||||
}
|
||||
|
||||
const RELEASE_RESOLUTION: Record<ContractResolveStatus, ResolutionStatus> = {
|
||||
resolved: "resolved",
|
||||
// 待确认的提议与歧义一样进待识别池
|
||||
proposed: "ambiguous",
|
||||
ambiguous: "ambiguous",
|
||||
unidentifiable: "unidentifiable",
|
||||
};
|
||||
|
||||
/** 能力输出状态 → source_release.resolution_status */
|
||||
export function toReleaseResolutionStatus(status: ContractResolveStatus): ResolutionStatus {
|
||||
return RELEASE_RESOLUTION[status];
|
||||
}
|
||||
|
||||
/**
|
||||
* 排名候选 → external_identity 新建输入。本地候选(provider=local)不是外部身份,返回 null;
|
||||
* 被否决的候选同样返回 null。
|
||||
*/
|
||||
export function rankedToIdentityCreate(
|
||||
r: RankedCandidate,
|
||||
target: { mediaEntityId: string } | { contentUnitId: string },
|
||||
status: IdentityStatus = "proposed",
|
||||
): ExternalIdentityCreate | null {
|
||||
const level = toContractEvidenceLevel(r.level);
|
||||
if (!level || r.candidate.provider === "local") return null;
|
||||
return {
|
||||
...target,
|
||||
provider: r.candidate.provider,
|
||||
entityType: r.candidate.entityType,
|
||||
externalId: r.candidate.externalId,
|
||||
status,
|
||||
evidenceLevel: level,
|
||||
evidence: {
|
||||
titleMatch: r.titleMatch,
|
||||
yearMatch: r.yearMatch,
|
||||
typeMatch: r.typeMatch,
|
||||
rules: r.evidence,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地作品 → domain 身份候选(本地优先召回)。externalId 用本地 UUID;
|
||||
* 已接受的外部身份作为 linkedExternalIds,用于与远端候选判同。
|
||||
* 媒体类型无法映射到 movie/tv(合集/其他)时返回 null。
|
||||
*/
|
||||
export function localIdentityCandidate(
|
||||
entity: Pick<MediaEntity, "id" | "kind" | "title" | "originalTitle" | "year">,
|
||||
aliases: readonly string[],
|
||||
identities: readonly Pick<ExternalIdentity, "provider" | "externalId" | "status">[] = [],
|
||||
): IdentityCandidate | null {
|
||||
const entityType = toDomainMediaType(entity.kind);
|
||||
if (!entityType) return null;
|
||||
const titles = [entity.title, ...(entity.originalTitle ? [entity.originalTitle] : [])];
|
||||
return {
|
||||
provider: "local",
|
||||
entityType,
|
||||
externalId: entity.id,
|
||||
titles,
|
||||
...(entity.year !== null ? { year: entity.year } : {}),
|
||||
aliases: [...aliases],
|
||||
localEntityId: entity.id,
|
||||
linkedExternalIds: identities
|
||||
.filter((i) => i.status === "accepted")
|
||||
.map((i) => ({ provider: i.provider, id: i.externalId })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// @umt/mapping 公开入口:domain ↔ contracts 的集中映射(W1 审查 B5)。
|
||||
// 只依赖 @umt/domain 与 @umt/contracts;不依赖 db / adapters(adapters 的 SiteRelease 以结构类型 SiteReleaseLike 接入)。
|
||||
// db 行(snake_case)↔ contracts(camelCase)的转换不在这里。
|
||||
export const PACKAGE_NAME = "@umt/mapping";
|
||||
|
||||
export * from "./download.ts";
|
||||
export * from "./evidence.ts";
|
||||
export * from "./identity.ts";
|
||||
export * from "./organize.ts";
|
||||
export * from "./path-binding.ts";
|
||||
export * from "./release.ts";
|
||||
export * from "./site-release.ts";
|
||||
export * from "./slot.ts";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { PathBinding as ContractPathBinding, PathBindingCreate } from "@umt/contracts";
|
||||
import type { PathBinding as DomainPathBinding, NewBinding } from "@umt/domain";
|
||||
|
||||
// 路径绑定:domain {entityId, scope, season, templateVersion:number, active}
|
||||
// ↔ contracts / path_binding {mediaEntityId, bindingKind, seasonNumber, templateVersion:string|null, status}
|
||||
|
||||
export type { DomainPathBinding };
|
||||
|
||||
/** 模板版本:库里是文本(可能是 "3" 或历史导入的任意串),domain 用整数;无法解析按 0(未知)处理 */
|
||||
export function parseTemplateVersion(v: string | null): number {
|
||||
return v !== null && /^\d+$/.test(v) ? Number(v) : 0;
|
||||
}
|
||||
|
||||
/** path_binding 行 → domain;只有 status=active 的绑定参与目标目录决策 */
|
||||
export function toDomainPathBinding(b: ContractPathBinding): DomainPathBinding {
|
||||
return {
|
||||
id: b.id,
|
||||
entityId: b.mediaEntityId,
|
||||
scope: b.bindingKind,
|
||||
...(b.seasonNumber !== null ? { season: b.seasonNumber } : {}),
|
||||
libraryRootId: b.libraryRootId,
|
||||
relativePath: b.relativePath,
|
||||
templateVersion: parseTemplateVersion(b.templateVersion),
|
||||
active: b.status === "active",
|
||||
};
|
||||
}
|
||||
|
||||
/** domain 决策里的新绑定 → path_binding 新建输入(status=active) */
|
||||
export function toPathBindingCreate(
|
||||
nb: NewBinding,
|
||||
target: { mediaEntityId: string; contentUnitId?: string | null; note?: string | null },
|
||||
): PathBindingCreate {
|
||||
return {
|
||||
mediaEntityId: target.mediaEntityId,
|
||||
...(target.contentUnitId !== undefined ? { contentUnitId: target.contentUnitId } : {}),
|
||||
bindingKind: nb.scope,
|
||||
...(nb.scope === "season" ? { seasonNumber: nb.season ?? null } : {}),
|
||||
libraryRootId: nb.libraryRootId,
|
||||
relativePath: nb.relativePath,
|
||||
templateVersion: String(nb.templateVersion),
|
||||
status: "active",
|
||||
...(target.note !== undefined ? { note: target.note } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { ContentType as ContractContentType, Segment as ContractSegment, JsonObject } from "@umt/contracts";
|
||||
import {
|
||||
type ContentType as DomainContentType,
|
||||
type Segment as DomainSegment,
|
||||
PARSER_VERSION,
|
||||
type ParsedRelease,
|
||||
} from "@umt/domain";
|
||||
|
||||
// 标题解析结果 ↔ source_release.parsed / parser_version 列;综艺内容类型与分段 ↔ content_unit 列。
|
||||
|
||||
/** ParsedRelease → source_release 的 parsed(jsonb)与 parser_version(text) */
|
||||
export function toParsedColumns(p: ParsedRelease): { parsed: JsonObject; parserVersion: string } {
|
||||
return { parsed: JSON.parse(JSON.stringify(p)) as JsonObject, parserVersion: String(p.parserVersion) };
|
||||
}
|
||||
|
||||
const REQUIRED_ARRAYS = ["audio", "hdr", "variantTags", "explicitIds", "evidence"] as const;
|
||||
|
||||
/**
|
||||
* source_release 列 → ParsedRelease。版本不是当前 PARSER_VERSION 或形状不对时返回 null,
|
||||
* 调用方应按原标题重新解析(规则变化后旧结果作废)。
|
||||
*/
|
||||
export function fromParsedColumns(cols: { parsed: JsonObject; parserVersion: string | null }): ParsedRelease | null {
|
||||
if (cols.parserVersion !== String(PARSER_VERSION)) return null;
|
||||
const p = cols.parsed;
|
||||
if (p.parserVersion !== PARSER_VERSION || typeof p.raw !== "string" || typeof p.isEncode !== "boolean") return null;
|
||||
if (typeof p.contentType !== "string" || typeof p.confidence !== "string") return null;
|
||||
if (REQUIRED_ARRAYS.some((k) => !Array.isArray(p[k]))) return null;
|
||||
return p as unknown as ParsedRelease;
|
||||
}
|
||||
|
||||
const CONTENT_TYPE: Record<DomainContentType, ContractContentType> = {
|
||||
unknown: "unknown",
|
||||
main: "main",
|
||||
extra: "extra",
|
||||
behind_the_scenes: "behind_the_scenes",
|
||||
performance_only: "performance_only",
|
||||
preview: "preview",
|
||||
reaction: "reaction",
|
||||
};
|
||||
const CONTENT_TYPE_BACK: Record<ContractContentType, DomainContentType> = {
|
||||
main: "main",
|
||||
extra: "extra",
|
||||
behind_the_scenes: "behind_the_scenes",
|
||||
performance_only: "performance_only",
|
||||
preview: "preview",
|
||||
reaction: "reaction",
|
||||
unknown: "unknown",
|
||||
};
|
||||
export const DOMAIN_CONTENT_TYPES = Object.keys(CONTENT_TYPE) as DomainContentType[];
|
||||
|
||||
export const toContractContentType = (t: DomainContentType): ContractContentType => CONTENT_TYPE[t];
|
||||
export const toDomainContentType = (t: ContractContentType): DomainContentType => CONTENT_TYPE_BACK[t];
|
||||
|
||||
const SEGMENT: Record<DomainSegment, ContractSegment> = {
|
||||
full: "full",
|
||||
upper: "upper",
|
||||
middle: "middle",
|
||||
lower: "lower",
|
||||
};
|
||||
const SEGMENT_BACK: Record<ContractSegment, DomainSegment | undefined> = {
|
||||
full: "full",
|
||||
upper: "upper",
|
||||
middle: "middle",
|
||||
lower: "lower",
|
||||
// domain 用"字段缺失"表示未知
|
||||
unknown: undefined,
|
||||
};
|
||||
export const DOMAIN_SEGMENTS = Object.keys(SEGMENT) as DomainSegment[];
|
||||
|
||||
/** 解析结果没有分段信息(undefined)时落为 unknown */
|
||||
export const toContractSegment = (s: DomainSegment | undefined): ContractSegment => (s ? SEGMENT[s] : "unknown");
|
||||
export const toDomainSegment = (s: ContractSegment): DomainSegment | undefined => SEGMENT_BACK[s];
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { JsonObject, SourceRelease, SourceReleaseCreate } from "@umt/contracts";
|
||||
import type { ParsedRelease } from "@umt/domain";
|
||||
import { toParsedColumns } from "./release.ts";
|
||||
|
||||
// 站点资源(adapters 的 SiteRelease)↔ source_release。
|
||||
// mapping 不依赖 adapters:这里用结构相同的 SiteReleaseLike,adapters 的 SiteRelease 可直接传入
|
||||
// (apps/server/test/mapping-compat.test.ts 在类型层校验两者兼容)。
|
||||
|
||||
export type SitePromotionType = "none" | "free" | "2xfree" | "50%" | "2x50%" | "30%" | "2x" | "unknown";
|
||||
|
||||
export interface SiteReleaseLike {
|
||||
/** 适配器侧的站点标识(可能是站点 key);落库时用 site 行 UUID 覆盖 */
|
||||
siteId: string;
|
||||
siteReleaseId: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
category: { id: string | null; name: string | null };
|
||||
sizeBytes: number | null;
|
||||
seeders: number | null;
|
||||
leechers: number | null;
|
||||
snatched: number | null;
|
||||
promotion: {
|
||||
type: SitePromotionType;
|
||||
downloadFactor: number | null;
|
||||
uploadFactor: number | null;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
hr: boolean | "unknown";
|
||||
uploadedAt: string | null;
|
||||
detailUrl: string;
|
||||
downloadUrl?: string;
|
||||
infoHash?: string;
|
||||
imdbId?: string;
|
||||
doubanId?: string;
|
||||
tags: string[];
|
||||
raw: Record<string, unknown>;
|
||||
observedAt: string;
|
||||
}
|
||||
|
||||
const HEX40 = /^[0-9a-f]{40}$/;
|
||||
const HEX64 = /^[0-9a-f]{64}$/;
|
||||
const PROMOTION_TYPES: readonly SitePromotionType[] = [
|
||||
"none",
|
||||
"free",
|
||||
"2xfree",
|
||||
"50%",
|
||||
"2x50%",
|
||||
"30%",
|
||||
"2x",
|
||||
"unknown",
|
||||
];
|
||||
|
||||
/**
|
||||
* SiteRelease → source_release 新建/upsert 输入(按 siteId + siteReleaseId upsert)。
|
||||
* - siteId 取 site 行 UUID(opts.siteId),不是适配器里的站点 key;
|
||||
* - downloadUrl 不入库(可能需要 cookie 或含一次性令牌,下载时现场向适配器取);
|
||||
* - H&R、完成数、详情页、外部 ID 等无专门列的字段放进 raw._site。
|
||||
*/
|
||||
export function siteReleaseToSourceRelease(
|
||||
r: SiteReleaseLike,
|
||||
opts: { siteId: string; parsed?: ParsedRelease },
|
||||
): SourceReleaseCreate {
|
||||
const hash = r.infoHash?.toLowerCase();
|
||||
const site: JsonObject = {
|
||||
adapterSiteId: r.siteId,
|
||||
categoryName: r.category.name,
|
||||
hr: r.hr,
|
||||
snatched: r.snatched,
|
||||
detailUrl: r.detailUrl,
|
||||
...(r.imdbId ? { imdbId: r.imdbId } : {}),
|
||||
...(r.doubanId ? { doubanId: r.doubanId } : {}),
|
||||
};
|
||||
return {
|
||||
siteId: opts.siteId,
|
||||
siteReleaseId: r.siteReleaseId,
|
||||
title: r.title,
|
||||
subtitle: r.subtitle ?? null,
|
||||
categoryRaw: r.category.id ?? r.category.name,
|
||||
tagsRaw: [...r.tags],
|
||||
sizeBytes: r.sizeBytes,
|
||||
infohashV1: hash && HEX40.test(hash) ? hash : null,
|
||||
infohashV2: hash && HEX64.test(hash) ? hash : null,
|
||||
promotion: {
|
||||
kind: r.promotion.type,
|
||||
observedAt: r.observedAt,
|
||||
expiresAt: r.promotion.expiresAt,
|
||||
raw: { downloadFactor: r.promotion.downloadFactor, uploadFactor: r.promotion.uploadFactor },
|
||||
},
|
||||
promotionExpiresAt: r.promotion.expiresAt,
|
||||
seeders: r.seeders,
|
||||
leechers: r.leechers,
|
||||
sourcePublishedAt: r.uploadedAt,
|
||||
...(opts.parsed ? toParsedColumns(opts.parsed) : {}),
|
||||
raw: { ...r.raw, _site: site },
|
||||
};
|
||||
}
|
||||
|
||||
/** source_release 行 → SiteReleaseLike(站点工作台从本地快照展示时用);缺失字段按"未知"还原 */
|
||||
export function sourceReleaseToSiteRelease(row: SourceRelease): SiteReleaseLike {
|
||||
const { _site, ...raw } = row.raw as JsonObject & { _site?: JsonObject };
|
||||
const site = _site ?? {};
|
||||
const promoRaw = (row.promotion.raw ?? {}) as { downloadFactor?: number | null; uploadFactor?: number | null };
|
||||
const kind = row.promotion.kind as SitePromotionType | undefined;
|
||||
const str = (v: unknown) => (typeof v === "string" ? v : undefined);
|
||||
const hr = site.hr === true || site.hr === false ? site.hr : "unknown";
|
||||
const imdbId = str(site.imdbId);
|
||||
const doubanId = str(site.doubanId);
|
||||
const infoHash = row.infohashV1 ?? row.infohashV2 ?? undefined;
|
||||
return {
|
||||
siteId: str(site.adapterSiteId) ?? row.siteId,
|
||||
siteReleaseId: row.siteReleaseId,
|
||||
title: row.title,
|
||||
...(row.subtitle !== null ? { subtitle: row.subtitle } : {}),
|
||||
category: { id: row.categoryRaw, name: str(site.categoryName) ?? null },
|
||||
sizeBytes: row.sizeBytes,
|
||||
seeders: row.seeders,
|
||||
leechers: row.leechers,
|
||||
snatched: typeof site.snatched === "number" ? site.snatched : null,
|
||||
promotion: {
|
||||
type: kind && PROMOTION_TYPES.includes(kind) ? kind : "unknown",
|
||||
downloadFactor: promoRaw.downloadFactor ?? null,
|
||||
uploadFactor: promoRaw.uploadFactor ?? null,
|
||||
expiresAt: row.promotionExpiresAt,
|
||||
},
|
||||
hr,
|
||||
uploadedAt: row.sourcePublishedAt,
|
||||
detailUrl: str(site.detailUrl) ?? "",
|
||||
...(infoHash ? { infoHash } : {}),
|
||||
...(imdbId ? { imdbId } : {}),
|
||||
...(doubanId ? { doubanId } : {}),
|
||||
tags: [...row.tagsRaw],
|
||||
raw,
|
||||
observedAt: row.lastSeenAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type {
|
||||
AcquisitionSlot,
|
||||
AcquisitionSlotCreate,
|
||||
SlotStatus as ContractSlotStatus,
|
||||
JsonObject,
|
||||
} from "@umt/contracts";
|
||||
import type {
|
||||
SlotStatus as DomainSlotStatus,
|
||||
SlotCompletionSpec,
|
||||
SlotEvaluation,
|
||||
SlotHardConditions,
|
||||
SlotSoftPreferences,
|
||||
SlotSpec,
|
||||
SlotUpgradePolicy,
|
||||
} from "@umt/domain";
|
||||
|
||||
// Slot:状态两侧取值相同(REQ §6.1),映射表只为让任一侧增删值时编译/测试失败。
|
||||
// 规则 JSON 列(hard_conditions / soft_preferences / upgrade_threshold / completion / fallback)↔ domain SlotSpec。
|
||||
|
||||
export type { DomainSlotStatus };
|
||||
|
||||
const TO_CONTRACT: Record<DomainSlotStatus, ContractSlotStatus> = {
|
||||
empty: "empty",
|
||||
reserved: "reserved",
|
||||
downloading: "downloading",
|
||||
verifying: "verifying",
|
||||
satisfied: "satisfied",
|
||||
attention: "attention",
|
||||
upgrade_pending: "upgrade_pending",
|
||||
};
|
||||
const TO_DOMAIN: Record<ContractSlotStatus, DomainSlotStatus> = {
|
||||
empty: "empty",
|
||||
reserved: "reserved",
|
||||
downloading: "downloading",
|
||||
verifying: "verifying",
|
||||
satisfied: "satisfied",
|
||||
upgrade_pending: "upgrade_pending",
|
||||
attention: "attention",
|
||||
};
|
||||
|
||||
export const DOMAIN_SLOT_STATUSES = Object.keys(TO_CONTRACT) as DomainSlotStatus[];
|
||||
|
||||
export const toContractSlotStatus = (s: DomainSlotStatus): ContractSlotStatus => TO_CONTRACT[s];
|
||||
export const toDomainSlotStatus = (s: ContractSlotStatus): DomainSlotStatus => TO_DOMAIN[s];
|
||||
|
||||
type SlotRuleRow = Pick<
|
||||
AcquisitionSlot,
|
||||
"id" | "name" | "hardConditions" | "softPreferences" | "upgradeThreshold" | "completion" | "fallback"
|
||||
>;
|
||||
|
||||
const isEmpty = (o: JsonObject | null | undefined) => !o || Object.keys(o).length === 0;
|
||||
|
||||
/**
|
||||
* acquisition_slot 行 → domain SlotSpec。JSON 列按 domain 类型原样解释(写入时由 slotSpecToColumns 保证形状);
|
||||
* fallback.unknownPolicy 对应 SlotSpec.unknownPolicy。
|
||||
*/
|
||||
export function slotSpecFromRow(row: SlotRuleRow): SlotSpec {
|
||||
const unknownPolicy = row.fallback.unknownPolicy as SlotSpec["unknownPolicy"] | undefined;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
hard: row.hardConditions as SlotHardConditions,
|
||||
...(unknownPolicy !== undefined ? { unknownPolicy } : {}),
|
||||
...(isEmpty(row.softPreferences) ? {} : { soft: row.softPreferences as SlotSoftPreferences }),
|
||||
...(isEmpty(row.upgradeThreshold) ? {} : { upgrade: row.upgradeThreshold as unknown as SlotUpgradePolicy }),
|
||||
...(isEmpty(row.completion) ? {} : { completion: row.completion as unknown as SlotCompletionSpec }),
|
||||
};
|
||||
}
|
||||
|
||||
/** domain SlotSpec → acquisition_slot 规则列(Create/Update 可直接展开) */
|
||||
export function slotSpecToColumns(
|
||||
spec: SlotSpec,
|
||||
): Pick<
|
||||
AcquisitionSlotCreate,
|
||||
"name" | "hardConditions" | "softPreferences" | "upgradeThreshold" | "completion" | "fallback"
|
||||
> {
|
||||
const json = (v: unknown): JsonObject => (v ? (JSON.parse(JSON.stringify(v)) as JsonObject) : {});
|
||||
return {
|
||||
name: spec.name,
|
||||
hardConditions: json(spec.hard),
|
||||
softPreferences: json(spec.soft),
|
||||
upgradeThreshold: json(spec.upgrade),
|
||||
completion: json(spec.completion),
|
||||
fallback: spec.unknownPolicy === undefined ? {} : json({ unknownPolicy: spec.unknownPolicy }),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContractSlotEvaluation {
|
||||
decision: SlotEvaluation["decision"];
|
||||
/** 可以用这个候选生成计划(accept 或 upgrade) */
|
||||
acceptable: boolean;
|
||||
/** 与 DownloadPlanOutput.rejected 同形:硬条件不满足 / 字段未知的原因 */
|
||||
rejected: { code: string; message: string }[];
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
/** domain 评估结果 → 能力输出/决策记录用的扁平表示 */
|
||||
export function toContractSlotEvaluation(ev: SlotEvaluation): ContractSlotEvaluation {
|
||||
switch (ev.decision) {
|
||||
case "accept":
|
||||
case "upgrade":
|
||||
return { decision: ev.decision, acceptable: true, rejected: [], reasons: [...ev.reasons] };
|
||||
case "reject":
|
||||
return {
|
||||
decision: "reject",
|
||||
acceptable: false,
|
||||
rejected: ev.violations.map((v) => ({
|
||||
code: `${v.field}.${v.code}`,
|
||||
message:
|
||||
v.expected === undefined
|
||||
? `${v.field}: ${v.code}`
|
||||
: `${v.field}: ${v.code}(期望 ${JSON.stringify(v.expected)},实际 ${JSON.stringify(v.actual ?? null)})`,
|
||||
})),
|
||||
reasons: [],
|
||||
};
|
||||
case "unknown_field":
|
||||
return {
|
||||
decision: "unknown_field",
|
||||
acceptable: false,
|
||||
rejected: ev.missing.map((m) => ({ code: `unknown_field.${m}`, message: `字段未知,需要补充: ${m}` })),
|
||||
reasons: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user