58 lines
8.0 KiB
Markdown
58 lines
8.0 KiB
Markdown
# @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?, ctx?)`、`rankForSlot`、`checkHardConditions`、`softRank`、`qualityTier`、`priorityLadder`/`currentPri`、`improvableUnits`、`describeViolation`、`validateKeywordPattern`、`transition`/`canTransition`、`transitionWithInflight`、`movieCompletion`、`unitCompletion`、`checkDistinctAssets`、`resolveEpisodeTotal`、`computeUnitGaps`/`seasonProgress`/`unitsOverlap` | 硬条件(区分缺字段与不满足;含排除来源、HDR/编码集合、每集体积、关键词正则、站点/做种/免费,站点事实经 `EvaluationContext`)、软偏好排序、优先级阶梯(MP `>` 分层 → pri 0–100 + 逐层 trace)、升级阈值与洗版模式(movie/per_unit/full_pack)、状态机(含 Slot×单元多在途聚合)、完成条件、季总集数账本(只升、回退不低于已覆盖最高集)、缺集 = 单元 ∩ 范围 − 已覆盖 − 在途 |
|
||
| `rules/` | `parseRuleString`/`parseExpr`、`serializeLayers`/`serializeExpr`、`normalizeExpr`、`BUILTIN_ATOMS`、`customAtomFromMp`、`evaluateRuleGroup`/`evaluateRuleChain`、`testAtom` | MP 规则串 ↔ 结构化层(`>` 分层,层内 `! & \|` 与括号,错误带位置);内置关键字对齐 MP(能用解析字段的用字段,其余沿用 MP 正则,音乐 13 条不支持);多组串联取交集、pri 取最后一个带层的组;经 `SlotSpec.rules` 接入 `evaluateCandidate`。差分见 `test/differential/rules.test.ts` 与 `docs/runbook/rules.md` |
|
||
| `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,改动这些行为必须先改需求)
|
||
|
||
1. **身份不等于名字,路径不等于外部 ID。** `identity` 只输出候选与证据等级,不写入任何绑定;文件名里的 `[tmdbid=…]` 只提升证据一级,不直接视为已确认身份。命名一律用本地实体的标题(`buildNamingContext(entity, parsed)`),发布标题只提供季集/版本等事实。
|
||
2. **已建立的路径绑定不随元数据重新计算。** `resolveTargetDirectory` 在存在有效绑定时直接返回 `existing`,模板与译名不参与;只有首次建立作品/季目录才渲染模板并带 `templateVersion`;同一作品多个历史目录 → `conflict`,不自动合并。
|
||
3. **模型不是业务事实来源,也不产生概率。** 解析与匹配只给 `rule_confirmed / multi_evidence / needs_confirm`(及 `rejected`)等级和 `evidence[]` 规则名;Slot 评估与优先级都是确定性代码。
|
||
4. **失败不等于不存在。** 字段缺失与字段不满足分开(`unknown_field` vs `reject`);磁盘状态读不到 → `block`;体积未知 → 预算 `unknown`;qB 返回未知状态 → 沿用上次并标记 `uncertain`;`mapQbState(null)`(=removed)只能在 qB 可达且确认列表中没有时调用;Slot 只在"确认缺失"事件下进 attention。
|
||
5. **清理/覆盖按明确授权。** 整理计划默认 `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`,非法迁移抛错即拒绝写入。
|