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

+87
View File
@@ -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 };
}
+73
View File
@@ -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;
}
+106
View File
@@ -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 })),
};
}
+13
View File
@@ -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";
+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,
};
}
+44
View File
@@ -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 } : {}),
};
}
+72
View File
@@ -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];
+135
View File
@@ -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,
};
}
+124
View File
@@ -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: [],
};
}
}