diff --git a/deno.json b/deno.json index e1bf1669..12c57812 100644 --- a/deno.json +++ b/deno.json @@ -5,6 +5,7 @@ "./packages/contracts", "./packages/db", "./packages/domain", + "./packages/mapping", "./packages/tasks", "./packages/testkit", "./apps/server", @@ -65,7 +66,7 @@ "types": "mkdir -p types && deno types > types/deno.d.ts", "typecheck": "deno task types && deno task typecheck:tsc && deno task typecheck:deno", "typecheck:tsc": "deno task --members -j 1 typecheck", - "typecheck:deno": "deno check apps/server/src/main.ts packages/contracts/src/index.ts packages/db/src/index.ts packages/db/scripts/migrate.ts packages/db/scripts/gen-types.ts packages/db/migrations/*.ts packages/domain/src/index.ts packages/tasks/src/index.ts packages/agent/src/index.ts packages/adapters/src/index.ts packages/testkit/src/index.ts", + "typecheck:deno": "deno check apps/server/src/main.ts packages/contracts/src/index.ts packages/db/src/index.ts packages/db/scripts/migrate.ts packages/db/scripts/gen-types.ts packages/db/migrations/*.ts packages/domain/src/index.ts packages/mapping/src/index.ts packages/tasks/src/index.ts packages/agent/src/index.ts packages/adapters/src/index.ts packages/testkit/src/index.ts", "lint": "deno run -A npm:@biomejs/biome check .", "fmt": "deno run -A npm:@biomejs/biome check --write .", "test": "deno task --members -j 1 test", diff --git a/packages/mapping/README.md b/packages/mapping/README.md new file mode 100644 index 00000000..1be5aa8d --- /dev/null +++ b/packages/mapping/README.md @@ -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` 一遍。 diff --git a/packages/mapping/deno.json b/packages/mapping/deno.json new file mode 100644 index 00000000..f897d6e1 --- /dev/null +++ b/packages/mapping/deno.json @@ -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" + } +} diff --git a/packages/mapping/package.json b/packages/mapping/package.json new file mode 100644 index 00000000..c3ac468e --- /dev/null +++ b/packages/mapping/package.json @@ -0,0 +1,8 @@ +{ + "name": "@umt/mapping", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + } +} diff --git a/packages/mapping/src/download.ts b/packages/mapping/src/download.ts new file mode 100644 index 00000000..35b65b89 --- /dev/null +++ b/packages/mapping/src/download.ts @@ -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 = { + 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 = { + 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 = { + 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 }; +} diff --git a/packages/mapping/src/evidence.ts b/packages/mapping/src/evidence.ts new file mode 100644 index 00000000..3ccccbc4 --- /dev/null +++ b/packages/mapping/src/evidence.ts @@ -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 = { + rule_confirmed: "rule_confirmed", + multi_evidence: "multi_evidence", + needs_confirm: "needs_review", + // 被否决的候选不形成证据记录 + rejected: null, +}; + +const LEVEL_TO_DOMAIN: Record = { + // 人工确认按最强规则证据参与排序 + 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 = { + 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 = { + 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; +} diff --git a/packages/mapping/src/identity.ts b/packages/mapping/src/identity.ts new file mode 100644 index 00000000..eae7573b --- /dev/null +++ b/packages/mapping/src/identity.ts @@ -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 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 = { + 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, + aliases: readonly string[], + identities: readonly Pick[] = [], +): 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 })), + }; +} diff --git a/packages/mapping/src/index.ts b/packages/mapping/src/index.ts new file mode 100644 index 00000000..37a70c6b --- /dev/null +++ b/packages/mapping/src/index.ts @@ -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"; diff --git a/packages/mapping/src/organize.ts b/packages/mapping/src/organize.ts new file mode 100644 index 00000000..2e3c0125 --- /dev/null +++ b/packages/mapping/src/organize.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 = { copy: "copy", move: "move", rename: "rename" }; +const ACTION_BACK: Record = { + 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 = { + video: "main", + subtitle: "subtitle", + audio: "audio", + nfo: "nfo", + other: "other", +}; +export const DOMAIN_FILE_KINDS = Object.keys(SIDECAR_ROLE) as OrganizeFileKind[]; + +export function toFileRole(f: Pick): FileRole { + return f.role === "main" ? "main" : SIDECAR_ROLE[f.kind]; +} + +/** 计划动作 → 文件结果初始状态;hold 表示被阻塞冲突挡住,不执行,记为 skipped(skipReason=held) */ +const PLANNED_STATUS: Record = { + transfer: "queued", + replace: "queued", + skip: "skipped", + hold: "skipped", +}; +export const DOMAIN_PLANNED_ACTIONS = Object.keys(PLANNED_STATUS) as PlannedAction[]; + +export interface OrganizeRecordContext { + trigger: OrganizationTrigger; + actorType: ActorType; + actorId?: string | null; + taskId?: string | null; + mediaEntityId?: string | null; + contentUnitId?: string | null; + downloadTaskId?: string | null; + sourceDownloaderInstanceId?: string | null; + /** 源根路径(执行进程本地视图) */ + sourcePath?: string | null; + targetLibraryRootId?: string | null; + targetPath?: string | null; + /** 冻结计划的指纹(payloadHash) */ + planFingerprint?: string | null; +} + +const toJson = (v: unknown) => JSON.parse(JSON.stringify(v)) as JsonObject; + +/** 整理计划 → organization_record 新建输入(源路径按本地视图记录,计划整体冻结进 plan 列) */ +export function organizePlanToRecord(plan: OrganizePlan, ctx: OrganizeRecordContext): OrganizationRecordCreate { + const { trigger, actorType, ...rest } = ctx; + const optional = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined)); + return { + trigger, + actorType, + ...optional, + sourcePathView: "local", + action: OPERATION[plan.operation], + plan: toJson(plan), + }; +} + +/** 文件结果草稿:organizationRecordId 在记录写入后由调用方补上 */ +export type OrganizationFileResultDraft = Omit & { + status: FileResultStatus; + skipReason: string | null; +}; + +/** + * 整理计划 → 逐文件结果草稿。计划内文件按顺序编号,排除的文件(样例、孤立附属…)追加在后面并记为 skipped, + * 让整理记录能解释"为什么没整理这个文件"。 + */ +export function organizePlanToFileResults( + plan: OrganizePlan, + opts: { targetLibraryRootId?: string | null } = {}, +): OrganizationFileResultDraft[] { + const mode = OPERATION[plan.operation]; + const planned = plan.files.map( + (f, i): OrganizationFileResultDraft => ({ + ordinal: i, + role: toFileRole(f), + mode, + sourcePath: f.src, + sourceSizeBytes: f.size, + targetLibraryRootId: opts.targetLibraryRootId ?? null, + targetPath: f.dst, + status: PLANNED_STATUS[f.action], + skipReason: f.action === "skip" ? (f.skipReason ?? "skipped") : f.action === "hold" ? "held" : null, + }), + ); + const excluded = plan.excluded.map( + (x: ExcludedFile, i): OrganizationFileResultDraft => ({ + ordinal: planned.length + i, + role: x.kind === "video" ? "main" : SIDECAR_ROLE[x.kind], + mode, + sourcePath: x.src, + targetPath: null, + status: "skipped", + skipReason: `excluded:${x.reason}`, + }), + ); + return [...planned, ...excluded]; +} + +/** 逐文件状态 → 整理记录状态(全部完成后调用;仍有 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, + }; +} diff --git a/packages/mapping/src/path-binding.ts b/packages/mapping/src/path-binding.ts new file mode 100644 index 00000000..b0d7ecfc --- /dev/null +++ b/packages/mapping/src/path-binding.ts @@ -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 } : {}), + }; +} diff --git a/packages/mapping/src/release.ts b/packages/mapping/src/release.ts new file mode 100644 index 00000000..5a5d40c1 --- /dev/null +++ b/packages/mapping/src/release.ts @@ -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 = { + 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 = { + 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 = { + full: "full", + upper: "upper", + middle: "middle", + lower: "lower", +}; +const SEGMENT_BACK: Record = { + 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]; diff --git a/packages/mapping/src/site-release.ts b/packages/mapping/src/site-release.ts new file mode 100644 index 00000000..430ee4e2 --- /dev/null +++ b/packages/mapping/src/site-release.ts @@ -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; + 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, + }; +} diff --git a/packages/mapping/src/slot.ts b/packages/mapping/src/slot.ts new file mode 100644 index 00000000..4cb3f615 --- /dev/null +++ b/packages/mapping/src/slot.ts @@ -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 = { + empty: "empty", + reserved: "reserved", + downloading: "downloading", + verifying: "verifying", + satisfied: "satisfied", + attention: "attention", + upgrade_pending: "upgrade_pending", +}; +const TO_DOMAIN: Record = { + 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: [], + }; + } +} diff --git a/packages/mapping/test/convert.test.ts b/packages/mapping/test/convert.test.ts new file mode 100644 index 00000000..bdd56d9e --- /dev/null +++ b/packages/mapping/test/convert.test.ts @@ -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 => ({ + 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), + 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) }); + }); +}); diff --git a/packages/mapping/test/enums.test.ts b/packages/mapping/test/enums.test.ts new file mode 100644 index 00000000..ac879071 --- /dev/null +++ b/packages/mapping/test/enums.test.ts @@ -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,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"])); + }); +}); diff --git a/packages/mapping/tsconfig.json b/packages/mapping/tsconfig.json new file mode 100644 index 00000000..ca7f692c --- /dev/null +++ b/packages/mapping/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../config/tsconfig.base.json", + "include": ["src", "test"] +}