- root deno.json workspace (nodeModulesDir auto), npm versions registered once in imports, deno.lock; remove pnpm-lock/pnpm-workspace/root package.json; members keep minimal package.json (name/type/exports) - tasks: dev, typecheck (tsc 7.0.2 per member + deno check), lint/fmt (biome, useImportExtensions), test (vitest via deno), check:tokens, build (web + deno compile), ci, db:migrate/reset, gen:types - tsconfig base moved to config/ (no root tsconfig.json); deno types -> types/deno.d.ts; web drops baseUrl - server: Deno.serve entry with graceful shutdown, --role migrate|preflight|probe, WEB_ROOT static SPA hosting; drop @hono/node-server, tsx, esbuild build.mjs - logger: redact child() bindings and serialize err by message/stack; sync stdout destination; tests - relative imports get explicit extensions (sites/mp-sync paths left for follow-up) - crash-recovery tests spawn `deno run -A`; tasks tests use unique per-file schemas - single-container Dockerfile + entrypoint (migrate -> preflight -> api+worker watchdog), docker-compose.yml - docs: README, runbook, ADR-001 superseded, plan §1 validation row Co-Authored-By: Claude Fable 5.1 <[email protected]>
6.9 KiB
6.9 KiB
@umt/domain
纯业务逻辑(无 IO):发布标题解析、身份匹配、Slot 满足度、收录模式/预算/磁盘阈值、路径绑定与命名、整理计划、下载状态映射。
- 禁止 import 任何网络/文件/数据库库,也不读全局配置与时钟;时间(
now)、设备号、文件清单、已存在目标等事实一律由调用方传入。 - 不依赖
@umt/contracts:这里的类型是领域类型,W2 在 server/tasks 层映射到 zod 合同。 - 唯一入口
src/index.ts(export *各模块)。测试:cd packages/domain && deno task test。
模块地图
| 目录 | 主要导出 | 说明 |
|---|---|---|
release-parser/ |
parseRelease(title, opts)、parseReleasePath(path | segments, opts)、mergeParsed、scanCnMarkers、evalEpisodeOffset、cnToInt、fileKindOf、expandEpisodes、RELEASE_GROUP_SEED、PARSER_VERSION |
标题/文件名/目录名 → ParsedRelease。行为对照 MP MetaInfo/MetaInfoPath,偏离点见下 |
identity/ |
scoreCandidate、rankCandidates、resolveIdentity、normalizeTitle、stripSeasonSuffix、toSimplified |
候选(TMDB 或本地别名召回,统一为 IdentityCandidate)按证据等级排序;年份容差 ±1;只给等级不给概率 |
slot/ |
evaluateCandidate(spec, parsed, current?)、rankForSlot、checkHardConditions、softRank、qualityTier、transition/canTransition、movieCompletion、unitCompletion、checkDistinctAssets |
硬条件(区分缺字段与不满足)、软偏好排序、升级阈值、状态机、完成条件 |
acquisition/ |
determineMode、sortByPriority/priorityOf、checkBudget、evaluateCacheVolume |
新作品/追更补缺/升级判定;即时>追更>新作品>升级(可配置);每日预算;缓存卷硬阈值与恢复水位 |
path/ |
resolveTargetDirectory、renderMediaFileName、renderTemplate/compileTemplate、buildNamingContext、sanitizeSegment/sanitizeFileName、detectPathConflicts、toHostPath/toDownloaderPath、validatePathMappings |
路径绑定(已有优先、不可变)、受限模板命名、非法字符、冲突结构、下载器↔宿主路径映射 |
organize/ |
planOrganize(input) → OrganizePlan、sidecarStem、sidecarLanguageSuffix |
主视频 + 随行字幕/音轨、样例排除、目标存在策略、跨卷标记、H&R 前置条件 |
download-state/ |
mapQbState、applyObservation、isDownloadComplete、detectNoProgress |
qB 状态 → download_task 状态机;完成判定;无进展只产出证据 |
不变量(对应 REQ §2.1,改动这些行为必须先改需求)
- 身份不等于名字,路径不等于外部 ID。
identity只输出候选与证据等级,不写入任何绑定;文件名里的[tmdbid=…]只提升证据一级,不直接视为已确认身份。命名一律用本地实体的标题(buildNamingContext(entity, parsed)),发布标题只提供季集/版本等事实。 - 已建立的路径绑定不随元数据重新计算。
resolveTargetDirectory在存在有效绑定时直接返回existing,模板与译名不参与;只有首次建立作品/季目录才渲染模板并带templateVersion;同一作品多个历史目录 →conflict,不自动合并。 - 模型不是业务事实来源,也不产生概率。 解析与匹配只给
rule_confirmed / multi_evidence / needs_confirm(及rejected)等级和evidence[]规则名;Slot 评估与优先级都是确定性代码。 - 失败不等于不存在。 字段缺失与字段不满足分开(
unknown_fieldvsreject);磁盘状态读不到 →block;体积未知 → 预算unknown;qB 返回未知状态 → 沿用上次并标记uncertain;mapQbState(null)(=removed)只能在 qB 可达且确认列表中没有时调用;Slot 只在"确认缺失"事件下进 attention。 - 清理/覆盖按明确授权。 整理计划默认
onExisting: "conflict";替换需grants.replace且旧文件先隔离;move 在 H&R 未满足或未知时是阻塞冲突,不静默改 copy;跨卷只标记crossDevice,由执行层走 copy→verify→delete source;孤儿字幕不搬不删;无进展只record_evidence,从不换源。
与 MoviePilot 的偏离(测试里以 [deviation: …] 标注)
| 场景 | MP | 本项目 |
|---|---|---|
综艺日期 2024.05.03 / 20240503 |
删除或忽略 | airDate(YYYYMMDD),有日期即单期,绝不判整季包 |
第N期 |
当 episode | issue,与 episode 分开 |
第八季 全12期 |
季号丢失 | season=8,totalEpisodes=12 |
| 未知发布组 | None |
末段 -GROUP 兜底,releaseGroup.source = "suffix";动漫开头方括号 bracket |
| 剪辑版 Extended/Director's Cut/IMAX… | 当干扰词删除 | 独立 cut 字段(内容版本),与交付版本分开 |
| 文件无剧集信号、目录是剧集 | 不合并 | 合并名称/季号,证据 parent_dir:type_inferred,置信度 needs_confirm |
辅助词文件名(简体.srt) |
取到根目录名 | auxiliaryOnly,名称取父目录,交给附属文件配对 |
| 附加内容目录(Featurettes/花絮) | 识别成独立电影 | contentType = "extra",归属父作品;整理计划中排除(M2) |
分盘 CD1.mkv |
part 丢失 | part = "CD1" |
| 有集号无季号 | 季号为空 | 推定 1 并标 seasonInferred |
| 集号偏移 | eval() |
白名单解析器 evalEpisodeOffset,负结果拒绝 |
| 多集文件 E01-E03 | 截成单集命名 | 保留区间 S02E01-E03 |
| overwrite / latest | 可静默覆盖、删同集其他版本 | 默认冲突待处理;跨 Slot 永不删除;替换需授权 + 隔离 |
| move 跨盘 | EXDEV 隐式降级复制,删源失败仍成功 | 只标记 crossDevice,执行层显式三步 |
| Move 删种 | 不看 H&R | H&R 未满足/未知 → 阻塞冲突 |
给 W2 的接入提示
- 解析:下载完成后用
parseReleasePath({ file, parent, grandParent })或planOrganize({ sourceRoot })(只在种子根内取目录名);RSS/站点标题用parseRelease(title, { subtitle })。ParsedRelease.sizeBytes可用站点字段覆盖({ ...parsed, sizeBytes })后再交给 Slot 评估。 - 身份:本地别名召回(pg_trgm)与 TMDB 结果都转成
IdentityCandidate(本地用provider: "local"+localEntityId+linkedExternalIds);resolveIdentity为auto才自动绑定,其余进待识别池。 - 选片:
rankForSlot(spec, candidates, currentAssetFacts);unknown_field表示需补查详情(如字幕/体积),不是拒绝。 - 下载前:
determineMode→priorityOf→checkBudget→evaluateCacheVolume(按卷汇总所有 qB 实例的在途任务,已提交计划的预留不要再传)。 - 入库:
resolveTargetDirectory(返回的newBindings与文件登记同事务写入)→ 库根 hostPath 拼成绝对目录 →planOrganize→ 冻结计划;requiresApproval为真时生成 decision/approval。 - 状态:Slot 与下载任务的状态迁移在写库前先过
transition/applyObservation,非法迁移抛错即拒绝写入。