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,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;
|
||||
}
|
||||
Reference in new issue
Block a user