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

+21
View File
@@ -0,0 +1,21 @@
# @umt/mapping
domain ↔ contracts 的集中映射(W1 审查 B5)。只依赖 `@umt/domain` 与 `@umt/contracts`,不依赖 db / adapters。server 各模块需要在两套取值之间转换时一律走这里,不要在模块里各写一份。
db 行(snake_case)↔ contracts(camelCase)的转换不在本包;adapters 的 `SiteRelease` 以结构类型 `SiteReleaseLike` 接入(兼容性由 `apps/server/test/mapping-compat.test.ts` 在类型层保证)。
| 文件 | 导出 | 方向 |
|---|---|---|
| `download.ts` | `toContractDownloadState` / `toDomainDownloadStatus` / `mergeObservedDownloadState` / `DOMAIN_DOWNLOAD_STATUSES` | domain `paused_by_threshold/paused_by_user/error/missing_files` ↔ contracts `paused+PauseReason/failed/missing`;业务叠加态(submitting/stalled/importing/imported)在合并观察时保留 |
| `evidence.ts` | `toContractEvidenceLevel` / `toDomainConfidence` / `toDomainMediaType` / `toContractMediaKind` / `toDomainEntityType` / `toContractEntityType` | `needs_confirm ↔ needs_review`,`manual → rule_confirmed`,`rejected → null`;`tv ↔ series/variety` |
| `identity.ts` | `toContractResolveStatus` / `toReleaseResolutionStatus` / `rankedToIdentityCreate` / `localIdentityCandidate` | `auto/ambiguous/no_match → resolved|proposed/ambiguous/unidentifiable → source_release.resolution_status`;排名候选 → `ExternalIdentityCreate`;本地作品 → 本地优先候选 |
| `slot.ts` | `toContractSlotStatus` / `toDomainSlotStatus` / `slotSpecFromRow` / `slotSpecToColumns` / `toContractSlotEvaluation` | Slot 状态;规则 JSON 列 ↔ `SlotSpec`;评估结果 → `{ decision, acceptable, rejected[] }` |
| `release.ts` | `toParsedColumns` / `fromParsedColumns` / `toContractContentType` / `toDomainContentType` / `toContractSegment` / `toDomainSegment` | `ParsedRelease ↔ source_release.parsed + parser_version`(版本不符返回 null,重解析) |
| `site-release.ts` | `siteReleaseToSourceRelease` / `sourceReleaseToSiteRelease` / `SiteReleaseLike` | 站点资源 ↔ `SourceReleaseCreate` / `SourceRelease`;downloadUrl 不入库 |
| `path-binding.ts` | `toDomainPathBinding` / `toPathBindingCreate` / `parseTemplateVersion` | `entityId/scope/season/templateVersion:number/active ↔ mediaEntityId/bindingKind/seasonNumber/templateVersion:string/status` |
| `organize.ts` | `organizePlanToRecord` / `organizePlanToFileResults` / `organizePlanToCapabilityOutput` / `organizationStatusFromFiles` / `toOrganizationAction` / `toOrganizeOperation` / `toFileRole` | `OrganizePlan →` organization_record / organization_file_result 草稿 / organize.plan 输出 |
## 规则
- 映射表一律写成 `Record<一侧的联合类型, …>`:domain 增删取值时 typecheck 失败;contracts 侧在 `test/enums.test.ts` 按 `z.enum(...).options` 逐个断言,新增取值时测试失败。
- 新增映射先写测试(往返、schema 合法)。输出给 contracts 的对象都用对应 zod schema 在测试里 `safeParse` 一遍。
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@umt/mapping",
"version": "0.0.0",
"exports": { ".": "./src/index.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
"typecheck": "deno run -A npm:typescript/tsc --noEmit -p tsconfig.json"
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "@umt/mapping",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
}
}
+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: [],
};
}
}
+266
View File
@@ -0,0 +1,266 @@
import * as C from "@umt/contracts";
import {
DEFAULT_TEMPLATES,
evaluateCandidate,
type IdentityCandidate,
parseRelease,
planOrganize,
resolveIdentity,
resolveTargetDirectory,
type SlotSpec,
} from "@umt/domain";
import { describe, expect, it } from "vitest";
import * as M from "../src/index.ts";
const U = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
const AT = "2026-09-27T00:00:00.000Z";
const GiB = 1024 ** 3;
describe("ParsedRelease ↔ source_release.parsed", () => {
it("往返无损;版本不符或形状不对返回 null(调用方重解析)", () => {
const p = parseRelease("Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1-FGT");
const cols = M.toParsedColumns(p);
expect(C.SourceReleaseCreate.shape.parserVersion.safeParse(cols.parserVersion).success).toBe(true);
expect(M.fromParsedColumns(cols)).toEqual(JSON.parse(JSON.stringify(p)));
expect(M.fromParsedColumns({ ...cols, parserVersion: "0" })).toBeNull();
expect(
M.fromParsedColumns({ parsed: { ...cols.parsed, audio: "x" }, parserVersion: cols.parserVersion }),
).toBeNull();
});
});
describe("身份", () => {
const p = parseRelease("Dune.Part.Two.2024.2160p.WEB-DL.DDP5.1.H.265-FLUX");
const tmdb: IdentityCandidate = {
provider: "tmdb",
entityType: "movie",
externalId: "693134",
titles: ["Dune: Part Two", "Dune Part Two"],
year: 2024,
aliases: [],
};
it("排名候选 → ExternalIdentityCreate(schema 合法);本地候选不生成外部身份", () => {
const res = resolveIdentity(p, [tmdb]);
const create = M.rankedToIdentityCreate(res.ranked[0]!, { mediaEntityId: U(1) }, "accepted");
expect(create).not.toBeNull();
expect(C.ExternalIdentityCreate.safeParse(create).success).toBe(true);
expect(create).toMatchObject({ provider: "tmdb", externalId: "693134", status: "accepted" });
const local = M.localIdentityCandidate(
{ id: U(2), kind: "movie", title: "沙丘2", originalTitle: "Dune: Part Two", year: 2024 },
["Dune Part Two"],
[{ provider: "tmdb", externalId: "693134", status: "accepted" }],
);
expect(local).toMatchObject({
provider: "local",
localEntityId: U(2),
linkedExternalIds: [{ provider: "tmdb", id: "693134" }],
});
const both = resolveIdentity(p, [tmdb, local!]);
// 本地作品与 TMDB 是同一身份(linkedExternalIds),不构成歧义
expect(both.status).toBe("auto");
const localRanked = both.ranked.find((r) => r.candidate.provider === "local")!;
expect(M.rankedToIdentityCreate(localRanked, { mediaEntityId: U(2) })).toBeNull();
expect(
M.localIdentityCandidate({ id: U(3), kind: "collection", title: "x", originalTitle: null, year: null }, []),
).toBeNull();
});
});
describe("PathBinding", () => {
const row = (over: Partial<C.PathBinding>): C.PathBinding => ({
id: U(10),
createdAt: AT,
updatedAt: AT,
mediaEntityId: U(1),
contentUnitId: null,
seasonNumber: null,
bindingKind: "entity",
libraryRootId: U(20),
relativePath: "最后生还者 (2023)",
templateVersion: "1",
status: "active",
boundAt: AT,
boundBy: null,
supersededById: null,
note: null,
...over,
});
it("行 → domain 后参与目标目录决策:active 优先,非 active 忽略", () => {
const bindings = [
row({}),
row({ id: U(11), bindingKind: "season", seasonNumber: 1, relativePath: "最后生还者 (2023)/S1" }),
row({ id: U(12), relativePath: "旧目录", status: "superseded", templateVersion: "legacy" }),
].map(M.toDomainPathBinding);
expect(bindings[2]).toMatchObject({ active: false, templateVersion: 0 });
const r = resolveTargetDirectory({
entity: { id: U(1), kind: "tv" },
naming: { title: "最后的生还者", year: 2023 },
season: 1,
bindings,
libraryRoot: { id: U(20), hostPath: "/media/tv" },
templates: DEFAULT_TEMPLATES,
});
expect(r).toMatchObject({ kind: "existing", relativePath: "最后生还者 (2023)/S1", bindingIds: [U(11)] });
});
it("新绑定 → PathBindingCreate(schema 合法,season 绑定带 seasonNumber)", () => {
const r = resolveTargetDirectory({
entity: { id: U(1), kind: "tv" },
naming: { title: "最后生还者", year: 2023 },
season: 2,
bindings: [],
libraryRoot: { id: U(20), hostPath: "/media/tv" },
templates: DEFAULT_TEMPLATES,
});
expect(r.kind).toBe("new");
if (r.kind !== "new") return;
const creates = r.newBindings.map((nb) => M.toPathBindingCreate(nb, { mediaEntityId: U(1) }));
for (const c of creates) expect(C.PathBindingCreate.safeParse(c).success, JSON.stringify(c)).toBe(true);
expect(creates.map((c) => [c.bindingKind, c.seasonNumber ?? null, c.templateVersion])).toEqual([
["entity", null, "1"],
["season", 2, "1"],
]);
});
});
describe("整理计划 → 整理记录", () => {
const ROOT = "/downloads/tv/The.Last.of.Us.S01.2160p.WEB-DL-FLUX";
const plan = planOrganize({
operation: "copy",
mediaType: "tv",
sourceRoot: ROOT,
files: [
{ path: `${ROOT}/The.Last.of.Us.S01E01.2160p.mkv`, size: 5 * GiB, device: 1 },
{ path: `${ROOT}/The.Last.of.Us.S01E01.2160p.chs.srt`, size: 50_000, device: 1 },
{ path: `${ROOT}/Sample/the.last.of.us.s01e01.sample.mkv`, size: 80 * 1024 ** 2, device: 1 },
],
entity: { title: "最后生还者", year: 2023 },
target: { seasonDirs: { 1: "/media/tv/最后生还者 (2023)/Season 1" }, device: 2 },
existingTargets: [],
});
it("记录与逐文件草稿都通过 contracts schema;排除文件记为 skipped", () => {
const rec = M.organizePlanToRecord(plan, {
trigger: "auto",
actorType: "system",
downloadTaskId: U(5),
sourcePath: ROOT,
planFingerprint: "h1",
});
expect(C.OrganizationRecordCreate.safeParse(rec).success).toBe(true);
expect(rec).toMatchObject({ action: "copy", sourcePathView: "local", downloadTaskId: U(5) });
expect(rec.plan).toMatchObject({ operation: "copy" });
const files = M.organizePlanToFileResults(plan, { targetLibraryRootId: U(20) });
for (const f of files) {
expect(C.OrganizationFileResultCreate.safeParse({ ...f, organizationRecordId: U(9) }).success).toBe(true);
expect(C.FileResultStatus.options).toContain(f.status);
}
expect(files.map((f) => [f.ordinal, f.role, f.status, f.skipReason])).toEqual([
[0, "main", "queued", null],
[1, "subtitle", "queued", null],
[2, "main", "skipped", "excluded:sample"],
]);
});
it("能力输出合法;整理记录状态按文件结果汇总", () => {
const out = M.organizePlanToCapabilityOutput(plan, { planId: U(7), payloadHash: "h1" });
expect(C.OrganizePlanOutput.safeParse(out).success).toBe(true);
expect(out.files).toHaveLength(3);
expect(M.organizationStatusFromFiles(["succeeded", "skipped"])).toBe("succeeded");
expect(M.organizationStatusFromFiles(["succeeded", "failed"])).toBe("partial");
expect(M.organizationStatusFromFiles(["failed", "skipped"])).toBe("failed");
expect(M.organizationStatusFromFiles(["succeeded", "running"])).toBe("running");
expect(M.organizationStatusFromFiles(["cancelled"])).toBe("cancelled");
expect(M.organizationStatusFromFiles([])).toBe("succeeded");
});
});
describe("Slot 规则与评估", () => {
const spec: SlotSpec = {
id: U(30),
name: "archival",
hard: { resolutions: ["2160p"], maxSizeBytes: 40 * GiB },
unknownPolicy: { size: "accept" },
soft: { hdr: ["DV"] },
upgrade: { enabled: true },
};
it("SlotSpec → 规则列 → SlotSpec 往返", () => {
const cols = M.slotSpecToColumns(spec);
expect(C.AcquisitionSlotCreate.safeParse({ ...cols, mediaEntityId: U(1) }).success).toBe(true);
const back = M.slotSpecFromRow({ id: U(30), ...cols, completion: cols.completion ?? {} } as Parameters<
typeof M.slotSpecFromRow
>[0]);
expect(back).toEqual(spec);
});
it("评估结果 → 扁平表示(rejected 与 DownloadPlanOutput.rejected 同形)", () => {
const ok = M.toContractSlotEvaluation(evaluateCandidate(spec, parseRelease("Dune.2021.2160p.BluRay.x265.DV-CHD")));
expect(ok).toMatchObject({ decision: "accept", acceptable: true, rejected: [] });
const bad = M.toContractSlotEvaluation(evaluateCandidate(spec, parseRelease("Dune.2021.1080p.HDTV.x264-GRP")));
expect(bad.acceptable).toBe(false);
expect(bad.rejected[0]?.code).toMatch(/^resolution\./);
const shape = C.DownloadPlanOutput.shape.rejected;
expect(shape.safeParse(bad.rejected).success).toBe(true);
});
});
describe("SiteRelease ↔ source_release", () => {
const site: M.SiteReleaseLike = {
siteId: "audiences",
siteReleaseId: "123",
title: "Dune.Part.Two.2024.2160p.WEB-DL-FLUX",
subtitle: "沙丘2",
category: { id: "401", name: "电影" },
sizeBytes: 20 * GiB,
seeders: 10,
leechers: 1,
snatched: 30,
promotion: { type: "free", downloadFactor: 0, uploadFactor: 1, expiresAt: "2026-09-28T00:00:00.000Z" },
hr: "unknown",
uploadedAt: "2026-09-26T00:00:00.000Z",
detailUrl: "https://pt.example/details.php?id=123",
downloadUrl: "https://pt.example/download.php?id=123",
infoHash: "A".repeat(40),
imdbId: "tt15239678",
tags: ["中字"],
raw: { small_descr: "沙丘2" },
observedAt: AT,
};
it("转换结果通过 SourceReleaseCreate;downloadUrl 不入库;siteId 用 site 行 UUID", () => {
const create = M.siteReleaseToSourceRelease(site, { siteId: U(40), parsed: parseRelease(site.title) });
expect(C.SourceReleaseCreate.safeParse(create).success).toBe(true);
expect(create).toMatchObject({ siteId: U(40), infohashV1: "a".repeat(40), infohashV2: null, categoryRaw: "401" });
expect(JSON.stringify(create)).not.toContain("download.php");
expect(create.parserVersion).toBe("1");
});
it("source_release 行还原为 SiteReleaseLike(下载入口除外)", () => {
const create = M.siteReleaseToSourceRelease(site, { siteId: U(40) });
const row: C.SourceRelease = {
...(create as Required<typeof create>),
id: U(41),
createdAt: AT,
updatedAt: AT,
firstSeenAt: AT,
lastSeenAt: AT,
fileList: null,
detailFetchedAt: null,
parsed: {},
parserVersion: null,
mediaEntityId: null,
contentUnitId: null,
resolutionStatus: "unresolved",
resolutionEvidence: {},
availability: "active",
};
expect(C.SourceRelease.safeParse(row).success).toBe(true);
const { downloadUrl: _d, ...expected } = site;
expect(M.sourceReleaseToSiteRelease(row)).toEqual({ ...expected, infoHash: "a".repeat(40) });
});
});
+141
View File
@@ -0,0 +1,141 @@
import * as C from "@umt/contracts";
import { describe, expect, it } from "vitest";
import * as M from "../src/index.ts";
// 枚举全覆盖:
// - domain 侧是 TS 联合类型,映射表写成 Record<DomainX, …>,domain 增删值时 typecheck 直接失败;
// 这里再在运行时比对 DOMAIN_* 清单与期望清单,防止有人用类型断言绕过。
// - contracts 侧是 z.enum,逐个 .options 断言都有映射,新增值时测试失败。
const sorted = (xs: readonly string[]) => [...xs].sort();
describe("下载状态", () => {
it("domain 取值清单固定", () => {
expect(sorted(M.DOMAIN_DOWNLOAD_STATUSES)).toEqual(
sorted([
"queued",
"downloading",
"paused_by_threshold",
"paused_by_user",
"completed",
"error",
"missing_files",
"removed",
]),
);
});
it("domain → contracts → domain 往返无损,pauseReason 只在 paused 时出现", () => {
for (const s of M.DOMAIN_DOWNLOAD_STATUSES) {
const c = M.toContractDownloadState(s);
expect(C.DownloadStatus.options).toContain(c.status);
expect(c.pauseReason !== null).toBe(c.status === "paused");
expect(M.toDomainDownloadStatus(c.status, c.pauseReason), s).toBe(s);
}
});
it("contracts 每个状态、每个暂停原因都能折回 domain", () => {
for (const s of C.DownloadStatus.options) {
for (const r of [null, ...C.PauseReason.options]) {
expect(M.DOMAIN_DOWNLOAD_STATUSES, `${s}/${r}`).toContain(M.toDomainDownloadStatus(s, r));
}
}
expect(M.toDomainDownloadStatus("paused", "threshold")).toBe("paused_by_threshold");
expect(M.toDomainDownloadStatus("paused", "agent")).toBe("paused_by_user");
expect(M.toDomainDownloadStatus("imported", null)).toBe("completed");
});
it("合并观察:业务叠加态在底层状态不变时保留,否则以观察为准", () => {
const st = (status: C.DownloadStatus, pauseReason: C.PauseReason | null = null) => ({ status, pauseReason });
expect(M.mergeObservedDownloadState(st("importing"), "completed")).toEqual(st("importing"));
expect(M.mergeObservedDownloadState(st("imported"), "completed")).toEqual(st("imported"));
expect(M.mergeObservedDownloadState(st("stalled"), "downloading")).toEqual(st("stalled"));
expect(M.mergeObservedDownloadState(st("stalled"), "completed")).toEqual(st("completed"));
expect(M.mergeObservedDownloadState(st("submitting"), "queued")).toEqual(st("submitting"));
expect(M.mergeObservedDownloadState(st("paused", "agent"), "paused_by_user")).toEqual(st("paused", "agent"));
expect(M.mergeObservedDownloadState(st("paused", "user"), "paused_by_threshold")).toEqual(
st("paused", "threshold"),
);
expect(M.mergeObservedDownloadState(st("imported"), "removed")).toEqual(st("removed"));
});
});
describe("证据等级 / 媒体类型", () => {
it("domain 匹配等级清单固定;rejected 不形成证据", () => {
expect(sorted(M.DOMAIN_MATCH_LEVELS)).toEqual(
sorted(["rule_confirmed", "multi_evidence", "needs_confirm", "rejected"]),
);
expect(M.toContractEvidenceLevel("needs_confirm")).toBe("needs_review");
expect(M.toContractEvidenceLevel("rejected")).toBeNull();
for (const l of M.DOMAIN_MATCH_LEVELS) {
const c = M.toContractEvidenceLevel(l);
if (c) expect(M.toDomainConfidence(c)).toBe(l);
}
});
it("contracts 每个证据等级都能折回 domain;manual 视为最强", () => {
for (const l of C.EvidenceLevel.options) expect(M.DOMAIN_MATCH_LEVELS).toContain(M.toDomainConfidence(l));
expect(M.toDomainConfidence("manual")).toBe("rule_confirmed");
});
it("媒体类型与外部实体类型", () => {
for (const k of C.MediaKind.options) {
const d = M.toDomainMediaType(k);
if (d) expect(M.toDomainMediaType(M.toContractMediaKind(d, { variety: k === "variety" }))).toBe(d);
}
expect(M.toContractMediaKind("tv")).toBe("series");
expect(M.toContractMediaKind("tv", { variety: true })).toBe("variety");
for (const t of C.ExternalEntityType.options) {
const d = M.toDomainEntityType(t);
if (d) expect(M.toContractEntityType(d)).toBe(t);
}
expect(M.toDomainEntityType("season")).toBeNull();
});
});
describe("身份解析状态", () => {
it("domain 状态清单固定,映射到能力输出与 source_release 状态", () => {
expect(sorted(M.DOMAIN_RESOLUTION_STATUSES)).toEqual(sorted(["auto", "ambiguous", "no_match"]));
const out = C.IdentityResolveOutput.shape.status.options;
for (const s of M.DOMAIN_RESOLUTION_STATUSES) {
for (const autoAccept of [true, false]) expect(out).toContain(M.toContractResolveStatus(s, { autoAccept }));
}
expect(M.toContractResolveStatus("auto", { autoAccept: true })).toBe("resolved");
expect(M.toContractResolveStatus("auto", { autoAccept: false })).toBe("proposed");
expect(M.toContractResolveStatus("no_match", { autoAccept: true })).toBe("unidentifiable");
});
it("能力输出每个状态都能映射到 ResolutionStatus", () => {
for (const s of C.IdentityResolveOutput.shape.status.options) {
expect(C.ResolutionStatus.options).toContain(M.toReleaseResolutionStatus(s));
}
});
});
describe("Slot / 内容类型 / 分段 / 整理动作", () => {
it("Slot 状态两侧一一对应", () => {
expect(sorted(M.DOMAIN_SLOT_STATUSES)).toEqual(sorted(C.SlotStatus.options));
for (const s of C.SlotStatus.options) expect(M.toContractSlotStatus(M.toDomainSlotStatus(s))).toBe(s);
});
it("综艺内容类型两侧一一对应;分段 unknown ↔ 缺失", () => {
expect(sorted(M.DOMAIN_CONTENT_TYPES)).toEqual(sorted(C.ContentType.options));
for (const t of C.ContentType.options) expect(M.toContractContentType(M.toDomainContentType(t))).toBe(t);
expect(sorted([...M.DOMAIN_SEGMENTS, "unknown"])).toEqual(sorted(C.Segment.options));
for (const s of C.Segment.options) expect(M.toContractSegment(M.toDomainSegment(s))).toBe(s);
});
it("整理操作、文件类型、计划动作都有映射", () => {
expect(sorted(M.DOMAIN_ORGANIZE_OPERATIONS)).toEqual(sorted(["copy", "move", "rename"]));
for (const op of M.DOMAIN_ORGANIZE_OPERATIONS) expect(M.toOrganizeOperation(M.toOrganizationAction(op))).toBe(op);
for (const a of C.OrganizationAction.options) {
const op = M.toOrganizeOperation(a);
if (op) expect(M.toOrganizationAction(op)).toBe(a);
}
expect(sorted(M.DOMAIN_FILE_KINDS)).toEqual(sorted(["video", "subtitle", "audio", "nfo", "other"]));
for (const k of M.DOMAIN_FILE_KINDS) {
expect(C.FileRole.options).toContain(M.toFileRole({ role: "sidecar", kind: k }));
}
expect(sorted(M.DOMAIN_PLANNED_ACTIONS)).toEqual(sorted(["transfer", "replace", "skip", "hold"]));
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../config/tsconfig.base.json",
"include": ["src", "test"]
}