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

+151
View File
@@ -0,0 +1,151 @@
import type { ParsedRelease } from "../release-parser/types";
import { sanitizeSegment } from "./sanitize";
/** 受限模板(白名单变量 + 可选片段),不用 Jinja/任意表达式 */
export const TEMPLATE_VARS = [
"title",
"originalTitle",
"year",
"season",
"episode",
"episodeEnd",
"seasonEpisode",
"airDate",
"issue",
"part",
"cut",
"resolution",
"source",
"videoCodec",
"hdr",
"releaseGroup",
] as const;
export type TemplateVar = (typeof TEMPLATE_VARS)[number];
export type NamingContext = Partial<Record<TemplateVar, string | number>>;
export class TemplateError extends Error {
override name = "TemplateError";
}
type Part = { kind: "text"; text: string } | { kind: "var"; name: TemplateVar; format?: string };
interface Piece {
optional: boolean;
parts: Part[];
}
export interface CompiledTemplate {
source: string;
pieces: Piece[];
vars: TemplateVar[];
}
function parseParts(src: string): Part[] {
const parts: Part[] = [];
const re = /\{([A-Za-z]+)(?::([A-Za-z0-9-]+))?\}/g;
let last = 0;
for (const m of src.matchAll(re)) {
if (m.index > last) parts.push({ kind: "text", text: src.slice(last, m.index) });
const name = m[1] as TemplateVar;
if (!TEMPLATE_VARS.includes(name)) throw new TemplateError(`未知模板变量: ${m[1]}`);
parts.push({ kind: "var", name, ...(m[2] ? { format: m[2] } : {}) });
last = m.index + m[0].length;
}
if (last < src.length) parts.push({ kind: "text", text: src.slice(last) });
for (const p of parts) if (p.kind === "text" && /[{}]/.test(p.text)) throw new TemplateError(`花括号不成对: ${src}`);
return parts;
}
export function compileTemplate(source: string): CompiledTemplate {
if (source.length > 512) throw new TemplateError("模板过长");
const pieces: Piece[] = [];
let i = 0;
while (i < source.length) {
const open = source.indexOf("[[", i);
const close = source.indexOf("]]", i);
if (close !== -1 && (open === -1 || close < open)) throw new TemplateError("可选片段 ]] 没有对应的 [[");
if (open === -1) {
pieces.push({ optional: false, parts: parseParts(source.slice(i)) });
break;
}
if (open > i) pieces.push({ optional: false, parts: parseParts(source.slice(i, open)) });
const end = source.indexOf("]]", open + 2);
if (end === -1) throw new TemplateError("可选片段缺少 ]]");
const inner = source.slice(open + 2, end);
if (inner.includes("[[")) throw new TemplateError("可选片段不能嵌套");
pieces.push({ optional: true, parts: parseParts(inner) });
i = end + 2;
}
const vars = [
...new Set(pieces.flatMap((p) => p.parts.flatMap((x) => (x.kind === "var" ? [x.name] : [])))),
] as TemplateVar[];
return { source, pieces, vars };
}
function formatValue(v: string | number, format?: string): string {
if (!format) return String(v);
const pad = /^0(\d)$/.exec(format);
if (pad && typeof v === "number") return String(v).padStart(Number(pad[1]), "0");
if (format === "dash" && typeof v === "string" && /^\d{8}$/.test(v))
return `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6)}`;
if (format === "dot" && typeof v === "string" && /^\d{8}$/.test(v))
return `${v.slice(0, 4)}.${v.slice(4, 6)}.${v.slice(6)}`;
return String(v);
}
const present = (v: unknown) => v !== undefined && v !== null && v !== "";
export type RenderResult = { ok: true; value: string } | { ok: false; missing: TemplateVar[] };
/** 渲染:必选部分缺变量 → missing;可选片段缺任一变量则整段省略。变量值内的路径分隔符等被转全角 */
export function renderTemplate(tpl: CompiledTemplate | string, ctx: NamingContext): RenderResult {
const c = typeof tpl === "string" ? compileTemplate(tpl) : tpl;
const missing: TemplateVar[] = [];
let out = "";
for (const piece of c.pieces) {
const lack = piece.parts.filter((p) => p.kind === "var" && !present(ctx[p.name])) as Extract<
Part,
{ kind: "var" }
>[];
if (lack.length > 0) {
if (!piece.optional) for (const l of lack) if (!missing.includes(l.name)) missing.push(l.name);
continue;
}
for (const p of piece.parts) {
out += p.kind === "text" ? p.text : sanitizeSegment(formatValue(ctx[p.name]!, p.format));
}
}
if (missing.length > 0) return { ok: false, missing };
return { ok: true, value: out.replace(/\s+/g, " ").trim() };
}
const pad2 = (n: number) => String(n).padStart(2, "0");
/** 由作品信息 + 解析结果构造命名上下文(标题用本地实体的绑定名,不用发布标题) */
export function buildNamingContext(
entity: { title: string; originalTitle?: string; year?: number },
parsed?: Partial<ParsedRelease>,
): NamingContext {
const ctx: NamingContext = { title: entity.title };
if (entity.originalTitle) ctx.originalTitle = entity.originalTitle;
if (entity.year !== undefined) ctx.year = entity.year;
if (!parsed) return ctx;
if (parsed.season !== undefined) ctx.season = parsed.season;
if (parsed.episodes) {
ctx.episode = parsed.episodes.start;
if (parsed.episodes.end > parsed.episodes.start) ctx.episodeEnd = parsed.episodes.end;
if (parsed.season !== undefined) {
const { start, end } = parsed.episodes;
// 多集文件保留区间(偏离 MP 的 >2 集截断)
ctx.seasonEpisode = `S${pad2(parsed.season)}E${pad2(start)}${end > start ? `-E${pad2(end)}` : ""}`;
}
}
if (parsed.airDate) ctx.airDate = parsed.airDate;
if (parsed.issue !== undefined) ctx.issue = parsed.issue;
if (parsed.part) ctx.part = parsed.part;
if (parsed.cut) ctx.cut = parsed.cut;
if (parsed.resolution) ctx.resolution = parsed.resolution;
if (parsed.source) ctx.source = parsed.source;
if (parsed.videoCodec) ctx.videoCodec = parsed.videoCodec;
if (parsed.hdr?.length) ctx.hdr = parsed.hdr.join(" ");
if (parsed.releaseGroup) ctx.releaseGroup = parsed.releaseGroup.name;
return ctx;
}