feat(domain): pure business logic for parsing, identity, slots, acquisition, paths, organize, download state (W1-2)

- release-parser: title/file/dir parsing with MP parity + deviations (airDate/issue,
  cut, release group suffix fallback, dir+file merge, safe episode offset)
- identity: title normalization (NFKC, trad->simp map, season suffix) and
  evidence-level candidate ranking (year tolerance ±1, no probabilities)
- slot: hard/soft/upgrade evaluation (missing vs unmet fields), state machine,
  movie/unit completion (season pack must be covered by actual file list)
- acquisition: new_title/catch_up/upgrade modes, priority order, budget,
  cache volume hard threshold with resume watermark (no double counting)
- path: immutable path bindings, restricted naming templates, sanitize,
  conflict detection, downloader<->host path mapping
- organize: planOrganize with sidecar pairing, sample exclusion, existing
  target policy, crossDevice marking, H&R -> conflict for move
- download-state: qB state mapping, completion, no-progress evidence

230 tests, ~97% statement coverage.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-27 18:11:29 +08:00
1 parent a6d049db81
commit 7ba712ea55
40 files changed
+5885 -2

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
export {
type EvidenceLevel,
type IdentityCandidate,
type IdentityResolution,
type MatchOptions,
queryTitles,
type RankedCandidate,
rankCandidates,
resolveIdentity,
scoreCandidate,
type TitleMatch,
type TypeMatch,
type YearMatch,
} from "./match";
export { normalizeTitle, stripSeasonSuffix } from "./normalize";
export { TRAD_TO_SIMP, toSimplified } from "./zh-map";
+178
View File
@@ -0,0 +1,178 @@
import type { Confidence, ParsedRelease } from "../release-parser/types";
import { normalizeTitle } from "./normalize";
export interface IdentityCandidate {
/** tmdb / local(本地别名召回)/ 其他提供方 */
provider: string;
entityType: "movie" | "tv";
externalId: string;
titles: string[];
year?: number;
aliases: string[];
/** 剧集各季首播年份(发布标题里的年份可能是季年份) */
seasonYears?: number[];
/** local 候选:本地实体 UUID 及其已登记的外部映射 */
localEntityId?: string;
linkedExternalIds?: { provider: string; id: string }[];
}
export type EvidenceLevel = Confidence | "rejected";
export type TitleMatch = "exact" | "alias" | "partial" | "none";
export type YearMatch = "exact" | "season_year" | "within_tolerance" | "unknown" | "mismatch";
export type TypeMatch = "match" | "mismatch" | "unknown";
export interface RankedCandidate {
candidate: IdentityCandidate;
level: EvidenceLevel;
titleMatch: TitleMatch;
yearMatch: YearMatch;
typeMatch: TypeMatch;
evidence: string[];
}
export interface MatchOptions {
/** 年份容差,默认 ±1 */
yearTolerance?: number;
}
const LEVEL_RANK: Record<EvidenceLevel, number> = {
rule_confirmed: 3,
multi_evidence: 2,
needs_confirm: 1,
rejected: 0,
};
const TITLE_RANK: Record<TitleMatch, number> = { exact: 3, alias: 2, partial: 1, none: 0 };
const YEAR_RANK: Record<YearMatch, number> = {
exact: 4,
season_year: 3,
within_tolerance: 2,
unknown: 1,
mismatch: 0,
};
const raise = (l: EvidenceLevel): EvidenceLevel =>
l === "needs_confirm" ? "multi_evidence" : l === "multi_evidence" ? "rule_confirmed" : l;
export function queryTitles(p: ParsedRelease): string[] {
const out: string[] = [];
for (const t of [p.titleCn, p.titleEn, p.title]) {
if (!t) continue;
const n = normalizeTitle(t);
if (n && !out.includes(n)) out.push(n);
}
return out;
}
function titleMatch(queries: string[], c: IdentityCandidate): TitleMatch {
const titles = c.titles.map(normalizeTitle).filter(Boolean);
const aliases = c.aliases.map(normalizeTitle).filter(Boolean);
if (queries.some((q) => titles.includes(q))) return "exact";
if (queries.some((q) => aliases.includes(q))) return "alias";
const all = [...titles, ...aliases];
const partial = queries.some((q) =>
all.some((t) => {
const [short, long] = q.length <= t.length ? [q, t] : [t, q];
return short.length >= 2 && long.includes(short);
}),
);
return partial ? "partial" : "none";
}
function yearMatch(p: ParsedRelease, c: IdentityCandidate, tol: number): YearMatch {
if (p.year === undefined || c.year === undefined) return "unknown";
if (p.year === c.year) return "exact";
if (c.entityType === "tv" && c.seasonYears?.includes(p.year)) return "season_year";
if (Math.abs(p.year - c.year) <= tol) return "within_tolerance";
// 剧集后续季的发布标题常带季年份,而非首播年份:未提供季年份时不据此拒绝
if (c.entityType === "tv" && (p.season ?? 1) > 1 && p.year > c.year && !c.seasonYears) return "unknown";
return "mismatch";
}
function typeMatch(p: ParsedRelease, c: IdentityCandidate): TypeMatch {
if (p.mediaTypeHint === "tv") return c.entityType === "tv" ? "match" : "mismatch";
return "unknown";
}
/** 单个候选的证据等级(纯函数,不产生概率数字) */
export function scoreCandidate(p: ParsedRelease, c: IdentityCandidate, opts: MatchOptions = {}): RankedCandidate {
const tol = opts.yearTolerance ?? 1;
const tm = titleMatch(queryTitles(p), c);
const ym = yearMatch(p, c, tol);
const ty = typeMatch(p, c);
const evidence: string[] = [`title:${tm}`, `year:${ym}`, `type:${ty}`];
const explicit = p.explicitIds.some(
(x) =>
(x.provider === c.provider && x.id === c.externalId) ||
c.linkedExternalIds?.some((l) => l.provider === x.provider && l.id === x.id),
);
if (explicit) evidence.push("explicit_id");
if (c.provider === "local") evidence.push("local_entity");
let level: EvidenceLevel;
if (ym === "mismatch") level = "rejected";
else if (tm === "none") level = explicit ? "needs_confirm" : "rejected";
else if (tm === "partial") level = "needs_confirm";
else if (ym === "exact" || ym === "season_year") level = tm === "exact" ? "rule_confirmed" : "multi_evidence";
else if (ym === "within_tolerance") level = "multi_evidence";
else {
// 年份未知:剧集有季集信息且类型一致时仍算多证据,电影需确认
level = ty === "match" && p.season !== undefined ? "multi_evidence" : "needs_confirm";
}
if (level !== "rejected" && ty === "mismatch") {
level = "needs_confirm";
evidence.push("type_conflict");
}
if (explicit && level !== "rejected" && tm !== "none") level = raise(level);
return { candidate: c, level, titleMatch: tm, yearMatch: ym, typeMatch: ty, evidence };
}
/** 候选排序:等级 > 标题 > 年份 > 本地优先 > 原顺序 */
export function rankCandidates(
p: ParsedRelease,
candidates: readonly IdentityCandidate[],
opts: MatchOptions = {},
): RankedCandidate[] {
return candidates
.map((c, i) => ({ r: scoreCandidate(p, c, opts), i }))
.sort(
(a, b) =>
LEVEL_RANK[b.r.level] - LEVEL_RANK[a.r.level] ||
TITLE_RANK[b.r.titleMatch] - TITLE_RANK[a.r.titleMatch] ||
YEAR_RANK[b.r.yearMatch] - YEAR_RANK[a.r.yearMatch] ||
Number(b.r.candidate.provider === "local") - Number(a.r.candidate.provider === "local") ||
a.i - b.i,
)
.map((x) => x.r);
}
function sameIdentity(a: IdentityCandidate, b: IdentityCandidate): boolean {
if (a.localEntityId && a.localEntityId === b.localEntityId) return true;
if (a.provider === b.provider && a.entityType === b.entityType && a.externalId === b.externalId) return true;
const linked = (x: IdentityCandidate, y: IdentityCandidate) =>
x.linkedExternalIds?.some((l) => l.provider === y.provider && l.id === y.externalId) ?? false;
return linked(a, b) || linked(b, a);
}
export interface IdentityResolution {
/** auto = 唯一且证据足够,可自动绑定;ambiguous = 进待识别池;no_match = 无可用候选 */
status: "auto" | "ambiguous" | "no_match";
best?: RankedCandidate;
ranked: RankedCandidate[];
reason: string;
}
export function resolveIdentity(
p: ParsedRelease,
candidates: readonly IdentityCandidate[],
opts: MatchOptions = {},
): IdentityResolution {
const ranked = rankCandidates(p, candidates, opts);
const top = ranked[0];
if (!top || top.level === "rejected") return { status: "no_match", ranked, reason: "no_viable_candidate" };
if (top.level === "needs_confirm") return { status: "ambiguous", best: top, ranked, reason: "weak_evidence" };
const rivals = ranked.filter(
(r) => r !== top && LEVEL_RANK[r.level] >= LEVEL_RANK[top.level] && !sameIdentity(r.candidate, top.candidate),
);
if (rivals.length > 0) return { status: "ambiguous", best: top, ranked, reason: "multiple_candidates_same_level" };
return { status: "auto", best: top, ranked, reason: top.level };
}
+37
View File
@@ -0,0 +1,37 @@
import { CN_NUM } from "../release-parser/cn-number";
import { toSimplified } from "./zh-map";
const SEASON_SUFFIX = [
new RegExp(`\\s*第\\s*${CN_NUM}\\s*[季部]\\s*$`, "u"),
/\s*[::-]?\s*(?:season|series)\s*\d+\s*$/iu,
/\s+S\d{1,2}\s*$/iu,
/\s*[((]\s*(?:19|20)\d{2}\s*[))]\s*$/u,
];
/** 去掉标题末尾的季号/年份后缀("庆余年 第二季"、"Show Season 2"、"Title (2019)") */
export function stripSeasonSuffix(s: string): string {
let out = s.trim();
for (let changed = true; changed; ) {
changed = false;
for (const re of SEASON_SUFFIX) {
const next = out.replace(re, "");
if (next !== out && next.trim().length > 0) {
out = next.trim();
changed = true;
}
}
}
return out;
}
/**
* 标题归一化(用于比较,不用于展示):
* 全角→半角(NFKC)、繁→简、小写、& → and、去季号后缀、去标点与空白。
*/
export function normalizeTitle(s: string): string {
const base = stripSeasonSuffix(toSimplified(s.normalize("NFKC")));
return base
.toLowerCase()
.replace(/&/g, "and")
.replace(/[^\p{L}\p{N}]+/gu, "");
}
+19
View File
@@ -0,0 +1,19 @@
/**
* 繁 → 简 简单映射表(只覆盖影视标题常见字,不引第三方库)。
* 格式:每两个字符为一对「繁简」。一对多/语境相关的字(乾/著/瞭 等)刻意不收。
*/
const PAIRS =
"萬万與与專专業业叢丛東东絲丝兩两嚴严喪丧個个豐丰臨临為为麗丽舉举麼么義义烏乌樂乐喬乔習习鄉乡書书買买亂乱爭争於于虧亏雲云亞亚產产親亲億亿僅仅從从倉仓儀仪們们價价眾众優优會会傘伞偉伟傳传傷伤倫伦偽伪體体餘余傭佣俠侠侶侣偵侦側侧僑侨儂侬倆俩儉俭債债傾倾償偿儲储兒儿兇凶黨党蘭兰關关興兴養养獸兽內内岡冈冊册寫写軍军農农馮冯衝冲決决況况凍冻淨净準准涼凉減减湊凑幾几鳳凤憑凭凱凯擊击劃划劉刘則则剛刚創创刪删別别劑剂劍剑剝剥劇剧勸劝辦办務务動动勵励勁劲勞劳勢势勳勋勝胜區区醫医華华協协單单賣卖盧卢衛卫卻却廠厂廳厅曆历厲厉壓压厭厌廁厕廂厢廈厦廚厨縣县參参雙双發发變变敘叙疊叠葉叶號号嘆叹嚇吓呂吕嗎吗啟启吳吴員员響响啞哑嘩哗喚唤噴喷團团園园圍围國国圖图圓圆聖圣場场壞坏塊块堅坚壇坛壩坝墳坟墜坠壘垒墊垫牆墙壯壮聲声殼壳壺壶處处備备復复夠够頭头誇夸夾夹奪夺奮奋獎奖奧奥妝妆婦妇媽妈嬌娇娛娱嬰婴嬸婶孫孙學学寧宁寶宝實实寵宠審审憲宪宮宫寬宽賓宾寢寝對对尋寻導导壽寿將将爾尔塵尘嘗尝堯尧屍尸盡尽層层屆届屬属嶼屿歲岁豈岂崗岗島岛嶺岭峽峡嶄崭巔巅幣币帥帅師师帳帐簾帘幟帜帶带幫帮並并廣广莊庄慶庆廬庐庫库應应廟庙龐庞廢废開开異异棄弃張张彌弥彎弯彈弹強强歸归當当錄录彥彦徹彻徑径憶忆憂忧懷怀態态憐怜總总戀恋懇恳惡恶惱恼悅悦懸悬驚惊懼惧慘惨懲惩慚惭慣惯憤愤願愿懶懒戲戏戰战戶户紮扎撲扑執执擴扩掃扫揚扬擾扰撫抚拋抛搶抢護护報报擔担擬拟擁拥攔拦擇择掛挂揮挥撈捞損损撿捡換换據据擲掷攬揽攜携攝摄擺摆搖摇攤摊撐撑敵敌斂敛數数齋斋鬥斗斬斩斷断無无舊旧時时曠旷晝昼顯显晉晋曬晒曉晓暈晕暉晖暫暂術术樸朴機机殺杀雜杂權权條条來来楊杨傑杰極极構构槍枪楓枫櫃柜標标棧栈棟栋欄栏樹树棲栖樣样檔档橋桥夢梦檢检樓楼欖榄櫻樱櫥橱歡欢歐欧殲歼殘残毀毁畢毕斃毙氣气匯汇漢汉湯汤溝沟沒没淚泪瀉泻潑泼澤泽潔洁灑洒淺浅漿浆濁浊測测濟济渾浑濃浓濤涛漲涨淵渊漸渐漁渔滲渗溫温遊游灣湾濕湿潰溃濺溅滿满濾滤濫滥濱滨灘滩瀟潇潛潜瀾澜瀕濒滅灭燈灯靈灵災灾燦灿爐炉點点煉炼熾炽爍烁爛烂燭烛煙烟煩烦燒烧熱热煥焕愛爱爺爷牽牵犧牺狀状猶犹狹狭獅狮獄狱獨独獵猎豬猪貓猫獻献瑪玛環环現现瓊琼電电畫画暢畅療疗瘋疯癢痒發发皺皱盞盏鹽盐監监蓋盖盜盗盤盘睜睁瞞瞒矯矫礦矿碼码磚砖確确礙碍禮礼禍祸禪禅離离禿秃種种積积稱称穩稳穫获窮穷竊窃窯窑竄窜窩窝豎竖競竞筆笔築筑篩筛籌筹簽签簡简籃篮籬篱類类糧粮糾纠紀纪約约紅红紋纹納纳紐纽純纯紗纱紡纺紛纷級级紙纸細细紳绅終终組组結结繞绕給给絢绚絡络絕绝統统綁绑絹绢繡绣經经綜综綠绿綴缀網网綱纲緒绪續续緊紧維维綿绵緣缘編编緩缓緯纬練练縫缝縮缩織织繩绳繪绘繳缴纖纤羅罗罰罚罷罢羈羁翹翘聳耸恥耻聶聂聾聋職职聯联聰聪肅肃腸肠膚肤腫肿脅胁膽胆朧胧膠胶脈脉腦脑腳脚脫脱臉脸臘腊騰腾輿舆艦舰艙舱艱艰豔艳藝艺節节蕪芜蘆芦葦苇蒼苍蘇苏蘋苹莖茎薦荐莢荚蕩荡榮荣藥药萊莱蓮莲獲获瑩莹鶯莺蘿萝螢萤營营蕭萧薩萨蔥葱蔣蒋藍蓝薔蔷藹蔼蘊蕴虜虏慮虑虛虚蟲虫雖虽蝦虾蝕蚀蟻蚁螞蚂蠶蚕蠻蛮蝸蜗蠟蜡蠅蝇蟬蝉補补襯衬襖袄襪袜襲袭裝装褲裤見见觀观規规覓觅視视覽览覺觉觸触計计訂订認认討讨讓让訓训議议訊讯記记講讲許许論论設设訪访訣诀證证評评識识詐诈訴诉診诊詞词譯译試试詩诗誠诚話话誕诞詭诡詢询該该詳详誤误語语誘诱說说誦诵請请諸诸諾诺讀读課课誰谁調调諒谅談谈誼谊謀谋謊谎諧谐謂谓謎谜謝谢謠谣謙谦謹谨謬谬譜谱貝贝貞贞負负貢贡財财責责賢贤敗败賬账貨货質质販贩貪贪貧贫購购貫贯貼贴貴贵貸贷貿贸費费賀贺賊贼資资賦赋賭赌贖赎賞赏賜赐賠赔賴赖賺赚賽赛贈赠贊赞贏赢趙赵趕赶趨趋躍跃踐践蹤踪躲躲車车軌轨軍军軒轩轉转輪轮軟软轟轰軸轴輕轻載载較较輔辅輛辆輩辈輝辉輸输轄辖辭辞辯辩邊边遼辽達达遷迁過过邁迈運运還还這这進进遠远違违連连遲迟適适選选遜逊遞递邏逻遺遗遙遥鄧邓郵邮鄰邻鬱郁鄭郑醞酝醬酱釀酿釋释裏里鑒鉴針针釘钉鐘钟鋼钢鑰钥鈴铃鐵铁鉛铅銅铜鋁铝銀银鏈链銷销鎖锁鋒锋鋤锄鍋锅錯错錨锚錘锤錦锦鍵键鋸锯鍛锻鏡镜鐮镰鑲镶長长門门閃闪閉闭問问闖闯閑闲間间悶闷閘闸鬧闹閨闺聞闻閣Line truncated
export const TRAD_TO_SIMP: ReadonlyMap<string, string> = (() => {
const chars = [...PAIRS];
const m = new Map<string, string>();
for (let i = 0; i + 1 < chars.length; i += 2) m.set(chars[i]!, chars[i + 1]!);
return m;
})();
export function toSimplified(s: string): string {
let out = "";
for (const c of s) out += TRAD_TO_SIMP.get(c) ?? c;
return out;
}