feat(domain): naming template engine (Jinja safe subset, MP variables, path layout)

- naming/engine: lexer/parser/AST/render for {{ }} / if-elif-else / whitelisted filters
  (default pad pad2 upper lower trim replace truncate date join); rejects for/set/attribute/
  subscript/call/~/% with line:col errors and 'did you mean' hints
- naming/variables: variable table aligned with MoviePilot names plus ours (cut, airDate,
  issue, slotName, libraryRoot ...); buildNamingVars from parsed release + entity + units
- naming/layout: split by literal '/', segment roles (category/root/season/sub/file),
  value escaping, reserved names, 255-byte segments, validateNamingTemplate
- naming/defaults: MP default movie/tv + our variety template, v1 syntax upgrade,
  entity > root > global override resolution, builtin preview samples
- path/binding: resolveTargetDirectory keeps bound dirs, only unbound segments render;
  renderMediaFileName = last segment of the full template; DEFAULT_TEMPLATES = MP v2
- differential: 34 cases vs MP (21 identical, 13 documented deviations)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 15:24:32 +08:00
1 parent e8745115f4
commit f7c75ad781
22 files changed
+2674 -281

No files matched your search

+136 -63
View File
@@ -1,5 +1,7 @@
import { sanitizeFileName, sanitizeSegment } from "./sanitize.ts";
import { compileTemplate, type NamingContext, renderTemplate, type TemplateVar } from "./template.ts";
import { MP_DEFAULT_NAMING, type NamingTemplates } from "../naming/defaults.ts";
import { type CompiledNaming, compileNaming, type NamingVars } from "../naming/engine.ts";
import { type NamingSegment, renderNamingPath } from "../naming/layout.ts";
import { NAMING_VARIABLE_NAMES, type NamingKind, normalizeExt } from "../naming/variables.ts";
export interface LibraryRoot {
id: string;
@@ -19,32 +21,17 @@ export interface PathBinding {
active?: boolean;
}
export interface NamingTemplateSet {
/** 生效的模板集:三条全路径模板 + 版本号(写进新建 path_binding.template_version) */
export interface NamingTemplateSet extends NamingTemplates {
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}期]]",
};
/** 全局默认:MP 默认模板,版本 2(1 = v1 旧模板,见 LEGACY_V1_NAMING) */
export const DEFAULT_TEMPLATES: NamingTemplateSet = { version: 2, ...MP_DEFAULT_NAMING };
/** 校验模板集合(白名单变量、括号成对);发布模板版本前调用 */
export function validateTemplateSet(t: NamingTemplateSet): void {
for (const k of ["movieDir", "tvDir", "seasonDir", "movieFile", "episodeFile", "dailyFile"] as const) {
compileTemplate(t[k]);
}
/** 校验模板集合(语法、白名单变量);发布前调用。完整校验(文件名/作品根)用 validateNamingTemplate */
export function validateTemplateSet(t: NamingTemplates): void {
for (const k of ["movie", "tv", "variety"] as const) compileNaming(t[k], { knownVars: NAMING_VARIABLE_NAMES });
}
export interface NewBinding {
@@ -65,12 +52,19 @@ export type TargetDirectoryDecision =
reusedBindingIds: string[];
}
| { kind: "conflict"; reason: "multiple_entity_bindings" | "multiple_season_bindings"; bindings: PathBinding[] }
| { kind: "error"; reason: "season_required" | "template_missing_vars"; missing?: TemplateVar[] };
| {
kind: "error";
reason: "season_required" | "template_missing_vars" | "template_error";
missing?: string[];
message?: string;
};
export interface ResolveTargetInput {
entity: { id: string; kind: "movie" | "tv" };
/** 命名上下文(首次绑定才会用到) */
naming: NamingContext;
/** 命名变量(buildNamingContext 的结果;首次绑定与子目录段才会用到) */
naming: NamingVars;
/** 命名类型;不传按 entity.kind(tv 且无集号有播出日期 = 综艺) */
namingKind?: NamingKind;
season?: number;
bindings: readonly PathBinding[];
/** 首次绑定时使用的库根 */
@@ -86,9 +80,73 @@ function distinct(bs: PathBinding[]): PathBinding[] {
return [...seen.values()];
}
/** 选模板:电影 → movie;剧集有集号 → tv;剧集只有播出日期、或综艺作品 → variety */
export function pickNamingTemplate(kind: "movie" | "tv" | NamingKind, t: NamingTemplates, vars: NamingVars): string {
if (kind === "movie") return t.movie;
if (kind === "variety" || vars.type === "综艺") return t.variety;
if (!vars.episode && vars.airDate) return t.variety;
return t.tv;
}
const compiledCache = new Map<string, CompiledNaming>();
function compiled(src: string): CompiledNaming {
let c = compiledCache.get(src);
if (!c) {
c = compileNaming(src, { knownVars: NAMING_VARIABLE_NAMES });
if (compiledCache.size > 64) compiledCache.clear();
compiledCache.set(src, c);
}
return c;
}
interface DirLayout {
/** 分类前缀 + 作品根 */
entity: string[];
/** 作品根之后到最后一个季目录段(含) */
season: string[];
/** 其余目录段(绑定之后仍按模板渲染) */
tail: string[];
rootVars: string[];
rootEmpty: boolean;
}
/** 渲染目录部分并按角色切成 作品 / 季 / 其余 三截 */
function layoutDirs(
tpl: string,
vars: NamingVars,
): { ok: true; layout: DirLayout } | { ok: false; message: string; missing: string[] } {
let c: CompiledNaming;
try {
c = compiled(tpl);
} catch (e) {
return { ok: false, message: (e as Error).message, missing: [] };
}
const r = renderNamingPath(c, vars, { mode: "dirs" });
const fatal = r.errors.filter((e) => !(e.code === "empty_segment" && /作品目录/.test(e.message)));
if (fatal.length) return { ok: false, message: fatal.map((e) => e.message).join("; "), missing: r.missing };
const dirs: NamingSegment[] = r.segments.filter((s) => s.role !== "file");
const rootIdx = dirs.findIndex((s) => s.role === "root");
let lastSeason = rootIdx;
dirs.forEach((s, i) => {
if (s.role === "season" && i > rootIdx) lastSeason = i;
});
const root = dirs[rootIdx]!;
return {
ok: true,
layout: {
entity: dirs.slice(0, rootIdx + 1).map((s) => s.text),
season: dirs.slice(rootIdx + 1, lastSeason + 1).map((s) => s.text),
tail: dirs.slice(lastSeason + 1).map((s) => s.text),
rootVars: root.vars.filter((v) => !vars[v]),
rootEmpty: root.text === "",
},
};
}
/**
* 目标目录决策(REQ §7.1):已有绑定优先且不可变,不因译名/模板变化重新渲染;
* 只有首次建立作品/季目录时才按当前模板渲染,并记录模板版本。
* 只有首次建立作品/季目录时才按当前模板渲染作品根/季目录段,并记录模板版本。
* 绑定之后模板里剩余的目录段(如 {{edition}} 子目录)照常渲染拼在绑定路径后。
* 同一作品存在多个不同的历史目录 → conflict,不自动合并。
*/
export function resolveTargetDirectory(input: ResolveTargetInput): TargetDirectoryDecision {
@@ -98,22 +156,32 @@ export function resolveTargetDirectory(input: ResolveTargetInput): TargetDirecto
return { kind: "conflict", reason: "multiple_entity_bindings", bindings: entityBindings };
const entityBinding = entityBindings[0];
const t = input.templates;
const isMovie = input.entity.kind === "movie";
if (!isMovie && input.season === undefined) return { kind: "error", reason: "season_required" };
if (input.entity.kind === "movie") {
const vars: NamingVars = isMovie ? input.naming : { ...input.naming, season: String(input.season) };
const tpl = pickNamingTemplate(input.namingKind ?? input.entity.kind, t, vars);
const lay = layoutDirs(tpl, vars);
if (!lay.ok) return { kind: "error", reason: "template_error", message: lay.message, missing: lay.missing };
const L = lay.layout;
const needRoot = () =>
L.rootEmpty ? ({ kind: "error", reason: "template_missing_vars", missing: L.rootVars } as const) : undefined;
if (isMovie) {
if (entityBinding)
return {
kind: "existing",
libraryRootId: entityBinding.libraryRootId,
relativePath: entityBinding.relativePath,
relativePath: joinRel(entityBinding.relativePath, ...L.season, ...L.tail),
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);
const miss = needRoot();
if (miss) return miss;
const rel = joinRel(...L.entity);
return {
kind: "new",
libraryRootId: input.libraryRoot.id,
relativePath: rel,
relativePath: joinRel(rel, ...L.season, ...L.tail),
newBindings: [
{ scope: "entity", libraryRootId: input.libraryRoot.id, relativePath: rel, templateVersion: t.version },
],
@@ -121,7 +189,6 @@ export function resolveTargetDirectory(input: ResolveTargetInput): TargetDirecto
};
}
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 };
@@ -130,22 +197,19 @@ export function resolveTargetDirectory(input: ResolveTargetInput): TargetDirecto
return {
kind: "existing",
libraryRootId: seasonBinding.libraryRootId,
relativePath: seasonBinding.relativePath,
relativePath: joinRel(seasonBinding.relativePath, ...L.tail),
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);
const rel = joinRel(entityBinding.relativePath, ...L.season);
return {
kind: "new",
libraryRootId: entityBinding.libraryRootId,
relativePath: rel,
relativePath: joinRel(rel, ...L.tail),
newBindings: [
{
scope: "season",
season: input.season,
season: input.season!,
libraryRootId: entityBinding.libraryRootId,
relativePath: rel,
templateVersion: t.version,
@@ -154,19 +218,19 @@ export function resolveTargetDirectory(input: ResolveTargetInput): TargetDirecto
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);
const miss = needRoot();
if (miss) return miss;
const showRel = joinRel(...L.entity);
const rel = joinRel(showRel, ...L.season);
return {
kind: "new",
libraryRootId: input.libraryRoot.id,
relativePath: rel,
relativePath: joinRel(rel, ...L.tail),
newBindings: [
{ scope: "entity", libraryRootId: input.libraryRoot.id, relativePath: showRel, templateVersion: t.version },
{
scope: "season",
season: input.season,
season: input.season!,
libraryRootId: input.libraryRoot.id,
relativePath: rel,
templateVersion: t.version,
@@ -176,23 +240,32 @@ export function resolveTargetDirectory(input: ResolveTargetInput): TargetDirecto
};
}
export type FileNameResult =
| { ok: true; fileName: string }
| { ok: false; missing: TemplateVar[] | ["episode_or_air_date"] };
export type FileNameResult = { ok: true; fileName: string } | { ok: false; missing: string[]; message?: string };
/** 渲染视频文件名:电影用 movieFile;剧集有集号用 episodeFile;只有播出日期用 dailyFile */
/**
* 渲染视频文件名 = 整条模板的最后一段(每次入库按当前模板;目录由路径绑定决定)。
* 剧集既无集号也无播出日期 → missing episode_or_air_date。
*/
export function renderMediaFileName(
kind: "movie" | "tv",
templates: NamingTemplateSet,
ctx: NamingContext,
kind: "movie" | "tv" | NamingKind,
templates: NamingTemplates,
ctx: NamingVars,
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) };
if (!ctx.title) return { ok: false, missing: ["title"] };
if (kind !== "movie" && !ctx.episode && !ctx.airDate) return { ok: false, missing: ["episode_or_air_date"] };
const vars: NamingVars = { ...ctx, fileExt: normalizeExt(ext) };
let c: CompiledNaming;
try {
c = compiled(pickNamingTemplate(kind, templates, vars));
} catch (e) {
return { ok: false, missing: [], message: (e as Error).message };
}
const r = renderNamingPath(c, vars, { mode: "dirs" });
const file = r.segments.find((s) => s.role === "file");
if (!file?.text || file.text === normalizeExt(ext))
return { ok: false, missing: file?.vars.filter((v) => !vars[v]) ?? r.missing, message: "文件名为空" };
if (r.errors.some((e) => e.code !== "empty_segment"))
return { ok: false, missing: r.missing, message: r.errors.map((e) => e.message).join("; ") };
return { ok: true, fileName: file.text };
}
+3 -12
View File
@@ -5,6 +5,7 @@ export {
type NamingTemplateSet,
type NewBinding,
type PathBinding,
pickNamingTemplate,
type ResolveTargetInput,
renderMediaFileName,
resolveTargetDirectory,
@@ -13,15 +14,5 @@ export {
} from "./binding.ts";
export { detectPathConflicts, type ExistingPath, type PathConflict, type PlannedPath } from "./conflicts.ts";
export { type MapResult, type PathMapping, toDownloaderPath, toHostPath, validatePathMappings } from "./mapping.ts";
export { sanitizeFileName, sanitizeSegment, utf8Length } from "./sanitize.ts";
export {
buildNamingContext,
type CompiledTemplate,
compileTemplate,
type NamingContext,
type RenderResult,
renderTemplate,
TEMPLATE_VARS,
TemplateError,
type TemplateVar,
} from "./template.ts";
export { cleanSegment, escapeNamingValue, sanitizeFileName, sanitizeSegment, utf8Length } from "./sanitize.ts";
export { buildNamingContext, type NamingContext } from "./template.ts";
+20 -7
View File
@@ -26,24 +26,37 @@ function truncateBytes(s: string, maxBytes: number): string {
return out;
}
/**
* 路径段非法字符处理:`\/:*?"<>|` 转全角(对照 MP),去控制字符、首尾空白与点,
* Windows 保留名加下划线,按 UTF-8 字节截断(默认 255),空串变 `_`。
*/
export function sanitizeSegment(input: string, maxBytes = 255): string {
/** 变量值转义:`\/:*?"<>|` 转全角(对照 MP,但作用于所有变量),删控制字符;值里的 `/` 因此不会产生目录 */
export function escapeNamingValue(input: string): 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
return s;
}
/**
* 路径段清理:非法字符转全角、去控制字符、合并空白、去首尾空白与点,
* Windows 保留名加下划线,按 UTF-8 字节截断(默认 255)。结果可能为空串。
*/
export function cleanSegment(input: string, maxBytes = 255): string {
let s = escapeNamingValue(input)
.replace(/\s+/g, " ")
.trim()
.replace(/^[.\s]+/, "")
.replace(/[.\s]+$/, "");
if (RESERVED.test(s)) s = `${s}_`;
s = truncateBytes(s, maxBytes).replace(/[.\s]+$/, "");
return truncateBytes(s, maxBytes).replace(/[.\s]+$/, "");
}
/**
* 路径段非法字符处理:`\/:*?"<>|` 转全角(对照 MP),去控制字符、首尾空白与点,
* Windows 保留名加下划线,按 UTF-8 字节截断(默认 255),空串变 `_`。
*/
export function sanitizeSegment(input: string, maxBytes = 255): string {
const s = cleanSegment(input, maxBytes);
return s.length > 0 ? s : "_";
}
+8 -146
View File
@@ -1,151 +1,13 @@
import { buildNamingVars, type NamingEntityInput } from "../naming/variables.ts";
import type { ParsedRelease } from "../release-parser/types.ts";
import { sanitizeSegment } from "./sanitize.ts";
/** 受限模板(白名单变量 + 可选片段),不用 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>>;
// 命名上下文入口(兼容旧调用点):新引擎见 ../naming/**;v1 `{var}`/`[[ ]]` 语法已由
// naming/defaults.ts 的 upgradeLegacyTemplate 一次性迁移,不再单独解析。
export class TemplateError extends Error {
override name = "TemplateError";
}
/** 渲染上下文:MP 变量名 → 值(空值不出现) */
export type NamingContext = Record<string, string | number>;
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;
/** 由作品信息 + 解析结果构造命名上下文(标题用本地实体名,不用发布标题) */
export function buildNamingContext(entity: NamingEntityInput, parsed?: Partial<ParsedRelease>): NamingContext {
return buildNamingVars(entity, parsed);
}