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

+170
View File
@@ -0,0 +1,170 @@
import { confidenceOf, parseRelease } from "./parse";
import type { Confidence, PackHint, ParsedRelease, ParseOptions } from "./types";
export interface PathSegments {
file: string;
parent?: string;
grandParent?: string;
}
const EXTRAS_DIR =
/^(?:featurettes?|extras?|bonus(?:es)?|behind[ ._-]the[ ._-]scenes|deleted[ ._-]scenes|interviews?|trailers?|shorts|scenes|花絮|特典|特辑|特輯|幕后|幕後)$/iu;
const SAMPLE_DIR = /^samples?$/i;
/** 把路径拆成 文件 / 父目录 / 祖父目录;附加内容目录会被跳过并记为 extra */
export function splitReleasePath(path: string): PathSegments & { extrasDir?: string; sampleDir?: boolean } {
const parts = path.split(/[\\/]+/).filter((p) => p.length > 0);
const file = parts.pop() ?? "";
let parent = parts.pop();
let extrasDir: string | undefined;
let sampleDir = false;
if (parent && (EXTRAS_DIR.test(parent) || SAMPLE_DIR.test(parent))) {
if (SAMPLE_DIR.test(parent)) sampleDir = true;
else extrasDir = parent;
parent = parts.pop();
}
const grandParent = parts.pop();
return {
file,
...(parent !== undefined ? { parent } : {}),
...(grandParent !== undefined ? { grandParent } : {}),
...(extrasDir ? { extrasDir } : {}),
...(sampleDir ? { sampleDir } : {}),
};
}
/** 合并后的置信度:由证据推导,多次合并不会把降级"洗掉" */
function mergedConfidence(r: ParsedRelease): Confidence {
let conf = confidenceOf(r);
if (r.evidence.some((e) => /_dir:/.test(e))) conf = minConfidence(conf, "multi_evidence");
if (r.evidence.some((e) => e.endsWith(":type_inferred"))) conf = "needs_confirm";
if (r.mediaTypeHint !== "tv" && r.evidence.some((e) => e.endsWith(":conflict_year"))) conf = "needs_confirm";
return conf;
}
const RANK: Record<Confidence, number> = { rule_confirmed: 2, multi_evidence: 1, needs_confirm: 0 };
const minConfidence = (a: Confidence, b: Confidence): Confidence => (RANK[a] <= RANK[b] ? a : b);
function filePackHint(r: ParsedRelease): PackHint {
if (r.airDate !== undefined || r.issue !== undefined) return "single";
if (r.episodes) return r.episodes.end > r.episodes.start ? "multi_episode" : "single";
return r.mediaTypeHint === "tv" ? "unknown" : "single";
}
/**
* 文件优先、上级目录只补空字段(对照 MP MetaInfoPath.merge)。
* 偏离:文件本身无剧集信号而目录是剧集时,MP 不合并;本项目合并名称与季号,
* 打 `<level>:type_inferred` 证据并把置信度降为 needs_confirm。
*/
export function mergeParsed(
primary: ParsedRelease,
fallback: ParsedRelease,
level: "parent_dir" | "grandparent_dir",
): ParsedRelease {
const r: ParsedRelease = {
...primary,
audio: [...primary.audio],
hdr: [...primary.hdr],
variantTags: [...primary.variantTags],
evidence: [...primary.evidence],
};
const used: string[] = [];
const fbTv = fallback.mediaTypeHint === "tv";
const deviation = primary.mediaTypeHint !== "tv" && fbTv;
if (!primary.title) {
if (fallback.title) {
r.title = fallback.title;
r.titleCn = fallback.titleCn;
r.titleEn = fallback.titleEn;
used.push("title");
}
}
if (r.year === undefined && fallback.year !== undefined) {
r.year = fallback.year;
used.push("year");
}
if ((r.season === undefined || r.seasonInferred) && fallback.season !== undefined && !fallback.seasonInferred) {
r.season = fallback.season;
r.seasonInferred = undefined;
if (!r.isFile && fallback.seasonEnd !== undefined) r.seasonEnd = fallback.seasonEnd;
used.push("season");
}
const fill = <K extends keyof ParsedRelease>(k: K) => {
if (r[k] === undefined && fallback[k] !== undefined) {
r[k] = fallback[k];
used.push(String(k));
}
};
for (const k of [
"resolution",
"source",
"videoCodec",
"encoder",
"bitDepth",
"releaseGroup",
"cut",
"webSource",
"uhd",
"fps",
"subtitles",
] as const)
fill(k);
if (used.includes("source")) r.isEncode = fallback.isEncode;
if (r.audio.length === 0 && fallback.audio.length > 0) {
r.audio = [...fallback.audio];
used.push("audio");
}
if (r.hdr.length === 0 && fallback.hdr.length > 0) {
r.hdr = [...fallback.hdr];
used.push("hdr");
}
if (r.explicitIds.length === 0 && fallback.explicitIds.length > 0) {
r.explicitIds = [...fallback.explicitIds];
used.push("explicitIds");
}
if (r.contentType === "unknown" && fallback.contentType !== "unknown") {
r.contentType = fallback.contentType;
used.push("contentType");
}
if (fbTv) r.mediaTypeHint = "tv";
for (const u of used) r.evidence.push(`${level}:${u}`);
if (r.isFile) {
r.packHint = filePackHint(r);
r.isSeasonPack = false;
}
if (deviation && used.length > 0) r.evidence.push(`${level}:type_inferred`);
if (primary.year !== undefined && fallback.year !== undefined && primary.year !== fallback.year) {
// 电影合集年份冲突:以文件为准,但需要确认
r.evidence.push(`${level}:conflict_year`);
}
r.confidence = mergedConfidence(r);
return r;
}
/**
* 目录名 + 文件名合并识别。纯函数,不访问文件系统;最多看两级上级目录。
* 附加内容目录(Featurettes/Extras/花絮…)不参与命名,结果标记 contentType=extra。
*/
export function parseReleasePath(path: string | PathSegments, opts: ParseOptions = {}): ParsedRelease {
const seg = typeof path === "string" ? splitReleasePath(path) : path;
const { subtitle: _subtitle, ...dirOpts } = opts;
let r = parseRelease(seg.file, { ...opts, isFile: true });
if (seg.parent) r = mergeParsed(r, parseRelease(seg.parent, { ...dirOpts, isFile: false }), "parent_dir");
if (seg.grandParent)
r = mergeParsed(r, parseRelease(seg.grandParent, { ...dirOpts, isFile: false }), "grandparent_dir");
const extras = typeof path === "string" ? splitReleasePath(path) : undefined;
if (extras?.extrasDir && seg.parent) {
// 附加内容归属父作品:名称取父目录,文件自身名称不参与身份识别
const owner = parseRelease(seg.parent, { ...dirOpts, isFile: false });
r.title = owner.title;
r.titleCn = owner.titleCn;
r.titleEn = owner.titleEn;
r.contentType = "extra";
r.evidence.push("dir:extras");
r.confidence = "needs_confirm";
}
if (extras?.sampleDir) r.evidence.push("dir:sample");
return r;
}