feat(mapping): centralize domain <-> contracts mappings (B5)
New @umt/mapping (depends on domain + contracts only): download status + pause reason, evidence level / media kind, identity resolution status, slot status / spec columns / evaluation, ParsedRelease <-> source_release.parsed, SiteRelease <-> source_release, PathBinding, OrganizePlan -> organization_record / file results / organize.plan output. Tables are Record<union, ...> so adding a value on either side fails typecheck or tests. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
da8e53bbca
commit
42ecc93fa5
16 files changed
+1282
-1
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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` 一遍。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "@umt/mapping",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.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<DomainDownloadStatus, ContractDownloadState> = {
|
||||
queued: { status: "queued", pauseReason: null },
|
||||
downloading: { status: "downloading", pauseReason: null },
|
||||
paused_by_threshold: { status: "paused", pauseReason: "threshold" },
|
||||
paused_by_user: { status: "paused", pauseReason: "user" },
|
||||
completed: { status: "completed", pauseReason: null },
|
||||
error: { status: "failed", pauseReason: null },
|
||||
missing_files: { status: "missing", pauseReason: null },
|
||||
removed: { status: "removed", pauseReason: null },
|
||||
};
|
||||
|
||||
/** contracts 状态 → 下载器层面的 domain 状态(业务叠加态折回其底层下载状态) */
|
||||
const TO_DOMAIN: Record<DownloadStatus, DomainDownloadStatus | "by_pause_reason"> = {
|
||||
queued: "queued",
|
||||
// 提交中:下载器里还没有/刚出现,按排队处理
|
||||
submitting: "queued",
|
||||
downloading: "downloading",
|
||||
paused: "by_pause_reason",
|
||||
// 无进展是业务判定(detectNoProgress),下载器里仍是 downloading
|
||||
stalled: "downloading",
|
||||
completed: "completed",
|
||||
// 入库阶段:下载本身已完成
|
||||
importing: "completed",
|
||||
imported: "completed",
|
||||
failed: "error",
|
||||
removed: "removed",
|
||||
missing: "missing_files",
|
||||
};
|
||||
|
||||
/** 暂停原因 → domain 状态:只有 threshold 会被阈值恢复自动恢复,其余都按用户暂停对待(REQ §8.4.1) */
|
||||
const PAUSE_TO_DOMAIN: Record<PauseReason, DomainDownloadStatus> = {
|
||||
threshold: "paused_by_threshold",
|
||||
user: "paused_by_user",
|
||||
policy: "paused_by_user",
|
||||
agent: "paused_by_user",
|
||||
};
|
||||
|
||||
export const DOMAIN_DOWNLOAD_STATUSES = Object.keys(TO_CONTRACT) as DomainDownloadStatus[];
|
||||
|
||||
export function toContractDownloadState(s: DomainDownloadStatus): ContractDownloadState {
|
||||
return { ...TO_CONTRACT[s] };
|
||||
}
|
||||
|
||||
export function toDomainDownloadStatus(status: DownloadStatus, pauseReason: PauseReason | null): DomainDownloadStatus {
|
||||
const d = TO_DOMAIN[status];
|
||||
if (d !== "by_pause_reason") return d;
|
||||
return PAUSE_TO_DOMAIN[pauseReason ?? "user"];
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步轮询时合并"下载器观察"与"当前业务状态":
|
||||
* - 业务叠加态在其底层下载状态不变时保留(importing/imported 遇 completed、stalled 遇 downloading、submitting 遇 queued);
|
||||
* - 暂停原因为 policy/agent 且下载器仍是暂停时保留原因,不被改写成 user;
|
||||
* - 其余以观察为准。
|
||||
*/
|
||||
export function mergeObservedDownloadState(
|
||||
current: ContractDownloadState,
|
||||
observed: DomainDownloadStatus,
|
||||
): ContractDownloadState {
|
||||
const next = TO_CONTRACT[observed];
|
||||
const keep =
|
||||
((current.status === "importing" || current.status === "imported") && observed === "completed") ||
|
||||
(current.status === "stalled" && observed === "downloading") ||
|
||||
(current.status === "submitting" && observed === "queued");
|
||||
if (keep) return { ...current };
|
||||
if (
|
||||
current.status === "paused" &&
|
||||
observed === "paused_by_user" &&
|
||||
(current.pauseReason === "policy" || current.pauseReason === "agent")
|
||||
) {
|
||||
return { ...current };
|
||||
}
|
||||
return { ...next };
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { EvidenceLevel as ContractEvidenceLevel, ExternalEntityType, MediaKind } from "@umt/contracts";
|
||||
import type { Confidence, EvidenceLevel as DomainMatchLevel } from "@umt/domain";
|
||||
|
||||
// 证据等级:domain 的 EvidenceLevel 实为"匹配等级"(含 rejected),这里以 DomainMatchLevel 称呼;
|
||||
// contracts 的 EvidenceLevel 是落库/展示用的证据等级(含 manual、needs_review)。
|
||||
|
||||
export type { DomainMatchLevel };
|
||||
|
||||
const LEVEL_TO_CONTRACT: Record<DomainMatchLevel, ContractEvidenceLevel | null> = {
|
||||
rule_confirmed: "rule_confirmed",
|
||||
multi_evidence: "multi_evidence",
|
||||
needs_confirm: "needs_review",
|
||||
// 被否决的候选不形成证据记录
|
||||
rejected: null,
|
||||
};
|
||||
|
||||
const LEVEL_TO_DOMAIN: Record<ContractEvidenceLevel, Confidence> = {
|
||||
// 人工确认按最强规则证据参与排序
|
||||
manual: "rule_confirmed",
|
||||
rule_confirmed: "rule_confirmed",
|
||||
multi_evidence: "multi_evidence",
|
||||
needs_review: "needs_confirm",
|
||||
};
|
||||
|
||||
export const DOMAIN_MATCH_LEVELS = Object.keys(LEVEL_TO_CONTRACT) as DomainMatchLevel[];
|
||||
|
||||
/** domain 匹配等级 / 解析置信度 → contracts 证据等级;rejected 返回 null */
|
||||
export function toContractEvidenceLevel(level: DomainMatchLevel): ContractEvidenceLevel | null {
|
||||
return LEVEL_TO_CONTRACT[level];
|
||||
}
|
||||
|
||||
export function toDomainConfidence(level: ContractEvidenceLevel): Confidence {
|
||||
return LEVEL_TO_DOMAIN[level];
|
||||
}
|
||||
|
||||
// ── 媒体类型:domain 只分 movie / tv ──
|
||||
|
||||
export type DomainMediaType = "movie" | "tv";
|
||||
|
||||
const MEDIA_TO_DOMAIN: Record<MediaKind, DomainMediaType | null> = {
|
||||
movie: "movie",
|
||||
series: "tv",
|
||||
// 综艺按剧集规则(季/期)处理
|
||||
variety: "tv",
|
||||
collection: null,
|
||||
other: null,
|
||||
};
|
||||
|
||||
export function toDomainMediaType(kind: MediaKind): DomainMediaType | null {
|
||||
return MEDIA_TO_DOMAIN[kind];
|
||||
}
|
||||
|
||||
/** domain → contracts 媒体类型;tv 默认落为 series,综艺由调用方按来源分类显式指定 */
|
||||
export function toContractMediaKind(t: DomainMediaType, opts: { variety?: boolean } = {}): MediaKind {
|
||||
return t === "movie" ? "movie" : opts.variety ? "variety" : "series";
|
||||
}
|
||||
|
||||
const ENTITY_TYPE_TO_DOMAIN: Record<ExternalEntityType, DomainMediaType | null> = {
|
||||
movie: "movie",
|
||||
tv: "tv",
|
||||
season: null,
|
||||
episode: null,
|
||||
collection: null,
|
||||
person: null,
|
||||
};
|
||||
|
||||
export function toDomainEntityType(t: ExternalEntityType): DomainMediaType | null {
|
||||
return ENTITY_TYPE_TO_DOMAIN[t];
|
||||
}
|
||||
|
||||
export function toContractEntityType(t: DomainMediaType): ExternalEntityType {
|
||||
return t;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type {
|
||||
ExternalIdentity,
|
||||
ExternalIdentityCreate,
|
||||
IdentityResolveOutput,
|
||||
IdentityStatus,
|
||||
MediaEntity,
|
||||
ResolutionStatus,
|
||||
} from "@umt/contracts";
|
||||
import type { IdentityCandidate, IdentityResolution, RankedCandidate } from "@umt/domain";
|
||||
import { toContractEvidenceLevel, toDomainMediaType } from "./evidence.ts";
|
||||
|
||||
// 身份解析:domain.resolveIdentity(auto / ambiguous / no_match)→ contracts 的能力输出状态
|
||||
// 与 source_release.resolution_status。
|
||||
|
||||
export type DomainResolutionStatus = IdentityResolution["status"];
|
||||
export type ContractResolveStatus = IdentityResolveOutput["status"];
|
||||
|
||||
export interface ResolveStatusOptions {
|
||||
/**
|
||||
* auto 时是否可直接接受(automation 总开关/授权/人工锁都允许)。
|
||||
* false 时产出 proposed:证据足够但需要确认,进待识别池。
|
||||
*/
|
||||
autoAccept: boolean;
|
||||
}
|
||||
|
||||
const RESOLVE_STATUS: Record<DomainResolutionStatus, (o: ResolveStatusOptions) => ContractResolveStatus> = {
|
||||
auto: (o) => (o.autoAccept ? "resolved" : "proposed"),
|
||||
ambiguous: () => "ambiguous",
|
||||
no_match: () => "unidentifiable",
|
||||
};
|
||||
|
||||
export const DOMAIN_RESOLUTION_STATUSES = Object.keys(RESOLVE_STATUS) as DomainResolutionStatus[];
|
||||
|
||||
export function toContractResolveStatus(
|
||||
status: DomainResolutionStatus,
|
||||
opts: ResolveStatusOptions,
|
||||
): ContractResolveStatus {
|
||||
return RESOLVE_STATUS[status](opts);
|
||||
}
|
||||
|
||||
const RELEASE_RESOLUTION: Record<ContractResolveStatus, ResolutionStatus> = {
|
||||
resolved: "resolved",
|
||||
// 待确认的提议与歧义一样进待识别池
|
||||
proposed: "ambiguous",
|
||||
ambiguous: "ambiguous",
|
||||
unidentifiable: "unidentifiable",
|
||||
};
|
||||
|
||||
/** 能力输出状态 → source_release.resolution_status */
|
||||
export function toReleaseResolutionStatus(status: ContractResolveStatus): ResolutionStatus {
|
||||
return RELEASE_RESOLUTION[status];
|
||||
}
|
||||
|
||||
/**
|
||||
* 排名候选 → external_identity 新建输入。本地候选(provider=local)不是外部身份,返回 null;
|
||||
* 被否决的候选同样返回 null。
|
||||
*/
|
||||
export function rankedToIdentityCreate(
|
||||
r: RankedCandidate,
|
||||
target: { mediaEntityId: string } | { contentUnitId: string },
|
||||
status: IdentityStatus = "proposed",
|
||||
): ExternalIdentityCreate | null {
|
||||
const level = toContractEvidenceLevel(r.level);
|
||||
if (!level || r.candidate.provider === "local") return null;
|
||||
return {
|
||||
...target,
|
||||
provider: r.candidate.provider,
|
||||
entityType: r.candidate.entityType,
|
||||
externalId: r.candidate.externalId,
|
||||
status,
|
||||
evidenceLevel: level,
|
||||
evidence: {
|
||||
titleMatch: r.titleMatch,
|
||||
yearMatch: r.yearMatch,
|
||||
typeMatch: r.typeMatch,
|
||||
rules: r.evidence,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地作品 → domain 身份候选(本地优先召回)。externalId 用本地 UUID;
|
||||
* 已接受的外部身份作为 linkedExternalIds,用于与远端候选判同。
|
||||
* 媒体类型无法映射到 movie/tv(合集/其他)时返回 null。
|
||||
*/
|
||||
export function localIdentityCandidate(
|
||||
entity: Pick<MediaEntity, "id" | "kind" | "title" | "originalTitle" | "year">,
|
||||
aliases: readonly string[],
|
||||
identities: readonly Pick<ExternalIdentity, "provider" | "externalId" | "status">[] = [],
|
||||
): IdentityCandidate | null {
|
||||
const entityType = toDomainMediaType(entity.kind);
|
||||
if (!entityType) return null;
|
||||
const titles = [entity.title, ...(entity.originalTitle ? [entity.originalTitle] : [])];
|
||||
return {
|
||||
provider: "local",
|
||||
entityType,
|
||||
externalId: entity.id,
|
||||
titles,
|
||||
...(entity.year !== null ? { year: entity.year } : {}),
|
||||
aliases: [...aliases],
|
||||
localEntityId: entity.id,
|
||||
linkedExternalIds: identities
|
||||
.filter((i) => i.status === "accepted")
|
||||
.map((i) => ({ provider: i.provider, id: i.externalId })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// @umt/mapping 公开入口:domain ↔ contracts 的集中映射(W1 审查 B5)。
|
||||
// 只依赖 @umt/domain 与 @umt/contracts;不依赖 db / adapters(adapters 的 SiteRelease 以结构类型 SiteReleaseLike 接入)。
|
||||
// db 行(snake_case)↔ contracts(camelCase)的转换不在这里。
|
||||
export const PACKAGE_NAME = "@umt/mapping";
|
||||
|
||||
export * from "./download.ts";
|
||||
export * from "./evidence.ts";
|
||||
export * from "./identity.ts";
|
||||
export * from "./organize.ts";
|
||||
export * from "./path-binding.ts";
|
||||
export * from "./release.ts";
|
||||
export * from "./site-release.ts";
|
||||
export * from "./slot.ts";
|
||||
@@ -0,0 +1,177 @@
|
||||
import type {
|
||||
ActorType,
|
||||
FileResultStatus,
|
||||
FileRole,
|
||||
JsonObject,
|
||||
OrganizationAction,
|
||||
OrganizationFileResultCreate,
|
||||
OrganizationRecordCreate,
|
||||
OrganizationStatus,
|
||||
OrganizationTrigger,
|
||||
OrganizePlanOutput,
|
||||
} from "@umt/contracts";
|
||||
import type {
|
||||
ExcludedFile,
|
||||
OrganizeFileKind,
|
||||
OrganizeOperation,
|
||||
OrganizePlan,
|
||||
PlannedAction,
|
||||
PlannedFile,
|
||||
} from "@umt/domain";
|
||||
|
||||
// 整理计划(domain.planOrganize)→ organization_record / organization_file_result,以及 organize.plan 能力输出。
|
||||
|
||||
const OPERATION: Record<OrganizeOperation, OrganizationAction> = { copy: "copy", move: "move", rename: "rename" };
|
||||
const ACTION_BACK: Record<OrganizationAction, OrganizeOperation | null> = {
|
||||
copy: "copy",
|
||||
move: "move",
|
||||
rename: "rename",
|
||||
// domain 不生成目录调整/硬链接计划
|
||||
relocate: null,
|
||||
hardlink: null,
|
||||
};
|
||||
export const DOMAIN_ORGANIZE_OPERATIONS = Object.keys(OPERATION) as OrganizeOperation[];
|
||||
|
||||
export const toOrganizationAction = (op: OrganizeOperation): OrganizationAction => OPERATION[op];
|
||||
export const toOrganizeOperation = (a: OrganizationAction): OrganizeOperation | null => ACTION_BACK[a];
|
||||
|
||||
const SIDECAR_ROLE: Record<OrganizeFileKind, FileRole> = {
|
||||
video: "main",
|
||||
subtitle: "subtitle",
|
||||
audio: "audio",
|
||||
nfo: "nfo",
|
||||
other: "other",
|
||||
};
|
||||
export const DOMAIN_FILE_KINDS = Object.keys(SIDECAR_ROLE) as OrganizeFileKind[];
|
||||
|
||||
export function toFileRole(f: Pick<PlannedFile, "role" | "kind">): FileRole {
|
||||
return f.role === "main" ? "main" : SIDECAR_ROLE[f.kind];
|
||||
}
|
||||
|
||||
/** 计划动作 → 文件结果初始状态;hold 表示被阻塞冲突挡住,不执行,记为 skipped(skipReason=held) */
|
||||
const PLANNED_STATUS: Record<PlannedAction, FileResultStatus> = {
|
||||
transfer: "queued",
|
||||
replace: "queued",
|
||||
skip: "skipped",
|
||||
hold: "skipped",
|
||||
};
|
||||
export const DOMAIN_PLANNED_ACTIONS = Object.keys(PLANNED_STATUS) as PlannedAction[];
|
||||
|
||||
export interface OrganizeRecordContext {
|
||||
trigger: OrganizationTrigger;
|
||||
actorType: ActorType;
|
||||
actorId?: string | null;
|
||||
taskId?: string | null;
|
||||
mediaEntityId?: string | null;
|
||||
contentUnitId?: string | null;
|
||||
downloadTaskId?: string | null;
|
||||
sourceDownloaderInstanceId?: string | null;
|
||||
/** 源根路径(执行进程本地视图) */
|
||||
sourcePath?: string | null;
|
||||
targetLibraryRootId?: string | null;
|
||||
targetPath?: string | null;
|
||||
/** 冻结计划的指纹(payloadHash) */
|
||||
planFingerprint?: string | null;
|
||||
}
|
||||
|
||||
const toJson = (v: unknown) => JSON.parse(JSON.stringify(v)) as JsonObject;
|
||||
|
||||
/** 整理计划 → organization_record 新建输入(源路径按本地视图记录,计划整体冻结进 plan 列) */
|
||||
export function organizePlanToRecord(plan: OrganizePlan, ctx: OrganizeRecordContext): OrganizationRecordCreate {
|
||||
const { trigger, actorType, ...rest } = ctx;
|
||||
const optional = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined));
|
||||
return {
|
||||
trigger,
|
||||
actorType,
|
||||
...optional,
|
||||
sourcePathView: "local",
|
||||
action: OPERATION[plan.operation],
|
||||
plan: toJson(plan),
|
||||
};
|
||||
}
|
||||
|
||||
/** 文件结果草稿:organizationRecordId 在记录写入后由调用方补上 */
|
||||
export type OrganizationFileResultDraft = Omit<OrganizationFileResultCreate, "organizationRecordId"> & {
|
||||
status: FileResultStatus;
|
||||
skipReason: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 整理计划 → 逐文件结果草稿。计划内文件按顺序编号,排除的文件(样例、孤立附属…)追加在后面并记为 skipped,
|
||||
* 让整理记录能解释"为什么没整理这个文件"。
|
||||
*/
|
||||
export function organizePlanToFileResults(
|
||||
plan: OrganizePlan,
|
||||
opts: { targetLibraryRootId?: string | null } = {},
|
||||
): OrganizationFileResultDraft[] {
|
||||
const mode = OPERATION[plan.operation];
|
||||
const planned = plan.files.map(
|
||||
(f, i): OrganizationFileResultDraft => ({
|
||||
ordinal: i,
|
||||
role: toFileRole(f),
|
||||
mode,
|
||||
sourcePath: f.src,
|
||||
sourceSizeBytes: f.size,
|
||||
targetLibraryRootId: opts.targetLibraryRootId ?? null,
|
||||
targetPath: f.dst,
|
||||
status: PLANNED_STATUS[f.action],
|
||||
skipReason: f.action === "skip" ? (f.skipReason ?? "skipped") : f.action === "hold" ? "held" : null,
|
||||
}),
|
||||
);
|
||||
const excluded = plan.excluded.map(
|
||||
(x: ExcludedFile, i): OrganizationFileResultDraft => ({
|
||||
ordinal: planned.length + i,
|
||||
role: x.kind === "video" ? "main" : SIDECAR_ROLE[x.kind],
|
||||
mode,
|
||||
sourcePath: x.src,
|
||||
targetPath: null,
|
||||
status: "skipped",
|
||||
skipReason: `excluded:${x.reason}`,
|
||||
}),
|
||||
);
|
||||
return [...planned, ...excluded];
|
||||
}
|
||||
|
||||
/** 逐文件状态 → 整理记录状态(全部完成后调用;仍有 queued/running 时为 running) */
|
||||
export function organizationStatusFromFiles(statuses: readonly FileResultStatus[]): OrganizationStatus {
|
||||
if (statuses.some((s) => s === "queued" || s === "running")) return "running";
|
||||
const done = statuses.filter((s) => s !== "skipped");
|
||||
// 全部跳过(或没有文件)视为成功:没有需要做的事
|
||||
if (done.length === 0) return "succeeded";
|
||||
const ok = done.filter((s) => s === "succeeded").length;
|
||||
if (ok === done.length) return "succeeded";
|
||||
if (done.every((s) => s === "cancelled")) return "cancelled";
|
||||
return ok > 0 ? "partial" : "failed";
|
||||
}
|
||||
|
||||
/** 整理计划 → organize.plan 能力输出(planId / payloadHash 由保存计划的一方给出) */
|
||||
export function organizePlanToCapabilityOutput(
|
||||
plan: OrganizePlan,
|
||||
saved: { planId: string; payloadHash: string },
|
||||
): OrganizePlanOutput {
|
||||
const action = OPERATION[plan.operation];
|
||||
return {
|
||||
planId: saved.planId,
|
||||
payloadHash: saved.payloadHash,
|
||||
files: [
|
||||
...plan.files.map((f) => ({
|
||||
sourcePath: f.src,
|
||||
targetPath: f.dst,
|
||||
action,
|
||||
skipReason: f.action === "skip" ? (f.skipReason ?? "skipped") : f.action === "hold" ? "held" : null,
|
||||
})),
|
||||
...plan.excluded.map((x) => ({
|
||||
sourcePath: x.src,
|
||||
targetPath: null,
|
||||
action,
|
||||
skipReason: `excluded:${x.reason}`,
|
||||
})),
|
||||
],
|
||||
conflicts: plan.conflicts.map((c) => ({
|
||||
code: c.kind,
|
||||
message: c.detail ?? (c.blocking ? `${c.kind}(需要处理后才能执行)` : c.kind),
|
||||
path: c.dst ?? c.src ?? null,
|
||||
})),
|
||||
requiresApproval: plan.requiresApproval,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { PathBinding as ContractPathBinding, PathBindingCreate } from "@umt/contracts";
|
||||
import type { PathBinding as DomainPathBinding, NewBinding } from "@umt/domain";
|
||||
|
||||
// 路径绑定:domain {entityId, scope, season, templateVersion:number, active}
|
||||
// ↔ contracts / path_binding {mediaEntityId, bindingKind, seasonNumber, templateVersion:string|null, status}
|
||||
|
||||
export type { DomainPathBinding };
|
||||
|
||||
/** 模板版本:库里是文本(可能是 "3" 或历史导入的任意串),domain 用整数;无法解析按 0(未知)处理 */
|
||||
export function parseTemplateVersion(v: string | null): number {
|
||||
return v !== null && /^\d+$/.test(v) ? Number(v) : 0;
|
||||
}
|
||||
|
||||
/** path_binding 行 → domain;只有 status=active 的绑定参与目标目录决策 */
|
||||
export function toDomainPathBinding(b: ContractPathBinding): DomainPathBinding {
|
||||
return {
|
||||
id: b.id,
|
||||
entityId: b.mediaEntityId,
|
||||
scope: b.bindingKind,
|
||||
...(b.seasonNumber !== null ? { season: b.seasonNumber } : {}),
|
||||
libraryRootId: b.libraryRootId,
|
||||
relativePath: b.relativePath,
|
||||
templateVersion: parseTemplateVersion(b.templateVersion),
|
||||
active: b.status === "active",
|
||||
};
|
||||
}
|
||||
|
||||
/** domain 决策里的新绑定 → path_binding 新建输入(status=active) */
|
||||
export function toPathBindingCreate(
|
||||
nb: NewBinding,
|
||||
target: { mediaEntityId: string; contentUnitId?: string | null; note?: string | null },
|
||||
): PathBindingCreate {
|
||||
return {
|
||||
mediaEntityId: target.mediaEntityId,
|
||||
...(target.contentUnitId !== undefined ? { contentUnitId: target.contentUnitId } : {}),
|
||||
bindingKind: nb.scope,
|
||||
...(nb.scope === "season" ? { seasonNumber: nb.season ?? null } : {}),
|
||||
libraryRootId: nb.libraryRootId,
|
||||
relativePath: nb.relativePath,
|
||||
templateVersion: String(nb.templateVersion),
|
||||
status: "active",
|
||||
...(target.note !== undefined ? { note: target.note } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { ContentType as ContractContentType, Segment as ContractSegment, JsonObject } from "@umt/contracts";
|
||||
import {
|
||||
type ContentType as DomainContentType,
|
||||
type Segment as DomainSegment,
|
||||
PARSER_VERSION,
|
||||
type ParsedRelease,
|
||||
} from "@umt/domain";
|
||||
|
||||
// 标题解析结果 ↔ source_release.parsed / parser_version 列;综艺内容类型与分段 ↔ content_unit 列。
|
||||
|
||||
/** ParsedRelease → source_release 的 parsed(jsonb)与 parser_version(text) */
|
||||
export function toParsedColumns(p: ParsedRelease): { parsed: JsonObject; parserVersion: string } {
|
||||
return { parsed: JSON.parse(JSON.stringify(p)) as JsonObject, parserVersion: String(p.parserVersion) };
|
||||
}
|
||||
|
||||
const REQUIRED_ARRAYS = ["audio", "hdr", "variantTags", "explicitIds", "evidence"] as const;
|
||||
|
||||
/**
|
||||
* source_release 列 → ParsedRelease。版本不是当前 PARSER_VERSION 或形状不对时返回 null,
|
||||
* 调用方应按原标题重新解析(规则变化后旧结果作废)。
|
||||
*/
|
||||
export function fromParsedColumns(cols: { parsed: JsonObject; parserVersion: string | null }): ParsedRelease | null {
|
||||
if (cols.parserVersion !== String(PARSER_VERSION)) return null;
|
||||
const p = cols.parsed;
|
||||
if (p.parserVersion !== PARSER_VERSION || typeof p.raw !== "string" || typeof p.isEncode !== "boolean") return null;
|
||||
if (typeof p.contentType !== "string" || typeof p.confidence !== "string") return null;
|
||||
if (REQUIRED_ARRAYS.some((k) => !Array.isArray(p[k]))) return null;
|
||||
return p as unknown as ParsedRelease;
|
||||
}
|
||||
|
||||
const CONTENT_TYPE: Record<DomainContentType, ContractContentType> = {
|
||||
unknown: "unknown",
|
||||
main: "main",
|
||||
extra: "extra",
|
||||
behind_the_scenes: "behind_the_scenes",
|
||||
performance_only: "performance_only",
|
||||
preview: "preview",
|
||||
reaction: "reaction",
|
||||
};
|
||||
const CONTENT_TYPE_BACK: Record<ContractContentType, DomainContentType> = {
|
||||
main: "main",
|
||||
extra: "extra",
|
||||
behind_the_scenes: "behind_the_scenes",
|
||||
performance_only: "performance_only",
|
||||
preview: "preview",
|
||||
reaction: "reaction",
|
||||
unknown: "unknown",
|
||||
};
|
||||
export const DOMAIN_CONTENT_TYPES = Object.keys(CONTENT_TYPE) as DomainContentType[];
|
||||
|
||||
export const toContractContentType = (t: DomainContentType): ContractContentType => CONTENT_TYPE[t];
|
||||
export const toDomainContentType = (t: ContractContentType): DomainContentType => CONTENT_TYPE_BACK[t];
|
||||
|
||||
const SEGMENT: Record<DomainSegment, ContractSegment> = {
|
||||
full: "full",
|
||||
upper: "upper",
|
||||
middle: "middle",
|
||||
lower: "lower",
|
||||
};
|
||||
const SEGMENT_BACK: Record<ContractSegment, DomainSegment | undefined> = {
|
||||
full: "full",
|
||||
upper: "upper",
|
||||
middle: "middle",
|
||||
lower: "lower",
|
||||
// domain 用"字段缺失"表示未知
|
||||
unknown: undefined,
|
||||
};
|
||||
export const DOMAIN_SEGMENTS = Object.keys(SEGMENT) as DomainSegment[];
|
||||
|
||||
/** 解析结果没有分段信息(undefined)时落为 unknown */
|
||||
export const toContractSegment = (s: DomainSegment | undefined): ContractSegment => (s ? SEGMENT[s] : "unknown");
|
||||
export const toDomainSegment = (s: ContractSegment): DomainSegment | undefined => SEGMENT_BACK[s];
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { JsonObject, SourceRelease, SourceReleaseCreate } from "@umt/contracts";
|
||||
import type { ParsedRelease } from "@umt/domain";
|
||||
import { toParsedColumns } from "./release.ts";
|
||||
|
||||
// 站点资源(adapters 的 SiteRelease)↔ source_release。
|
||||
// mapping 不依赖 adapters:这里用结构相同的 SiteReleaseLike,adapters 的 SiteRelease 可直接传入
|
||||
// (apps/server/test/mapping-compat.test.ts 在类型层校验两者兼容)。
|
||||
|
||||
export type SitePromotionType = "none" | "free" | "2xfree" | "50%" | "2x50%" | "30%" | "2x" | "unknown";
|
||||
|
||||
export interface SiteReleaseLike {
|
||||
/** 适配器侧的站点标识(可能是站点 key);落库时用 site 行 UUID 覆盖 */
|
||||
siteId: string;
|
||||
siteReleaseId: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
category: { id: string | null; name: string | null };
|
||||
sizeBytes: number | null;
|
||||
seeders: number | null;
|
||||
leechers: number | null;
|
||||
snatched: number | null;
|
||||
promotion: {
|
||||
type: SitePromotionType;
|
||||
downloadFactor: number | null;
|
||||
uploadFactor: number | null;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
hr: boolean | "unknown";
|
||||
uploadedAt: string | null;
|
||||
detailUrl: string;
|
||||
downloadUrl?: string;
|
||||
infoHash?: string;
|
||||
imdbId?: string;
|
||||
doubanId?: string;
|
||||
tags: string[];
|
||||
raw: Record<string, unknown>;
|
||||
observedAt: string;
|
||||
}
|
||||
|
||||
const HEX40 = /^[0-9a-f]{40}$/;
|
||||
const HEX64 = /^[0-9a-f]{64}$/;
|
||||
const PROMOTION_TYPES: readonly SitePromotionType[] = [
|
||||
"none",
|
||||
"free",
|
||||
"2xfree",
|
||||
"50%",
|
||||
"2x50%",
|
||||
"30%",
|
||||
"2x",
|
||||
"unknown",
|
||||
];
|
||||
|
||||
/**
|
||||
* SiteRelease → source_release 新建/upsert 输入(按 siteId + siteReleaseId upsert)。
|
||||
* - siteId 取 site 行 UUID(opts.siteId),不是适配器里的站点 key;
|
||||
* - downloadUrl 不入库(可能需要 cookie 或含一次性令牌,下载时现场向适配器取);
|
||||
* - H&R、完成数、详情页、外部 ID 等无专门列的字段放进 raw._site。
|
||||
*/
|
||||
export function siteReleaseToSourceRelease(
|
||||
r: SiteReleaseLike,
|
||||
opts: { siteId: string; parsed?: ParsedRelease },
|
||||
): SourceReleaseCreate {
|
||||
const hash = r.infoHash?.toLowerCase();
|
||||
const site: JsonObject = {
|
||||
adapterSiteId: r.siteId,
|
||||
categoryName: r.category.name,
|
||||
hr: r.hr,
|
||||
snatched: r.snatched,
|
||||
detailUrl: r.detailUrl,
|
||||
...(r.imdbId ? { imdbId: r.imdbId } : {}),
|
||||
...(r.doubanId ? { doubanId: r.doubanId } : {}),
|
||||
};
|
||||
return {
|
||||
siteId: opts.siteId,
|
||||
siteReleaseId: r.siteReleaseId,
|
||||
title: r.title,
|
||||
subtitle: r.subtitle ?? null,
|
||||
categoryRaw: r.category.id ?? r.category.name,
|
||||
tagsRaw: [...r.tags],
|
||||
sizeBytes: r.sizeBytes,
|
||||
infohashV1: hash && HEX40.test(hash) ? hash : null,
|
||||
infohashV2: hash && HEX64.test(hash) ? hash : null,
|
||||
promotion: {
|
||||
kind: r.promotion.type,
|
||||
observedAt: r.observedAt,
|
||||
expiresAt: r.promotion.expiresAt,
|
||||
raw: { downloadFactor: r.promotion.downloadFactor, uploadFactor: r.promotion.uploadFactor },
|
||||
},
|
||||
promotionExpiresAt: r.promotion.expiresAt,
|
||||
seeders: r.seeders,
|
||||
leechers: r.leechers,
|
||||
sourcePublishedAt: r.uploadedAt,
|
||||
...(opts.parsed ? toParsedColumns(opts.parsed) : {}),
|
||||
raw: { ...r.raw, _site: site },
|
||||
};
|
||||
}
|
||||
|
||||
/** source_release 行 → SiteReleaseLike(站点工作台从本地快照展示时用);缺失字段按"未知"还原 */
|
||||
export function sourceReleaseToSiteRelease(row: SourceRelease): SiteReleaseLike {
|
||||
const { _site, ...raw } = row.raw as JsonObject & { _site?: JsonObject };
|
||||
const site = _site ?? {};
|
||||
const promoRaw = (row.promotion.raw ?? {}) as { downloadFactor?: number | null; uploadFactor?: number | null };
|
||||
const kind = row.promotion.kind as SitePromotionType | undefined;
|
||||
const str = (v: unknown) => (typeof v === "string" ? v : undefined);
|
||||
const hr = site.hr === true || site.hr === false ? site.hr : "unknown";
|
||||
const imdbId = str(site.imdbId);
|
||||
const doubanId = str(site.doubanId);
|
||||
const infoHash = row.infohashV1 ?? row.infohashV2 ?? undefined;
|
||||
return {
|
||||
siteId: str(site.adapterSiteId) ?? row.siteId,
|
||||
siteReleaseId: row.siteReleaseId,
|
||||
title: row.title,
|
||||
...(row.subtitle !== null ? { subtitle: row.subtitle } : {}),
|
||||
category: { id: row.categoryRaw, name: str(site.categoryName) ?? null },
|
||||
sizeBytes: row.sizeBytes,
|
||||
seeders: row.seeders,
|
||||
leechers: row.leechers,
|
||||
snatched: typeof site.snatched === "number" ? site.snatched : null,
|
||||
promotion: {
|
||||
type: kind && PROMOTION_TYPES.includes(kind) ? kind : "unknown",
|
||||
downloadFactor: promoRaw.downloadFactor ?? null,
|
||||
uploadFactor: promoRaw.uploadFactor ?? null,
|
||||
expiresAt: row.promotionExpiresAt,
|
||||
},
|
||||
hr,
|
||||
uploadedAt: row.sourcePublishedAt,
|
||||
detailUrl: str(site.detailUrl) ?? "",
|
||||
...(infoHash ? { infoHash } : {}),
|
||||
...(imdbId ? { imdbId } : {}),
|
||||
...(doubanId ? { doubanId } : {}),
|
||||
tags: [...row.tagsRaw],
|
||||
raw,
|
||||
observedAt: row.lastSeenAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type {
|
||||
AcquisitionSlot,
|
||||
AcquisitionSlotCreate,
|
||||
SlotStatus as ContractSlotStatus,
|
||||
JsonObject,
|
||||
} from "@umt/contracts";
|
||||
import type {
|
||||
SlotStatus as DomainSlotStatus,
|
||||
SlotCompletionSpec,
|
||||
SlotEvaluation,
|
||||
SlotHardConditions,
|
||||
SlotSoftPreferences,
|
||||
SlotSpec,
|
||||
SlotUpgradePolicy,
|
||||
} from "@umt/domain";
|
||||
|
||||
// Slot:状态两侧取值相同(REQ §6.1),映射表只为让任一侧增删值时编译/测试失败。
|
||||
// 规则 JSON 列(hard_conditions / soft_preferences / upgrade_threshold / completion / fallback)↔ domain SlotSpec。
|
||||
|
||||
export type { DomainSlotStatus };
|
||||
|
||||
const TO_CONTRACT: Record<DomainSlotStatus, ContractSlotStatus> = {
|
||||
empty: "empty",
|
||||
reserved: "reserved",
|
||||
downloading: "downloading",
|
||||
verifying: "verifying",
|
||||
satisfied: "satisfied",
|
||||
attention: "attention",
|
||||
upgrade_pending: "upgrade_pending",
|
||||
};
|
||||
const TO_DOMAIN: Record<ContractSlotStatus, DomainSlotStatus> = {
|
||||
empty: "empty",
|
||||
reserved: "reserved",
|
||||
downloading: "downloading",
|
||||
verifying: "verifying",
|
||||
satisfied: "satisfied",
|
||||
upgrade_pending: "upgrade_pending",
|
||||
attention: "attention",
|
||||
};
|
||||
|
||||
export const DOMAIN_SLOT_STATUSES = Object.keys(TO_CONTRACT) as DomainSlotStatus[];
|
||||
|
||||
export const toContractSlotStatus = (s: DomainSlotStatus): ContractSlotStatus => TO_CONTRACT[s];
|
||||
export const toDomainSlotStatus = (s: ContractSlotStatus): DomainSlotStatus => TO_DOMAIN[s];
|
||||
|
||||
type SlotRuleRow = Pick<
|
||||
AcquisitionSlot,
|
||||
"id" | "name" | "hardConditions" | "softPreferences" | "upgradeThreshold" | "completion" | "fallback"
|
||||
>;
|
||||
|
||||
const isEmpty = (o: JsonObject | null | undefined) => !o || Object.keys(o).length === 0;
|
||||
|
||||
/**
|
||||
* acquisition_slot 行 → domain SlotSpec。JSON 列按 domain 类型原样解释(写入时由 slotSpecToColumns 保证形状);
|
||||
* fallback.unknownPolicy 对应 SlotSpec.unknownPolicy。
|
||||
*/
|
||||
export function slotSpecFromRow(row: SlotRuleRow): SlotSpec {
|
||||
const unknownPolicy = row.fallback.unknownPolicy as SlotSpec["unknownPolicy"] | undefined;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
hard: row.hardConditions as SlotHardConditions,
|
||||
...(unknownPolicy !== undefined ? { unknownPolicy } : {}),
|
||||
...(isEmpty(row.softPreferences) ? {} : { soft: row.softPreferences as SlotSoftPreferences }),
|
||||
...(isEmpty(row.upgradeThreshold) ? {} : { upgrade: row.upgradeThreshold as unknown as SlotUpgradePolicy }),
|
||||
...(isEmpty(row.completion) ? {} : { completion: row.completion as unknown as SlotCompletionSpec }),
|
||||
};
|
||||
}
|
||||
|
||||
/** domain SlotSpec → acquisition_slot 规则列(Create/Update 可直接展开) */
|
||||
export function slotSpecToColumns(
|
||||
spec: SlotSpec,
|
||||
): Pick<
|
||||
AcquisitionSlotCreate,
|
||||
"name" | "hardConditions" | "softPreferences" | "upgradeThreshold" | "completion" | "fallback"
|
||||
> {
|
||||
const json = (v: unknown): JsonObject => (v ? (JSON.parse(JSON.stringify(v)) as JsonObject) : {});
|
||||
return {
|
||||
name: spec.name,
|
||||
hardConditions: json(spec.hard),
|
||||
softPreferences: json(spec.soft),
|
||||
upgradeThreshold: json(spec.upgrade),
|
||||
completion: json(spec.completion),
|
||||
fallback: spec.unknownPolicy === undefined ? {} : json({ unknownPolicy: spec.unknownPolicy }),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContractSlotEvaluation {
|
||||
decision: SlotEvaluation["decision"];
|
||||
/** 可以用这个候选生成计划(accept 或 upgrade) */
|
||||
acceptable: boolean;
|
||||
/** 与 DownloadPlanOutput.rejected 同形:硬条件不满足 / 字段未知的原因 */
|
||||
rejected: { code: string; message: string }[];
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
/** domain 评估结果 → 能力输出/决策记录用的扁平表示 */
|
||||
export function toContractSlotEvaluation(ev: SlotEvaluation): ContractSlotEvaluation {
|
||||
switch (ev.decision) {
|
||||
case "accept":
|
||||
case "upgrade":
|
||||
return { decision: ev.decision, acceptable: true, rejected: [], reasons: [...ev.reasons] };
|
||||
case "reject":
|
||||
return {
|
||||
decision: "reject",
|
||||
acceptable: false,
|
||||
rejected: ev.violations.map((v) => ({
|
||||
code: `${v.field}.${v.code}`,
|
||||
message:
|
||||
v.expected === undefined
|
||||
? `${v.field}: ${v.code}`
|
||||
: `${v.field}: ${v.code}(期望 ${JSON.stringify(v.expected)},实际 ${JSON.stringify(v.actual ?? null)})`,
|
||||
})),
|
||||
reasons: [],
|
||||
};
|
||||
case "unknown_field":
|
||||
return {
|
||||
decision: "unknown_field",
|
||||
acceptable: false,
|
||||
rejected: ev.missing.map((m) => ({ code: `unknown_field.${m}`, message: `字段未知,需要补充: ${m}` })),
|
||||
reasons: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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) });
|
||||
});
|
||||
});
|
||||
@@ -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"]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../config/tsconfig.base.json",
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
Reference in new issue
Block a user