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:
1 parent
a6d049db81
commit
7ba712ea55
40 files changed
+5885
-2
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user