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,198 @@
|
||||
import { sanitizeFileName, sanitizeSegment } from "./sanitize";
|
||||
import { compileTemplate, type NamingContext, renderTemplate, type TemplateVar } from "./template";
|
||||
|
||||
export interface LibraryRoot {
|
||||
id: string;
|
||||
/** 宿主机上的根路径(由 settings/挂载映射解析) */
|
||||
hostPath: string;
|
||||
}
|
||||
|
||||
export interface PathBinding {
|
||||
id: string;
|
||||
entityId: string;
|
||||
scope: "entity" | "season";
|
||||
season?: number;
|
||||
libraryRootId: string;
|
||||
/** 相对库根的路径,POSIX 风格 */
|
||||
relativePath: string;
|
||||
templateVersion: number;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface NamingTemplateSet {
|
||||
version: number;
|
||||
movieDir: string;
|
||||
tvDir: string;
|
||||
seasonDir: string;
|
||||
movieFile: string;
|
||||
episodeFile: string;
|
||||
/** 综艺按日期命名 */
|
||||
dailyFile: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_TEMPLATES: NamingTemplateSet = {
|
||||
version: 1,
|
||||
movieDir: "{title}[[ ({year})]]",
|
||||
tvDir: "{title}[[ ({year})]]",
|
||||
seasonDir: "Season {season}",
|
||||
movieFile: "{title}[[ ({year})]][[ - {cut}]][[ - {part}]][[ - {resolution}]]",
|
||||
episodeFile: "{title} - {seasonEpisode}[[ - {part}]]",
|
||||
dailyFile: "{title} - {airDate:dash}[[ - 第{issue}期]]",
|
||||
};
|
||||
|
||||
/** 校验模板集合(白名单变量、括号成对);发布模板版本前调用 */
|
||||
export function validateTemplateSet(t: NamingTemplateSet): void {
|
||||
for (const k of ["movieDir", "tvDir", "seasonDir", "movieFile", "episodeFile", "dailyFile"] as const) {
|
||||
compileTemplate(t[k]);
|
||||
}
|
||||
}
|
||||
|
||||
export interface NewBinding {
|
||||
scope: "entity" | "season";
|
||||
season?: number;
|
||||
libraryRootId: string;
|
||||
relativePath: string;
|
||||
templateVersion: number;
|
||||
}
|
||||
|
||||
export type TargetDirectoryDecision =
|
||||
| { kind: "existing"; libraryRootId: string; relativePath: string; bindingIds: string[] }
|
||||
| {
|
||||
kind: "new";
|
||||
libraryRootId: string;
|
||||
relativePath: string;
|
||||
newBindings: NewBinding[];
|
||||
reusedBindingIds: string[];
|
||||
}
|
||||
| { kind: "conflict"; reason: "multiple_entity_bindings" | "multiple_season_bindings"; bindings: PathBinding[] }
|
||||
| { kind: "error"; reason: "season_required" | "template_missing_vars"; missing?: TemplateVar[] };
|
||||
|
||||
export interface ResolveTargetInput {
|
||||
entity: { id: string; kind: "movie" | "tv" };
|
||||
/** 命名上下文(首次绑定才会用到) */
|
||||
naming: NamingContext;
|
||||
season?: number;
|
||||
bindings: readonly PathBinding[];
|
||||
/** 首次绑定时使用的库根 */
|
||||
libraryRoot: LibraryRoot;
|
||||
templates: NamingTemplateSet;
|
||||
}
|
||||
|
||||
const joinRel = (...parts: string[]) => parts.filter((p) => p.length > 0).join("/");
|
||||
|
||||
function distinct(bs: PathBinding[]): PathBinding[] {
|
||||
const seen = new Map<string, PathBinding>();
|
||||
for (const b of bs) seen.set(`${b.libraryRootId}\u0000${b.relativePath}`, b);
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* 目标目录决策(REQ §7.1):已有绑定优先且不可变,不因译名/模板变化重新渲染;
|
||||
* 只有首次建立作品/季目录时才按当前模板渲染,并记录模板版本。
|
||||
* 同一作品存在多个不同的历史目录 → conflict,不自动合并。
|
||||
*/
|
||||
export function resolveTargetDirectory(input: ResolveTargetInput): TargetDirectoryDecision {
|
||||
const mine = input.bindings.filter((b) => b.entityId === input.entity.id && b.active !== false);
|
||||
const entityBindings = mine.filter((b) => b.scope === "entity");
|
||||
if (distinct(entityBindings).length > 1)
|
||||
return { kind: "conflict", reason: "multiple_entity_bindings", bindings: entityBindings };
|
||||
const entityBinding = entityBindings[0];
|
||||
const t = input.templates;
|
||||
|
||||
if (input.entity.kind === "movie") {
|
||||
if (entityBinding)
|
||||
return {
|
||||
kind: "existing",
|
||||
libraryRootId: entityBinding.libraryRootId,
|
||||
relativePath: entityBinding.relativePath,
|
||||
bindingIds: [entityBinding.id],
|
||||
};
|
||||
const dir = renderTemplate(t.movieDir, input.naming);
|
||||
if (!dir.ok) return { kind: "error", reason: "template_missing_vars", missing: dir.missing };
|
||||
const rel = sanitizeSegment(dir.value);
|
||||
return {
|
||||
kind: "new",
|
||||
libraryRootId: input.libraryRoot.id,
|
||||
relativePath: rel,
|
||||
newBindings: [
|
||||
{ scope: "entity", libraryRootId: input.libraryRoot.id, relativePath: rel, templateVersion: t.version },
|
||||
],
|
||||
reusedBindingIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (input.season === undefined) return { kind: "error", reason: "season_required" };
|
||||
const seasonBindings = mine.filter((b) => b.scope === "season" && b.season === input.season);
|
||||
if (distinct(seasonBindings).length > 1)
|
||||
return { kind: "conflict", reason: "multiple_season_bindings", bindings: seasonBindings };
|
||||
const seasonBinding = seasonBindings[0];
|
||||
if (seasonBinding)
|
||||
return {
|
||||
kind: "existing",
|
||||
libraryRootId: seasonBinding.libraryRootId,
|
||||
relativePath: seasonBinding.relativePath,
|
||||
bindingIds: [seasonBinding.id],
|
||||
};
|
||||
const seasonDir = renderTemplate(t.seasonDir, { ...input.naming, season: input.season });
|
||||
if (!seasonDir.ok) return { kind: "error", reason: "template_missing_vars", missing: seasonDir.missing };
|
||||
const seasonSeg = sanitizeSegment(seasonDir.value);
|
||||
if (entityBinding) {
|
||||
const rel = joinRel(entityBinding.relativePath, seasonSeg);
|
||||
return {
|
||||
kind: "new",
|
||||
libraryRootId: entityBinding.libraryRootId,
|
||||
relativePath: rel,
|
||||
newBindings: [
|
||||
{
|
||||
scope: "season",
|
||||
season: input.season,
|
||||
libraryRootId: entityBinding.libraryRootId,
|
||||
relativePath: rel,
|
||||
templateVersion: t.version,
|
||||
},
|
||||
],
|
||||
reusedBindingIds: [entityBinding.id],
|
||||
};
|
||||
}
|
||||
const showDir = renderTemplate(t.tvDir, input.naming);
|
||||
if (!showDir.ok) return { kind: "error", reason: "template_missing_vars", missing: showDir.missing };
|
||||
const showRel = sanitizeSegment(showDir.value);
|
||||
const rel = joinRel(showRel, seasonSeg);
|
||||
return {
|
||||
kind: "new",
|
||||
libraryRootId: input.libraryRoot.id,
|
||||
relativePath: rel,
|
||||
newBindings: [
|
||||
{ scope: "entity", libraryRootId: input.libraryRoot.id, relativePath: showRel, templateVersion: t.version },
|
||||
{
|
||||
scope: "season",
|
||||
season: input.season,
|
||||
libraryRootId: input.libraryRoot.id,
|
||||
relativePath: rel,
|
||||
templateVersion: t.version,
|
||||
},
|
||||
],
|
||||
reusedBindingIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
export type FileNameResult =
|
||||
| { ok: true; fileName: string }
|
||||
| { ok: false; missing: TemplateVar[] | ["episode_or_air_date"] };
|
||||
|
||||
/** 渲染视频文件名:电影用 movieFile;剧集有集号用 episodeFile;只有播出日期用 dailyFile */
|
||||
export function renderMediaFileName(
|
||||
kind: "movie" | "tv",
|
||||
templates: NamingTemplateSet,
|
||||
ctx: NamingContext,
|
||||
ext: string,
|
||||
): FileNameResult {
|
||||
let tpl: string;
|
||||
if (kind === "movie") tpl = templates.movieFile;
|
||||
else if (ctx.seasonEpisode !== undefined) tpl = templates.episodeFile;
|
||||
else if (ctx.airDate !== undefined) tpl = templates.dailyFile;
|
||||
else return { ok: false, missing: ["episode_or_air_date"] };
|
||||
const r = renderTemplate(tpl, ctx);
|
||||
if (!r.ok) return r;
|
||||
return { ok: true, fileName: sanitizeFileName(r.value, ext) };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export interface PlannedPath {
|
||||
src: string;
|
||||
dst: string;
|
||||
}
|
||||
|
||||
export interface ExistingPath {
|
||||
path: string;
|
||||
size?: number;
|
||||
sha256?: string;
|
||||
}
|
||||
|
||||
export interface PathConflict {
|
||||
dst: string;
|
||||
kind: "duplicate_in_plan" | "exists" | "case_collision";
|
||||
srcs: string[];
|
||||
existing?: ExistingPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 冲突检测:计划内多个源写同一目标、目标已存在、(大小写不敏感文件系统上)仅大小写不同。
|
||||
* 只输出结构,是否覆盖由整理策略与授权决定。
|
||||
*/
|
||||
export function detectPathConflicts(
|
||||
planned: readonly PlannedPath[],
|
||||
existing: readonly ExistingPath[],
|
||||
opts: { caseInsensitive?: boolean } = {},
|
||||
): PathConflict[] {
|
||||
const out: PathConflict[] = [];
|
||||
const byDst = new Map<string, string[]>();
|
||||
for (const p of planned) byDst.set(p.dst, [...(byDst.get(p.dst) ?? []), p.src]);
|
||||
for (const [dst, srcs] of byDst) if (srcs.length > 1) out.push({ dst, kind: "duplicate_in_plan", srcs });
|
||||
const exact = new Map(existing.map((e) => [e.path, e]));
|
||||
const folded = new Map(existing.map((e) => [e.path.toLowerCase(), e]));
|
||||
for (const [dst, srcs] of byDst) {
|
||||
const hit = exact.get(dst);
|
||||
if (hit) {
|
||||
out.push({ dst, kind: "exists", srcs, existing: hit });
|
||||
continue;
|
||||
}
|
||||
if (opts.caseInsensitive) {
|
||||
const f = folded.get(dst.toLowerCase());
|
||||
if (f) out.push({ dst, kind: "case_collision", srcs, existing: f });
|
||||
}
|
||||
}
|
||||
if (opts.caseInsensitive) {
|
||||
const seen = new Map<string, string>();
|
||||
for (const dst of byDst.keys()) {
|
||||
const k = dst.toLowerCase();
|
||||
const prev = seen.get(k);
|
||||
if (prev !== undefined && prev !== dst)
|
||||
out.push({ dst, kind: "case_collision", srcs: [...(byDst.get(prev) ?? []), ...(byDst.get(dst) ?? [])] });
|
||||
seen.set(k, dst);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export {
|
||||
DEFAULT_TEMPLATES,
|
||||
type FileNameResult,
|
||||
type LibraryRoot,
|
||||
type NamingTemplateSet,
|
||||
type NewBinding,
|
||||
type PathBinding,
|
||||
type ResolveTargetInput,
|
||||
renderMediaFileName,
|
||||
resolveTargetDirectory,
|
||||
type TargetDirectoryDecision,
|
||||
validateTemplateSet,
|
||||
} from "./binding";
|
||||
export { detectPathConflicts, type ExistingPath, type PathConflict, type PlannedPath } from "./conflicts";
|
||||
export { type MapResult, type PathMapping, toDownloaderPath, toHostPath, validatePathMappings } from "./mapping";
|
||||
export { sanitizeFileName, sanitizeSegment, utf8Length } from "./sanitize";
|
||||
export {
|
||||
buildNamingContext,
|
||||
type CompiledTemplate,
|
||||
compileTemplate,
|
||||
type NamingContext,
|
||||
type RenderResult,
|
||||
renderTemplate,
|
||||
TEMPLATE_VARS,
|
||||
TemplateError,
|
||||
type TemplateVar,
|
||||
} from "./template";
|
||||
@@ -0,0 +1,68 @@
|
||||
/** 下载器实例看到的路径 ↔ 本服务宿主看到的路径(前缀替换,支持多实例) */
|
||||
export interface PathMapping {
|
||||
instanceId: string;
|
||||
downloaderPath: string;
|
||||
hostPath: string;
|
||||
}
|
||||
|
||||
export type MapResult =
|
||||
| { ok: true; path: string; mapping: PathMapping }
|
||||
| { ok: false; reason: "no_mapping" | "ambiguous" };
|
||||
|
||||
const norm = (p: string) => {
|
||||
const s = p.replace(/\\/g, "/").replace(/\/{2,}/g, "/");
|
||||
return s.length > 1 ? s.replace(/\/+$/, "") : s;
|
||||
};
|
||||
|
||||
function mapPrefix(
|
||||
path: string,
|
||||
instanceId: string,
|
||||
mappings: readonly PathMapping[],
|
||||
from: "downloaderPath" | "hostPath",
|
||||
to: "downloaderPath" | "hostPath",
|
||||
): MapResult {
|
||||
const p = norm(path);
|
||||
let best: PathMapping[] = [];
|
||||
let bestLen = -1;
|
||||
for (const m of mappings) {
|
||||
if (m.instanceId !== instanceId) continue;
|
||||
const prefix = norm(m[from]);
|
||||
const hit = p === prefix || p.startsWith(prefix === "/" ? "/" : `${prefix}/`);
|
||||
if (!hit) continue;
|
||||
if (prefix.length > bestLen) {
|
||||
best = [m];
|
||||
bestLen = prefix.length;
|
||||
} else if (prefix.length === bestLen) best.push(m);
|
||||
}
|
||||
if (best.length === 0) return { ok: false, reason: "no_mapping" };
|
||||
const targets = new Set(best.map((m) => norm(m[to])));
|
||||
if (targets.size > 1) return { ok: false, reason: "ambiguous" };
|
||||
const m = best[0]!;
|
||||
const rest = p.slice(bestLen).replace(/^\//, "");
|
||||
const base = norm(m[to]);
|
||||
const out = rest ? (base === "/" ? `/${rest}` : `${base}/${rest}`) : base;
|
||||
return { ok: true, path: out, mapping: m };
|
||||
}
|
||||
|
||||
/** 最长前缀匹配、按路径段边界(/downloads 不匹配 /downloads2);找不到映射不猜测 */
|
||||
export function toHostPath(path: string, instanceId: string, mappings: readonly PathMapping[]): MapResult {
|
||||
return mapPrefix(path, instanceId, mappings, "downloaderPath", "hostPath");
|
||||
}
|
||||
|
||||
export function toDownloaderPath(path: string, instanceId: string, mappings: readonly PathMapping[]): MapResult {
|
||||
return mapPrefix(path, instanceId, mappings, "hostPath", "downloaderPath");
|
||||
}
|
||||
|
||||
/** 同一实例同一前缀指向不同目标 → 配置错误 */
|
||||
export function validatePathMappings(mappings: readonly PathMapping[]): string[] {
|
||||
const errors: string[] = [];
|
||||
const seen = new Map<string, string>();
|
||||
for (const m of mappings) {
|
||||
const k = `${m.instanceId}\u0000${norm(m.downloaderPath)}`;
|
||||
const prev = seen.get(k);
|
||||
if (prev !== undefined && prev !== norm(m.hostPath))
|
||||
errors.push(`实例 ${m.instanceId} 的 ${m.downloaderPath} 映射到多个宿主路径`);
|
||||
seen.set(k, norm(m.hostPath));
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
const INVALID: Record<string, string> = {
|
||||
"\\": "\",
|
||||
"/": "/",
|
||||
":": ":",
|
||||
"*": "*",
|
||||
"?": "?",
|
||||
'"': """,
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"|": "|",
|
||||
};
|
||||
const RESERVED = /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$/i;
|
||||
const enc = new TextEncoder();
|
||||
|
||||
export function utf8Length(s: string): number {
|
||||
return enc.encode(s).length;
|
||||
}
|
||||
|
||||
function truncateBytes(s: string, maxBytes: number): string {
|
||||
if (utf8Length(s) <= maxBytes) return s;
|
||||
let out = "";
|
||||
for (const c of s) {
|
||||
if (utf8Length(out + c) > maxBytes) break;
|
||||
out += c;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径段非法字符处理:`\/:*?"<>|` 转全角(对照 MP),去控制字符、首尾空白与点,
|
||||
* Windows 保留名加下划线,按 UTF-8 字节截断(默认 255),空串变 `_`。
|
||||
*/
|
||||
export function sanitizeSegment(input: string, maxBytes = 255): string {
|
||||
let s = "";
|
||||
for (const c of input.normalize("NFC")) {
|
||||
const code = c.codePointAt(0)!;
|
||||
if (code < 0x20 || code === 0x7f) continue;
|
||||
s += INVALID[c] ?? c;
|
||||
}
|
||||
s = s
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/^[.\s]+/, "")
|
||||
.replace(/[.\s]+$/, "");
|
||||
if (RESERVED.test(s)) s = `${s}_`;
|
||||
s = truncateBytes(s, maxBytes).replace(/[.\s]+$/, "");
|
||||
return s.length > 0 ? s : "_";
|
||||
}
|
||||
|
||||
/** 文件名:只处理主干,扩展名保持;总长不超过 maxBytes */
|
||||
export function sanitizeFileName(stem: string, ext: string, maxBytes = 255): string {
|
||||
const e = ext ? `.${ext.replace(/^\.+/, "").toLowerCase()}` : "";
|
||||
return sanitizeSegment(stem, maxBytes - utf8Length(e)) + e;
|
||||
}
|
||||
@@ -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