- 同一批视频里文件名标题与作品标题 / 原名 / 别名明显不符(没有足够共同词)的排除为 title_mismatch,不按本作品命名入库; - 只在同批至少一个文件对得上作品时才判断,剧集只看带 SxxEyy 的文件,用户逐文件指定过的不核; - title_mismatch 算「本该入库却被排除」:记录部分完成、开待处理、Move 不移除下载任务。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
@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;只给等级不给概率 |
episode-resolution/ |
resolveEpisodes、validateEpisodes、convertEpisodes、selectRule、applyRule、suggestRule |
解析出的季集号 → 媒体库规范季集:规则 → 原号校验(元数据滞后放行)→ 绝对集号 / 播出日期换算 → 待确认。见 docs/plan/episode-resolution.md |
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,改动这些行为必须先改需求)
- 身份不等于名字,路径不等于外部 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,非法迁移抛错即拒绝写入。