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

+1
View File
@@ -4,6 +4,7 @@ export const PACKAGE_NAME = "@umt/domain";
export * from "./acquisition/index.ts";
export * from "./download-state/index.ts";
export * from "./identity/index.ts";
export * from "./naming/index.ts";
export * from "./organize/index.ts";
export * from "./path/index.ts";
export * from "./release-parser/index.ts";
+202
View File
@@ -0,0 +1,202 @@
import type { NamingEntityInput, NamingKind } from "./variables.ts";
// 默认模板(与 MP 一致,综艺为我们特有)、v1 旧语法迁移、三级覆盖回落、预览内置样本。
export interface NamingTemplates {
movie: string;
tv: string;
variety: string;
}
export type NamingOverride = Partial<NamingTemplates>;
export type NamingSource = "entity" | "root" | "global";
const TITLE_YEAR = "{{title}}{% if year %} ({{year}}){% endif %}";
/** MP 默认(app/runtime/config.py MOVIE_RENAME_FORMAT / TV_RENAME_FORMAT 原样)+ 综艺(我们特有:无集号按播出日期) */
export const MP_DEFAULT_NAMING: Readonly<NamingTemplates> = {
movie: `${TITLE_YEAR}/${TITLE_YEAR}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}{{fileExt}}`,
tv: `${TITLE_YEAR}/Season {{season}}/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}{{fileExt}}`,
variety: `${TITLE_YEAR}/Season {{season}}/{{title}} - {% if season_episode and episode %}{{season_episode}}{% else %}{{airDate | date('YYYY-MM-DD')}}{% endif %}{% if issue %} - 第 {{issue}} 期{% endif %}{{fileExt}}`,
};
/** 当前默认模板版本号(path_binding.template_version 的全局默认;1 = v1 旧模板) */
export const LEGACY_NAMING_VERSION = 1;
// ── v1 旧语法:{var}、{var:02}、{airDate:dash}、[[ 可选段 ]] ──
const V1_VAR_MAP: Record<string, string> = {
originalTitle: "original_title",
seasonEpisode: "season_episode",
resolution: "videoFormat",
source: "resourceType",
hdr: "effect",
episodeEnd: "episode",
};
const V1_FORMAT: Record<string, string> = {
dash: " | date('YYYY-MM-DD')",
dot: " | date('YYYY.MM.DD')",
};
function upgradeParts(src: string, vars: string[]): string {
return src.replace(/\{([A-Za-z]+)(?::([A-Za-z0-9-]+))?\}/g, (_m, name: string, fmt?: string) => {
const v = Object.hasOwn(V1_VAR_MAP, name) ? V1_VAR_MAP[name]! : name;
if (!vars.includes(v)) vars.push(v);
let f = "";
if (fmt) {
const pad = /^0(\d)$/.exec(fmt);
f = pad ? ` | pad(${pad[1]})` : Object.hasOwn(V1_FORMAT, fmt) ? V1_FORMAT[fmt]! : "";
}
return `{{${v}${f}}}`;
});
}
/** v1 单段模板 → 新语法:`[[ … ]]` 可选段改写为 `{% if 段内变量 %}…{% endif %}` */
export function upgradeLegacyTemplate(v1: string): string {
let out = "";
let i = 0;
while (i < v1.length) {
const open = v1.indexOf("[[", i);
if (open === -1) {
out += upgradeParts(v1.slice(i), []);
break;
}
out += upgradeParts(v1.slice(i, open), []);
const end = v1.indexOf("]]", open + 2);
if (end === -1) {
out += upgradeParts(v1.slice(open), []);
break;
}
const vars: string[] = [];
const inner = upgradeParts(v1.slice(open + 2, end), vars);
out += vars.length ? `{% if ${vars.join(" and ")} %}${inner}{% endif %}` : inner;
i = end + 2;
}
return out;
}
/** v1 六条子模板(目录/季目录/文件名分开)的形状 */
export interface LegacyNamingTemplateSet {
movieDir: string;
tvDir: string;
seasonDir: string;
movieFile: string;
episodeFile: string;
dailyFile: string;
}
export const LEGACY_V1_TEMPLATES: Readonly<LegacyNamingTemplateSet> = {
movieDir: "{title}[[ ({year})]]",
tvDir: "{title}[[ ({year})]]",
seasonDir: "Season {season}",
movieFile: "{title}[[ ({year})]][[ - {cut}]][[ - {part}]][[ - {resolution}]]",
episodeFile: "{title} - {seasonEpisode}[[ - {part}]]",
dailyFile: "{title} - {airDate:dash}[[ - 第{issue}期]]",
};
/** v1 六条 → 新的三条全路径模板;综艺 = 有集号用剧集文件名,否则按日期 */
export function upgradeLegacyTemplateSet(t: LegacyNamingTemplateSet): NamingTemplates {
const u = upgradeLegacyTemplate;
const tvPrefix = `${u(t.tvDir)}/${u(t.seasonDir)}/`;
return {
movie: `${u(t.movieDir)}/${u(t.movieFile)}{{fileExt}}`,
tv: `${tvPrefix}${u(t.episodeFile)}{{fileExt}}`,
variety: `${tvPrefix}{% if season_episode %}${u(t.episodeFile)}{% else %}${u(t.dailyFile)}{% endif %}{{fileExt}}`,
};
}
/** v1 旧模板(= LEGACY_NAMING_VERSION),锁定文件名模板版本时给已有 template_version=1 的作品用 */
export const LEGACY_V1_NAMING: Readonly<NamingTemplates> = upgradeLegacyTemplateSet(LEGACY_V1_TEMPLATES);
/** 模板集指纹的规范化输入(server 与迁移用 sha256 求指纹,相同内容 = 同一版本) */
export function namingCanonicalJson(t: NamingTemplates): string {
return JSON.stringify({ movie: t.movie, tv: t.tv, variety: t.variety });
}
// ── 覆盖层级:作品 > 库根 > 全局,按类型分别回落 ──
export function resolveNamingTemplates(layers: {
global: NamingTemplates;
root?: NamingOverride | null | undefined;
entity?: NamingOverride | null | undefined;
}): { templates: NamingTemplates; sources: Record<NamingKind, NamingSource> } {
const pick = (k: NamingKind): [string, NamingSource] => {
const e = layers.entity?.[k];
if (e) return [e, "entity"];
const r = layers.root?.[k];
if (r) return [r, "root"];
return [layers.global[k], "global"];
};
const [movie, sm] = pick("movie");
const [tv, st] = pick("tv");
const [variety, sv] = pick("variety");
return { templates: { movie, tv, variety }, sources: { movie: sm, tv: st, variety: sv } };
}
/** 作品类型 → 命名类型(series 及其它剧集类 = tv;collection/other 按电影) */
export function namingKindOf(entityKind: string): NamingKind {
if (entityKind === "variety") return "variety";
if (entityKind === "series" || entityKind === "tv") return "tv";
return "movie";
}
// ── 预览内置样本 ──
export interface NamingSample {
id: number;
label: string;
kind: NamingKind;
fileName: string;
entity: NamingEntityInput;
}
export const NAMING_SAMPLES: readonly NamingSample[] = [
{
id: 1,
label: "电影",
kind: "movie",
fileName: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR.mkv",
entity: { title: "沙丘2", originalTitle: "Dune: Part Two", enTitle: "Dune: Part Two", year: 2024, tmdbId: 693134 },
},
{
id: 2,
label: "电影分段",
kind: "movie",
fileName: "Inception.2010.CD1.1080p.BluRay.x264-CMCT.mkv",
entity: { title: "盗梦空间", enTitle: "Inception", year: 2010, tmdbId: 27205 },
},
{
id: 3,
label: "剧集单集",
kind: "tv",
fileName: "Breaking.Bad.S01E01.2160p.WEB-DL.DV.H265.DDP5.1-CMCT.mkv",
entity: {
title: "绝命毒师",
enTitle: "Breaking Bad",
year: 2008,
tmdbId: 1396,
seasons: { 1: { year: 2008, episodeTotal: 7 } },
episodes: { "1:1": { title: "Pilot", airDate: "2008-01-20" } },
},
},
{
id: 4,
label: "剧集多集",
kind: "tv",
fileName: "Breaking.Bad.S01E01-E03.1080p.BluRay.x264-CMCT.mkv",
entity: { title: "绝命毒师", enTitle: "Breaking Bad", year: 2008, tmdbId: 1396 },
},
{
id: 5,
label: "特别篇 S00",
kind: "tv",
fileName: "Breaking.Bad.S00E03.1080p.WEB-DL.H264-NTb.mkv",
entity: { title: "绝命毒师", enTitle: "Breaking Bad", year: 2008, tmdbId: 1396 },
},
{
id: 6,
label: "综艺按日期",
kind: "variety",
fileName: "快乐大本营.20260927.第12期.1080p.WEB-DL.H264.mp4",
entity: { title: "快乐大本营", kind: "variety" },
},
];
+704
View File
@@ -0,0 +1,704 @@
// 命名模板引擎:Jinja 写法的安全子集(docs/design/naming-template.md §2)。
// 自写 词法 → 语法 → AST → 渲染,纯函数;不执行任意代码:没有属性/下标访问、函数调用、循环、赋值。
// 支持:{{ expr }}、{% if/elif/else/endif %}、{# 注释 #}、`{%-`/`-%}` 去空白;
// expr = or/and/not、== != > < >= <=(整数比较)、括号、变量、'字符串'/"字符串"/整数、白名单过滤器链。
export type NamingScalar = string | number | boolean;
export type NamingValue = NamingScalar | readonly string[] | null | undefined;
export type NamingVars = Readonly<Record<string, NamingValue>>;
export interface NamingIssue {
code: string;
message: string;
/** 1 起 */
line: number;
/** 1 起 */
col: number;
}
export class NamingTemplateError extends Error {
override name = "NamingTemplateError";
constructor(readonly issues: NamingIssue[]) {
super(issues.map((i) => `${i.line}:${i.col} ${i.message}`).join("; "));
}
}
export const NAMING_LIMITS = { sourceChars: 1024, nodes: 500, ifDepth: 4, outputBytes: 4096 } as const;
// ── AST ──
export type Expr =
| { t: "var"; name: string; pos: number }
| { t: "str"; value: string }
| { t: "int"; value: number }
| { t: "not"; e: Expr }
| { t: "and" | "or"; a: Expr; b: Expr }
| { t: "cmp"; op: CmpOp; a: Expr; b: Expr }
| { t: "filter"; name: FilterName; args: (string | number)[]; e: Expr };
type CmpOp = "==" | "!=" | ">" | "<" | ">=" | "<=";
export type Node =
| { t: "text"; text: string }
| { t: "out"; e: Expr; pos: number }
| { t: "if"; branches: { cond: Expr; body: Node[] }[]; else?: Node[] };
export interface CompiledNaming {
source: string;
nodes: Node[];
/** 模板引用的全部变量(去重,按首次出现) */
vars: string[];
}
export interface CompileOptions {
/** 已知变量;不在其中的变量报 unknown_variable(带"是否指"建议)。不传则不检查 */
knownVars?: readonly string[];
}
// ── 过滤器 ──
export const NAMING_FILTERS = {
default: { args: [1, 2], doc: "值为空时用参数代替:default('未知')" },
pad: { args: [1, 1], doc: "数字左补零:pad(2) → 03;区间 1-3 → 01-03" },
pad2: { args: [0, 0], doc: "等同 pad(2)" },
upper: { args: [0, 0], doc: "转大写" },
lower: { args: [0, 0], doc: "转小写" },
trim: { args: [0, 0], doc: "去首尾空白" },
replace: { args: [2, 2], doc: "替换:replace('.', ' ')" },
truncate: { args: [1, 1], doc: "按字符截断:truncate(40)" },
date: { args: [1, 1], doc: "YYYYMMDD / YYYY-MM-DD 日期格式化:date('YYYY-MM-DD'|'YYYY.MM.DD'|'YYYYMMDD')" },
join: { args: [0, 1], doc: "列表值按分隔符连接(默认空格);标量原样" },
} as const;
export type FilterName = keyof typeof NAMING_FILTERS;
const DATE_FORMATS = ["YYYY-MM-DD", "YYYY.MM.DD", "YYYYMMDD"];
// ── 位置 ──
function lineCol(src: string, pos: number): { line: number; col: number } {
let line = 1;
let col = 1;
for (let i = 0; i < pos && i < src.length; i++) {
if (src[i] === "\n") {
line++;
col = 1;
} else col++;
}
return { line, col };
}
class Fail extends Error {
constructor(
readonly code: string,
message: string,
readonly pos: number,
) {
super(message);
}
}
// ── 顶层切分:文本 / {{ }} / {% %} / {# #} ──
type Chunk =
| { k: "text"; text: string; pos: number }
| { k: "out" | "tag"; body: string; pos: number; bodyPos: number };
function chunk(src: string): Chunk[] {
const out: Chunk[] = [];
let i = 0;
let text = "";
let textPos = 0;
let trimNext = false;
const flush = (trimEnd: boolean) => {
let t = text;
if (trimEnd) t = t.replace(/\s+$/, "");
if (t) out.push({ k: "text", text: t, pos: textPos });
text = "";
};
while (i < src.length) {
const two = src.slice(i, i + 2);
if (two === "{{" || two === "{%" || two === "{#") {
const close = two === "{{" ? "}}" : two === "{%" ? "%}" : "#}";
const end = src.indexOf(close, i + 2);
if (end === -1) throw new Fail("unclosed_tag", `${two} 没有对应的 ${close}`, i);
let bodyStart = i + 2;
let bodyEnd = end;
const lstrip = src[bodyStart] === "-";
const rstrip = src[bodyEnd - 1] === "-" && bodyEnd - 1 >= bodyStart + (lstrip ? 1 : 0);
if (lstrip) bodyStart++;
if (rstrip) bodyEnd--;
flush(lstrip);
if (two !== "{#") {
const body = src.slice(bodyStart, bodyEnd);
out.push({ k: two === "{{" ? "out" : "tag", body, pos: i, bodyPos: bodyStart });
}
i = end + 2;
trimNext = rstrip;
textPos = i;
continue;
}
if (two === "}}" || two === "%}" || two === "#}") throw new Fail("stray_close", `多余的 ${two}`, i);
if (trimNext && /\s/.test(src[i]!) && text === "") {
i++;
textPos = i;
continue;
}
trimNext = false;
text += src[i];
i++;
}
flush(false);
return out;
}
// ── 表达式词法 ──
type Tok =
| { k: "id"; v: string; pos: number }
| { k: "str"; v: string; pos: number }
| { k: "int"; v: number; pos: number }
| { k: "op"; v: string; pos: number }
| { k: "end"; pos: number };
const own = <T>(o: Readonly<Record<string, T>>, k: string): T | undefined => (Object.hasOwn(o, k) ? o[k] : undefined);
const KEYWORDS_UNSUPPORTED: Record<string, string> = {
for: "不支持 for 循环",
endfor: "不支持 for 循环",
set: "不支持 set 赋值",
macro: "不支持宏",
call: "不支持 call",
include: "不支持 include",
import: "不支持 import",
from: "不支持 import",
extends: "不支持模板继承",
block: "不支持 block",
raw: "不支持 raw",
filter: "不支持 filter 块",
with: "不支持 with",
in: "不支持 in 运算",
is: "不支持 is 测试",
};
function lex(body: string, base: number): Tok[] {
const toks: Tok[] = [];
let i = 0;
while (i < body.length) {
const c = body[i]!;
const pos = base + i;
if (/\s/.test(c)) {
i++;
continue;
}
if (/[A-Za-z_]/.test(c)) {
let j = i + 1;
while (j < body.length && /[A-Za-z0-9_]/.test(body[j]!)) j++;
toks.push({ k: "id", v: body.slice(i, j), pos });
i = j;
continue;
}
if (/[0-9]/.test(c)) {
let j = i + 1;
while (j < body.length && /[0-9]/.test(body[j]!)) j++;
if (body[j] === ".") throw new Fail("unsupported_number", "只支持整数字面量", base + j);
toks.push({ k: "int", v: Number(body.slice(i, j)), pos });
i = j;
continue;
}
if (c === "'" || c === '"') {
let j = i + 1;
let v = "";
while (j < body.length && body[j] !== c) {
if (body[j] === "\\" && j + 1 < body.length) {
v += body[j + 1];
j += 2;
continue;
}
v += body[j];
j++;
}
if (j >= body.length) throw new Fail("unclosed_string", "字符串缺少结束引号", pos);
toks.push({ k: "str", v, pos });
i = j + 1;
continue;
}
const two = body.slice(i, i + 2);
if (["==", "!=", ">=", "<="].includes(two)) {
toks.push({ k: "op", v: two, pos });
i += 2;
continue;
}
if ("|(),<>".includes(c)) {
toks.push({ k: "op", v: c, pos });
i++;
continue;
}
if (c === ".") throw new Fail("attribute_access", "不支持属性访问(a.b)", pos);
if (c === "[") throw new Fail("subscript", "不支持下标访问(a[0])", pos);
if (c === "~") throw new Fail("concat", "不支持 ~ 拼接,直接把两个 {{ }} 写在一起", pos);
if (c === "%") throw new Fail("arithmetic", "不支持 % 格式化/运算;补零用 {{ season | pad(2) }}", pos);
if ("+-*/".includes(c)) throw new Fail("arithmetic", "不支持算术运算", pos);
if (c === "=") throw new Fail("assignment", "不支持赋值;比较用 ==", pos);
throw new Fail("bad_char", `无法识别的字符 ${JSON.stringify(c)}`, pos);
}
toks.push({ k: "end", pos: base + body.length });
return toks;
}
// ── 表达式语法 ──
function didYouMean(name: string, known: readonly string[]): string | undefined {
const lower = name.toLowerCase();
const ci = known.find((k) => k.toLowerCase() === lower);
if (ci) return ci;
let best: { k: string; d: number } | undefined;
for (const k of known) {
const d = levenshtein(lower, k.toLowerCase());
if (d <= 2 && (!best || d < best.d)) best = { k, d };
}
return best?.k;
}
function levenshtein(a: string, b: string): number {
const dp = Array.from({ length: b.length + 1 }, (_, j) => j);
for (let i = 1; i <= a.length; i++) {
let prev = dp[0]!;
dp[0] = i;
for (let j = 1; j <= b.length; j++) {
const tmp = dp[j]!;
dp[j] = Math.min(dp[j]! + 1, dp[j - 1]! + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
prev = tmp;
}
}
return dp[b.length]!;
}
class ExprParser {
private i = 0;
constructor(
private readonly toks: Tok[],
private readonly opts: CompileOptions,
private readonly vars: string[],
) {}
private peek(): Tok {
return this.toks[this.i]!;
}
private next(): Tok {
return this.toks[this.i++]!;
}
private isOp(v: string): boolean {
const t = this.peek();
return t.k === "op" && t.v === v;
}
private isId(v: string): boolean {
const t = this.peek();
return t.k === "id" && t.v === v;
}
private expectOp(v: string) {
const t = this.next();
if (t.k !== "op" || t.v !== v) throw new Fail("syntax", `这里应为 ${v}`, t.pos);
}
parseAll(): Expr {
const e = this.parseOr();
const t = this.peek();
if (t.k !== "end") {
if (t.k === "id" && t.v === "if") throw new Fail("inline_if", "不支持行内 if 表达式,改用 {% if %}", t.pos);
if (t.k === "id" && own(KEYWORDS_UNSUPPORTED, t.v))
throw new Fail("unsupported", own(KEYWORDS_UNSUPPORTED, t.v)!, t.pos);
throw new Fail("syntax", "表达式多余内容", t.pos);
}
return e;
}
private parseOr(): Expr {
let a = this.parseAnd();
while (this.isId("or")) {
this.next();
a = { t: "or", a, b: this.parseAnd() };
}
return a;
}
private parseAnd(): Expr {
let a = this.parseNot();
while (this.isId("and")) {
this.next();
a = { t: "and", a, b: this.parseNot() };
}
return a;
}
private parseNot(): Expr {
if (this.isId("not")) {
this.next();
return { t: "not", e: this.parseNot() };
}
return this.parseCmp();
}
private parseCmp(): Expr {
const a = this.parsePostfix();
const t = this.peek();
if (t.k === "op" && ["==", "!=", ">", "<", ">=", "<="].includes(t.v)) {
this.next();
return { t: "cmp", op: t.v as CmpOp, a, b: this.parsePostfix() };
}
return a;
}
private parsePostfix(): Expr {
let e = this.parsePrimary();
while (this.isOp("|")) {
this.next();
const n = this.next();
if (n.k !== "id") throw new Fail("syntax", "| 后应为过滤器名", n.pos);
const spec = own(NAMING_FILTERS as Record<string, { args: readonly [number, number] }>, n.v);
if (!spec) {
const hint = didYouMean(n.v, Object.keys(NAMING_FILTERS));
throw new Fail(
"unknown_filter",
`不支持的过滤器 ${n.v}${hint ? `,是否指 ${hint}` : ""}(可用:${Object.keys(NAMING_FILTERS).join("/")})`,
n.pos,
);
}
const args: (string | number)[] = [];
if (this.isOp("(")) {
this.next();
while (!this.isOp(")")) {
const a = this.next();
if (a.k === "str" || a.k === "int") args.push(a.v);
else if (a.k === "id" && (a.v === "true" || a.v === "false" || a.v === "True" || a.v === "False"))
args.push(a.v.toLowerCase() === "true" ? 1 : 0);
else throw new Fail("filter_arg", "过滤器参数只能是字符串或整数字面量", a.pos);
if (this.isOp(",")) this.next();
else if (!this.isOp(")")) throw new Fail("syntax", "过滤器参数之间用逗号分隔", this.peek().pos);
}
this.expectOp(")");
}
if (args.length < spec.args[0] || args.length > spec.args[1])
throw new Fail("filter_arity", `过滤器 ${n.v} 参数个数不对`, n.pos);
const name = n.v as FilterName;
if ((name === "pad" || name === "truncate") && typeof args[0] !== "number")
throw new Fail("filter_arg", `${name} 的参数应为整数`, n.pos);
if (name === "pad" && ((args[0] as number) < 1 || (args[0] as number) > 6))
throw new Fail("filter_arg", "pad 的位数应在 1–6", n.pos);
if (name === "date" && !DATE_FORMATS.includes(String(args[0])))
throw new Fail("filter_arg", `date 只支持 ${DATE_FORMATS.join(" / ")}`, n.pos);
if (name === "replace" && (typeof args[0] !== "string" || args[0] === ""))
throw new Fail("filter_arg", "replace 的第一个参数应为非空字符串", n.pos);
e = { t: "filter", name, args, e };
}
return e;
}
private parsePrimary(): Expr {
const t = this.next();
if (t.k === "str") return { t: "str", value: t.v };
if (t.k === "int") return { t: "int", value: t.v };
if (t.k === "op" && t.v === "(") {
const e = this.parseOr();
this.expectOp(")");
return e;
}
if (t.k === "id") {
if (own(KEYWORDS_UNSUPPORTED, t.v)) throw new Fail("unsupported", own(KEYWORDS_UNSUPPORTED, t.v)!, t.pos);
if (["and", "or", "not", "if", "else", "elif", "endif"].includes(t.v))
throw new Fail("syntax", `此处不能是关键字 ${t.v}`, t.pos);
if (t.v === "true" || t.v === "True") return { t: "int", value: 1 };
if (t.v === "false" || t.v === "False" || t.v === "none" || t.v === "None") return { t: "str", value: "" };
if (this.isOp("(")) throw new Fail("call", `不支持函数调用 ${t.v}()`, t.pos);
const known = this.opts.knownVars;
if (known && !known.includes(t.v)) {
const hint = didYouMean(t.v, known);
throw new Fail("unknown_variable", `未知变量 ${t.v}${hint ? `,是否指 ${hint}` : ""}`, t.pos);
}
if (!this.vars.includes(t.v)) this.vars.push(t.v);
return { t: "var", name: t.v, pos: t.pos };
}
if (t.k === "end") throw new Fail("syntax", "表达式不完整", t.pos);
throw new Fail("syntax", `此处不能是 ${t.v}`, t.pos);
}
}
// ── 语句 ──
export function compileNaming(source: string, opts: CompileOptions = {}): CompiledNaming {
try {
return compileInner(source, opts);
} catch (e) {
if (e instanceof Fail) {
const { line, col } = lineCol(source, e.pos);
throw new NamingTemplateError([{ code: e.code, message: e.message, line, col }]);
}
throw e;
}
}
/** 编译并收集问题(不抛) */
export function tryCompileNaming(
source: string,
opts: CompileOptions = {},
): { ok: true; compiled: CompiledNaming } | { ok: false; issues: NamingIssue[] } {
try {
return { ok: true, compiled: compileNaming(source, opts) };
} catch (e) {
if (e instanceof NamingTemplateError) return { ok: false, issues: e.issues };
throw e;
}
}
function compileInner(source: string, opts: CompileOptions): CompiledNaming {
if (source.length > NAMING_LIMITS.sourceChars)
throw new Fail("too_long", `模板超过 ${NAMING_LIMITS.sourceChars} 字符`, NAMING_LIMITS.sourceChars);
const chunks = chunk(source);
const vars: string[] = [];
let count = 0;
type Frame = { node: Extract<Node, { t: "if" }>; body: Node[]; pos: number; sawElse: boolean };
const root: Node[] = [];
const stack: Frame[] = [];
const cur = () => (stack.length ? stack[stack.length - 1]!.body : root);
const add = (n: Node, pos: number) => {
if (++count > NAMING_LIMITS.nodes) throw new Fail("too_many_nodes", `模板节点超过 ${NAMING_LIMITS.nodes}`, pos);
cur().push(n);
};
const expr = (body: string, base: number) => new ExprParser(lex(body, base), opts, vars).parseAll();
for (const c of chunks) {
if (c.k === "text") {
// 同 MP:模板里的 \ 视为目录分隔
add({ t: "text", text: c.text.replace(/\\/g, "/") }, c.pos);
continue;
}
if (c.k === "out") {
if (!c.body.trim()) throw new Fail("empty_output", "{{ }} 里没有内容", c.pos);
add({ t: "out", e: expr(c.body, c.bodyPos), pos: c.pos }, c.pos);
continue;
}
const m = /^\s*([A-Za-z_]+)/.exec(c.body);
if (!m) throw new Fail("syntax", "{% %} 里缺少语句", c.pos);
const kw = m[1]!;
const rest = c.body.slice(m[0].length);
const restPos = c.bodyPos + m[0].length;
if (kw === "if") {
if (stack.length >= NAMING_LIMITS.ifDepth)
throw new Fail("too_deep", `if 嵌套不能超过 ${NAMING_LIMITS.ifDepth} 层`, c.pos);
if (!rest.trim()) throw new Fail("syntax", "if 缺少条件", c.pos);
const body: Node[] = [];
const node: Extract<Node, { t: "if" }> = { t: "if", branches: [{ cond: expr(rest, restPos), body }] };
add(node, c.pos);
stack.push({ node, body, pos: c.pos, sawElse: false });
} else if (kw === "elif") {
const f = stack[stack.length - 1];
if (!f) throw new Fail("stray_elif", "elif 没有对应的 if", c.pos);
if (f.sawElse) throw new Fail("syntax", "elif 不能在 else 之后", c.pos);
if (!rest.trim()) throw new Fail("syntax", "elif 缺少条件", c.pos);
const body: Node[] = [];
f.node.branches.push({ cond: expr(rest, restPos), body });
f.body = body;
} else if (kw === "else") {
const f = stack[stack.length - 1];
if (!f) throw new Fail("stray_else", "else 没有对应的 if", c.pos);
if (f.sawElse) throw new Fail("syntax", "重复的 else", c.pos);
if (rest.trim()) throw new Fail("syntax", "else 后不能带条件,用 elif", c.pos);
f.sawElse = true;
const body: Node[] = [];
f.node.else = body;
f.body = body;
} else if (kw === "endif") {
if (!stack.length) throw new Fail("stray_endif", "endif 没有对应的 if", c.pos);
if (rest.trim()) throw new Fail("syntax", "endif 后不能有内容", c.pos);
stack.pop();
} else if (own(KEYWORDS_UNSUPPORTED, kw)) {
throw new Fail("unsupported", own(KEYWORDS_UNSUPPORTED, kw)!, c.pos);
} else {
throw new Fail("unsupported", `不支持的语句 ${kw}`, c.pos);
}
}
if (stack.length) throw new Fail("unclosed_if", "if 缺少 endif", stack[stack.length - 1]!.pos);
return { source, nodes: root, vars };
}
// ── 渲染 ──
/** 渲染产出的片段:文本来自模板字面量(可含 `/`),值来自 {{ }}(调用方负责转义,不会产生目录) */
export type RenderPiece = { k: "text"; text: string } | { k: "value"; text: string; vars: string[] };
type Val = string | number | boolean | readonly string[] | undefined;
const isEmpty = (v: Val): boolean =>
v === undefined || v === "" || v === false || v === 0 || (Array.isArray(v) && v.length === 0);
const toStr = (v: Val): string => {
if (v === undefined) return "";
if (Array.isArray(v)) return v.join(" ");
if (v === true) return "True";
if (v === false) return "False";
return String(v);
};
function toNum(v: Val): number | undefined {
if (typeof v === "number") return v;
if (typeof v === "boolean") return v ? 1 : 0;
if (typeof v === "string" && /^-?\d+$/.test(v.trim())) return Number(v.trim());
return undefined;
}
function exprVars(e: Expr, out: string[] = []): string[] {
switch (e.t) {
case "var":
if (!out.includes(e.name)) out.push(e.name);
break;
case "not":
case "filter":
exprVars(e.e, out);
break;
case "and":
case "or":
case "cmp":
exprVars(e.a, out);
exprVars(e.b, out);
break;
}
return out;
}
function evalExpr(e: Expr, vars: NamingVars): Val {
switch (e.t) {
case "var": {
const v = own(vars, e.name);
return v === null ? undefined : (v as Val);
}
case "str":
return e.value;
case "int":
return e.value;
case "not":
return isEmpty(evalExpr(e.e, vars));
case "and": {
const a = evalExpr(e.a, vars);
return isEmpty(a) ? a : evalExpr(e.b, vars);
}
case "or": {
const a = evalExpr(e.a, vars);
return isEmpty(a) ? evalExpr(e.b, vars) : a;
}
case "cmp": {
const a = evalExpr(e.a, vars);
const b = evalExpr(e.b, vars);
if (e.op === "==" || e.op === "!=") {
const na = toNum(a);
const nb = toNum(b);
const eq = na !== undefined && nb !== undefined ? na === nb : toStr(a) === toStr(b);
return e.op === "==" ? eq : !eq;
}
const na = toNum(a);
const nb = toNum(b);
if (na === undefined || nb === undefined) return false;
return e.op === ">" ? na > nb : e.op === "<" ? na < nb : e.op === ">=" ? na >= nb : na <= nb;
}
case "filter":
return applyFilter(e.name, e.args, evalExpr(e.e, vars));
}
}
function applyFilter(name: FilterName, args: (string | number)[], v: Val): Val {
switch (name) {
case "default":
return isEmpty(v) && v !== 0 ? args[0] : v;
case "pad":
case "pad2": {
const n = name === "pad2" ? 2 : (args[0] as number);
if (v === undefined) return v;
return toStr(v).replace(/\d+/g, (d) => d.padStart(n, "0"));
}
case "upper":
return v === undefined ? v : toStr(v).toUpperCase();
case "lower":
return v === undefined ? v : toStr(v).toLowerCase();
case "trim":
return v === undefined ? v : toStr(v).trim();
case "replace":
return v === undefined ? v : toStr(v).split(String(args[0])).join(String(args[1]));
case "truncate":
return v === undefined ? v : [...toStr(v)].slice(0, args[0] as number).join("");
case "date": {
if (v === undefined) return v;
const s = toStr(v);
const m = /^(\d{4})-?(\d{2})-?(\d{2})$/.exec(s.trim());
if (!m) return s;
const fmt = String(args[0]);
const sep = fmt === "YYYY-MM-DD" ? "-" : fmt === "YYYY.MM.DD" ? "." : "";
return [m[1], m[2], m[3]].join(sep);
}
case "join":
return Array.isArray(v) ? v.join(args[0] === undefined ? " " : String(args[0])) : v;
}
}
/** 渲染为片段序列(不做路径处理);值原样(未转义),由调用方处理 */
export function renderNamingPieces(compiled: CompiledNaming, vars: NamingVars): RenderPiece[] {
const out: RenderPiece[] = [];
let bytes = 0;
const enc = new TextEncoder();
const push = (p: RenderPiece) => {
bytes += enc.encode(p.text).length;
if (bytes > NAMING_LIMITS.outputBytes)
throw new NamingTemplateError([
{ code: "output_too_long", message: `渲染结果超过 ${NAMING_LIMITS.outputBytes} 字节`, line: 1, col: 1 },
]);
out.push(p);
};
const walk = (nodes: Node[]) => {
for (const n of nodes) {
if (n.t === "text") push({ k: "text", text: n.text });
else if (n.t === "out") push({ k: "value", text: toStr(evalExpr(n.e, vars)), vars: exprVars(n.e) });
else {
const hit = n.branches.find((b) => !isEmpty(evalExpr(b.cond, vars)));
if (hit) walk(hit.body);
else if (n.else) walk(n.else);
}
}
};
walk(compiled.nodes);
return out;
}
/** 纯字符串渲染(不转义、不切段);调试与非路径用途 */
export function renderNamingString(tpl: CompiledNaming | string, vars: NamingVars, opts: CompileOptions = {}): string {
const c = typeof tpl === "string" ? compileNaming(tpl, opts) : tpl;
return renderNamingPieces(c, vars)
.map((p) => p.text)
.join("");
}
/** 最后一个顶层节点是否为 {{ fileExt }}(文件名段必须以扩展名结尾) */
export function endsWithFileExt(c: CompiledNaming): boolean {
const last = c.nodes[c.nodes.length - 1];
return last?.t === "out" && last.e.t === "var" && last.e.name === "fileExt";
}
/**
* 未被 {% if %} 保护、且可能为空的输出变量(用于校验提示:"year 可能为空,建议 {% if year %}")。
* guardedBy:外层 if/elif 条件里出现过的变量。
*/
export function unguardedOutputs(c: CompiledNaming): string[] {
const out: string[] = [];
const walk = (nodes: Node[], guard: Set<string>) => {
for (const n of nodes) {
if (n.t === "out") {
// `a or b`、带 default 的输出不提示
if (n.e.t === "var" && !guard.has(n.e.name) && !out.includes(n.e.name)) out.push(n.e.name);
if (n.e.t === "filter" && n.e.name !== "default") {
let inner: Expr = n.e;
while (inner.t === "filter" && inner.name !== "default") inner = inner.e;
if (inner.t === "var" && !guard.has(inner.name) && !out.includes(inner.name)) out.push(inner.name);
}
} else if (n.t === "if") {
for (const b of n.branches) walk(b.body, new Set([...guard, ...exprVars(b.cond)]));
if (n.else) walk(n.else, guard);
}
}
};
walk(c.nodes, new Set());
return out;
}
export function issueAt(source: string, pos: number, code: string, message: string): NamingIssue {
return { code, message, ...lineCol(source, pos) };
}
+59
View File
@@ -0,0 +1,59 @@
// 命名模板引擎(docs/design/naming-template.md):Jinja 安全子集 + MP 变量 + 段角色/清理 + 覆盖层级
export {
LEGACY_NAMING_VERSION,
LEGACY_V1_NAMING,
LEGACY_V1_TEMPLATES,
type LegacyNamingTemplateSet,
MP_DEFAULT_NAMING,
NAMING_SAMPLES,
type NamingOverride,
type NamingSample,
type NamingSource,
type NamingTemplates,
namingCanonicalJson,
namingKindOf,
resolveNamingTemplates,
upgradeLegacyTemplate,
upgradeLegacyTemplateSet,
} from "./defaults.ts";
export {
type CompiledNaming,
type CompileOptions,
compileNaming,
endsWithFileExt,
NAMING_FILTERS,
NAMING_LIMITS,
type NamingIssue,
NamingTemplateError,
type NamingValue,
type NamingVars,
renderNamingPieces,
renderNamingString,
tryCompileNaming,
unguardedOutputs,
} from "./engine.ts";
export {
type NamingPathResult,
type NamingSegment,
type NamingValidation,
PATH_MAX_BYTES,
type RenderPathOptions,
renderNamingPath,
SEGMENT_MAX_BYTES,
type SegmentRole,
validateNamingTemplate,
} from "./layout.ts";
export {
buildNamingVars,
exampleVars,
NAMING_VARIABLE_NAMES,
NAMING_VARIABLES,
type NamingEntityInput,
type NamingKind,
type NamingVarGroup,
type NamingVariableDef,
normalizeExt,
SEASON_VARS,
TITLE_VARS,
} from "./variables.ts";
+200
View File
@@ -0,0 +1,200 @@
import { cleanSegment, escapeNamingValue, utf8Length } from "../path/sanitize.ts";
import {
type CompiledNaming,
compileNaming,
endsWithFileExt,
type NamingIssue,
NamingTemplateError,
type NamingVars,
renderNamingPieces,
tryCompileNaming,
unguardedOutputs,
} from "./engine.ts";
import {
exampleVars,
NAMING_VARIABLE_NAMES,
NAMING_VARIABLES,
type NamingKind,
normalizeExt,
SEASON_VARS,
TITLE_VARS,
} from "./variables.ts";
// 整条模板 → 段列表(docs/design/naming-template.md §4、§5):
// 字面量 `/` 切段;{{ }} 的值先转义(值里的 `/` 变全角,不产生目录);
// 段角色:作品根 = 从后往前第一段含标题变量且不含季变量;其后含季变量的段 = 季目录;其余在根之后 = 子目录,
// 根之前 = 分类前缀;最后一段 = 文件名(必须以 {{fileExt}} 结尾)。空的分类/子目录段整段丢弃。
export type SegmentRole = "category" | "root" | "season" | "sub" | "file";
export interface NamingSegment {
text: string;
role: SegmentRole;
/** 该段里输出过的变量 */
vars: string[];
}
export interface NamingPathResult {
ok: boolean;
/** 清理后的段(空的分类/子目录段已丢弃;作品根/文件名为空时保留空串并报错) */
segments: NamingSegment[];
/** 相对库根路径(ok 时) */
relativePath: string;
errors: NamingIssue[];
/** 输出为空的变量 */
missing: string[];
}
export const PATH_MAX_BYTES = 4096;
export const SEGMENT_MAX_BYTES = 255;
const at = (code: string, message: string): NamingIssue => ({ code, message, line: 1, col: 1 });
interface RawSeg {
text: string;
vars: string[];
}
function splitPieces(c: CompiledNaming, vars: NamingVars, missing: string[]): RawSeg[] {
const segs: RawSeg[] = [{ text: "", vars: [] }];
for (const p of renderNamingPieces(c, vars)) {
const cur = segs[segs.length - 1]!;
if (p.k === "value") {
cur.text += escapeNamingValue(p.text);
for (const v of p.vars) if (!cur.vars.includes(v)) cur.vars.push(v);
if (p.text === "") for (const v of p.vars) if (!missing.includes(v)) missing.push(v);
continue;
}
const parts = p.text.split("/");
cur.text += parts[0];
for (const part of parts.slice(1)) segs.push({ text: part, vars: [] });
}
return segs.filter((s) => s.text.trim() !== "" || s.vars.length > 0);
}
/** 给段定角色;识别不出作品根返回 -1 */
function assignRoles(raw: RawSeg[]): { roles: SegmentRole[]; rootIdx: number } {
const n = raw.length;
const roles: SegmentRole[] = new Array(n).fill("sub");
if (n === 0) return { roles, rootIdx: -1 };
roles[n - 1] = "file";
let rootIdx = -1;
for (let i = n - 2; i >= 0; i--) {
const v = raw[i]!.vars;
if (v.some((x) => TITLE_VARS.includes(x)) && !v.some((x) => SEASON_VARS.includes(x))) {
rootIdx = i;
break;
}
}
if (rootIdx === -1) return { roles, rootIdx };
for (let i = 0; i < n - 1; i++) {
if (i < rootIdx) roles[i] = "category";
else if (i === rootIdx) roles[i] = "root";
else if (raw[i]!.vars.some((x) => SEASON_VARS.includes(x))) roles[i] = "season";
else roles[i] = "sub";
}
return { roles, rootIdx };
}
export interface RenderPathOptions {
/** dirs:只关心目录(文件名段为空不报错,如没有 fileExt 的目录决策) */
mode?: "full" | "dirs";
}
/** 渲染整条模板为相对路径;不抛,错误在 errors 里 */
export function renderNamingPath(
tpl: CompiledNaming | string,
vars: NamingVars,
opts: RenderPathOptions = {},
): NamingPathResult {
const missing: string[] = [];
const fail = (errors: NamingIssue[], segments: NamingSegment[] = []): NamingPathResult => ({
ok: false,
segments,
relativePath: "",
errors,
missing,
});
let c: CompiledNaming;
let raw: RawSeg[];
try {
c = typeof tpl === "string" ? compileNaming(tpl, { knownVars: NAMING_VARIABLE_NAMES }) : tpl;
raw = splitPieces(c, vars, missing);
} catch (e) {
if (e instanceof NamingTemplateError) return fail(e.issues);
throw e;
}
const { roles, rootIdx } = assignRoles(raw);
if (raw.length < 2 || rootIdx === -1)
return fail([
at(
"no_work_root",
"识别不出作品目录:文件名之前至少要有一层目录包含 title / title_year / en_title / original_title / name,且不含季变量",
),
]);
const errors: NamingIssue[] = [];
const segments: NamingSegment[] = [];
const ext = typeof vars.fileExt === "string" ? normalizeExt(vars.fileExt) : "";
raw.forEach((r, i) => {
const role = roles[i]!;
let text: string;
if (role === "file") {
const lower = r.text.toLowerCase();
if (ext && lower.endsWith(ext)) {
const stem = cleanSegment(r.text.slice(0, r.text.length - ext.length), SEGMENT_MAX_BYTES - utf8Length(ext));
text = stem ? stem + ext : "";
} else text = cleanSegment(r.text, SEGMENT_MAX_BYTES);
if (!text && opts.mode !== "dirs") errors.push(at("empty_segment", "文件名为空"));
} else {
text = cleanSegment(r.text, SEGMENT_MAX_BYTES);
if (!text && role === "root")
errors.push(at("empty_segment", `作品目录为空(${r.vars.join("、") || "无变量"} 没有值)`));
if (!text && role === "season") errors.push(at("empty_segment", "季目录为空"));
}
if (text || role === "root" || role === "file" || role === "season") segments.push({ text, role, vars: r.vars });
});
const relativePath = segments
.filter((s) => s.text)
.map((s) => s.text)
.join("/");
if (utf8Length(relativePath) > PATH_MAX_BYTES) errors.push(at("path_too_long", `路径超过 ${PATH_MAX_BYTES} 字节`));
if (errors.length) return fail(errors, segments);
return { ok: true, segments, relativePath, errors: [], missing };
}
export interface NamingValidation {
ok: boolean;
errors: NamingIssue[];
warnings: string[];
/** 用示例值渲染出的段与角色 */
roles: { segment: string; role: SegmentRole }[];
}
/** 保存前校验:语法 + 未知变量 + 文件名以 {{fileExt}} 结尾 + 能识别作品根;给出"可能为空"提示 */
export function validateNamingTemplate(source: string, kind: NamingKind): NamingValidation {
const r = tryCompileNaming(source, { knownVars: NAMING_VARIABLE_NAMES });
if (!r.ok) return { ok: false, errors: r.issues, warnings: [], roles: [] };
const errors: NamingIssue[] = [];
if (!endsWithFileExt(r.compiled)) {
const lines = source.split("\n");
errors.push({
code: "missing_file_ext",
message: "文件名缺少 {{fileExt}}:模板必须以 {{fileExt}} 结尾",
line: lines.length,
col: (lines[lines.length - 1]?.length ?? 0) + 1,
});
}
const sample = renderNamingPath(r.compiled, exampleVars(kind));
if (!sample.ok) errors.push(...sample.errors);
const always = new Set(NAMING_VARIABLES.filter((v) => v.always?.includes(kind)).map((v) => v.name));
if (kind === "tv") for (const v of ["episode", "season_episode"]) always.add(v);
const warnings = unguardedOutputs(r.compiled)
.filter((v) => !always.has(v))
.map((v) => `${v} 可能为空,建议写成 {% if ${v} %}…{{${v}}}…{% endif %}`);
return {
ok: errors.length === 0,
errors,
warnings,
roles: sample.segments.map((s) => ({ segment: s.text, role: s.role })),
};
}
+226
View File
@@ -0,0 +1,226 @@
import type { ParsedRelease } from "../release-parser/types.ts";
// 命名变量全表(docs/design/naming-template.md §3):名称照抄 MP(驼峰/下划线混用不改),ours = 我们特有。
export type NamingKind = "movie" | "tv" | "variety";
export type NamingVarGroup = "title" | "episode" | "tech" | "id" | "ours";
export interface NamingVariableDef {
name: string;
group: NamingVarGroup;
label: string;
example: string;
ours: boolean;
/** 这些类型下总有值(其余情况可能为空,模板里建议用 {% if %} 包住) */
always?: readonly NamingKind[];
}
const ALL: readonly NamingKind[] = ["movie", "tv", "variety"];
const SERIES: readonly NamingKind[] = ["tv", "variety"];
export const NAMING_VARIABLES: readonly NamingVariableDef[] = [
{ name: "title", group: "title", label: "作品标题(本地作品名)", example: "绝命毒师", ours: false, always: ALL },
{ name: "en_title", group: "title", label: "英文标题", example: "Breaking Bad", ours: false },
{ name: "original_title", group: "title", label: "原语种标题", example: "Breaking Bad", ours: false },
{ name: "name", group: "title", label: "文件名识别出的标题", example: "Breaking Bad", ours: false },
{ name: "year", group: "title", label: "年份(作品年份,缺失取文件名年份)", example: "2008", ours: false },
{
name: "title_year",
group: "title",
label: "标题 (年份);无年份只有标题",
example: "绝命毒师 (2008)",
ours: false,
always: ALL,
},
{ name: "type", group: "title", label: "类型:电影 / 电视剧 / 综艺", example: "电视剧", ours: false, always: ALL },
{
name: "category",
group: "title",
label: "二级分类(没有则为空,所在目录段整段省略)",
example: "欧美剧",
ours: false,
},
{ name: "season", group: "episode", label: "季号,不补零;剧集缺季号为 1", example: "1", ours: false, always: SERIES },
{ name: "season_fmt", group: "episode", label: "S01;多季 S01-S02", example: "S01", ours: false, always: SERIES },
{ name: "season_year", group: "episode", label: "该季首播年份", example: "2008", ours: false },
{ name: "episode", group: "episode", label: "集号,不补零;多集 1-3", example: "1", ours: false },
{ name: "season_episode", group: "episode", label: "S01E01;多集 S01E01-E03", example: "S01E01", ours: false },
{ name: "total_episodes", group: "episode", label: "本季总集数,未知为 0", example: "7", ours: false, always: ALL },
{ name: "episode_title", group: "episode", label: "单集标题", example: "Pilot", ours: false },
{ name: "episode_date", group: "episode", label: "单集播出日期", example: "2008-01-20", ours: false },
{ name: "part", group: "episode", label: "分段(CD1、Part2)", example: "CD1", ours: false },
{ name: "videoFormat", group: "tech", label: "分辨率", example: "2160p", ours: false },
{ name: "resourceType", group: "tech", label: "来源", example: "WEB-DL", ours: false },
{ name: "effect", group: "tech", label: "特效(HDR 格式,空格分隔)", example: "DV", ours: false },
{ name: "edition", group: "tech", label: "来源 + 特效", example: "WEB-DL DV", ours: false },
{ name: "resource_term", group: "tech", label: "来源 + 特效 + 分辨率", example: "WEB-DL DV 2160p", ours: false },
{ name: "releaseGroup", group: "tech", label: "发布组", example: "CMCT", ours: false },
{ name: "videoCodec", group: "tech", label: "视频编码", example: "H265", ours: false },
{ name: "videoBit", group: "tech", label: "位深", example: "10bit", ours: false },
{ name: "audioCodec", group: "tech", label: "音频编码", example: "DDP 5.1", ours: false },
{ name: "webSource", group: "tech", label: "流媒体平台", example: "NF", ours: false },
{ name: "fps", group: "tech", label: "帧率", example: "60", ours: false },
{ name: "customization", group: "tech", label: "自定义占位符(暂恒为空)", example: "", ours: false },
{ name: "fileExt", group: "tech", label: "扩展名,带点,小写", example: ".mkv", ours: false, always: ALL },
{ name: "tmdbid", group: "id", label: "TMDB ID", example: "1396", ours: false },
{ name: "imdbid", group: "id", label: "IMDb ID", example: "tt0903747", ours: false },
{ name: "doubanid", group: "id", label: "豆瓣 ID", example: "1234567", ours: false },
{ name: "cut", group: "ours", label: "剪辑版本", example: "Director's Cut", ours: true },
{ name: "contentType", group: "ours", label: "内容类型(main / extra …)", example: "main", ours: true },
{ name: "segment", group: "ours", label: "综艺分段(upper / lower …)", example: "upper", ours: true },
{
name: "airDate",
group: "ours",
label: "综艺播出日期 YYYYMMDD,配合 date() 格式化",
example: "20260927",
ours: true,
},
{ name: "issue", group: "ours", label: "综艺期号", example: "12", ours: true },
{ name: "slotName", group: "ours", label: "版本目标名", example: "4K HDR", ours: true },
{ name: "libraryRoot", group: "ours", label: "库根名", example: "电视剧-A", ours: true },
];
export const NAMING_VARIABLE_NAMES: readonly string[] = NAMING_VARIABLES.map((v) => v.name);
/** 作品根识别用:含这些变量的目录段才可能是作品根(同 MP get_media_root_path) */
export const TITLE_VARS: readonly string[] = ["title", "title_year", "en_title", "original_title", "name"];
/** 季目录识别用 */
export const SEASON_VARS: readonly string[] = ["season", "season_fmt", "season_year", "season_episode", "episode"];
export interface NamingEntityInput {
title: string;
originalTitle?: string | undefined;
enTitle?: string | undefined;
year?: number | undefined;
/** 命名类型;不传时按解析结果推断(有季/集/播出日期 = tv) */
kind?: NamingKind | undefined;
category?: string | undefined;
tmdbId?: string | number | undefined;
imdbId?: string | undefined;
doubanId?: string | undefined;
slotName?: string | undefined;
libraryRoot?: string | undefined;
/** 季/集元数据(content_unit):季号 → 季首播年份/总集数;`季:集` → 单集标题/播出日期 */
seasons?: Readonly<Record<number, { year?: number | undefined; episodeTotal?: number | undefined }>> | undefined;
episodes?: Readonly<Record<string, { title?: string | undefined; airDate?: string | undefined }>> | undefined;
}
const pad2 = (n: number) => String(n).padStart(2, "0");
const KIND_LABEL: Record<NamingKind, string> = { movie: "电影", tv: "电视剧", variety: "综艺" };
/** 我们的 videoCodec 规范值 → MP 写法(H.265 → H265);有 x264/x265 编码器时用编码器名(同 MP 保留压制器) */
function codecOf(p: Partial<ParsedRelease>): string | undefined {
if (p.encoder) return p.encoder;
return p.videoCodec?.replace(".", "");
}
/** 来源(MP 写法):原盘/压制 → BluRay,Remux → BluRay REMUX,标题带 UHD 时前缀 UHD */
function sourceOf(p: Partial<ParsedRelease>): string | undefined {
if (!p.source) return undefined;
const uhd = p.uhd ? "UHD " : "";
if (p.source === "BluRay-Disc" || p.source === "BluRay") return `${uhd}BluRay`;
if (p.source === "REMUX") return `${uhd}BluRay REMUX`;
return p.source;
}
/**
* 渲染上下文(MP 变量名):作品信息 + 发布解析 + 季集元数据 + 版本目标/库根。
* 标题用本地作品名,不用发布标题。空值不放进上下文(同 MP 剔除 None)。
*/
export function buildNamingVars(
entity: NamingEntityInput,
parsed?: Partial<ParsedRelease>,
extra: { fileExt?: string | undefined } = {},
): Record<string, string | number> {
const v: Record<string, string | number> = {};
const set = (k: string, x: string | number | undefined | null) => {
if (x !== undefined && x !== null && x !== "") v[k] = x;
};
const p = parsed ?? {};
const hasEp = !!p.episodes || p.airDate !== undefined;
const kind: NamingKind = entity.kind ?? (p.season !== undefined || hasEp ? "tv" : "movie");
set("title", entity.title);
set("en_title", entity.enTitle);
set("original_title", entity.originalTitle);
set("name", p.title);
const year = entity.year ?? p.year;
set("year", year !== undefined ? String(year) : undefined);
v.title_year = year !== undefined ? `${entity.title} (${year})` : entity.title;
v.type = KIND_LABEL[kind];
set("category", entity.category);
set("tmdbid", entity.tmdbId !== undefined ? String(entity.tmdbId) : undefined);
set("imdbid", entity.imdbId);
set("doubanid", entity.doubanId);
let season: number | undefined;
if (kind !== "movie") {
season = p.season ?? 1;
v.season = String(season);
v.season_fmt =
p.seasonEnd !== undefined && p.seasonEnd > season ? `S${pad2(season)}-S${pad2(p.seasonEnd)}` : `S${pad2(season)}`;
const sm = entity.seasons?.[season];
set("season_year", sm?.year !== undefined ? String(sm.year) : undefined);
v.total_episodes = sm?.episodeTotal ?? p.totalEpisodes ?? 0;
if (p.episodes) {
const { start, end } = p.episodes;
// 多集保留区间(有意偏离 MP 的 ≥3 集截成首集)
v.episode = end > start ? `${start}-${end}` : String(start);
v.season_episode = `S${pad2(season)}E${pad2(start)}${end > start ? `-E${pad2(end)}` : ""}`;
const em = entity.episodes?.[`${season}:${start}`];
set("episode_title", em?.title);
set("episode_date", em?.airDate);
}
} else {
v.total_episodes = 0;
}
set("part", p.part);
set("videoFormat", p.resolution?.toLowerCase());
const src = sourceOf(p);
set("resourceType", src);
const effect = p.hdr?.length ? p.hdr.join(" ") : undefined;
set("effect", effect);
set("edition", [src, effect].filter(Boolean).join(" "));
set("resource_term", [src, effect, p.resolution?.toLowerCase()].filter(Boolean).join(" "));
set("releaseGroup", p.releaseGroup?.name);
set("videoCodec", codecOf(p));
set("videoBit", p.bitDepth !== undefined ? `${p.bitDepth}bit` : undefined);
set("audioCodec", p.audio?.[0]);
set("webSource", p.webSource);
set("fps", p.fps !== undefined ? String(p.fps) : undefined);
if (extra.fileExt !== undefined) set("fileExt", normalizeExt(extra.fileExt));
set("cut", p.cut);
if (p.contentType && p.contentType !== "unknown") set("contentType", p.contentType);
set("segment", p.segment);
set("airDate", p.airDate);
set("issue", p.issue !== undefined ? String(p.issue) : undefined);
set("slotName", entity.slotName);
set("libraryRoot", entity.libraryRoot);
return v;
}
/** 扩展名:带点、小写(有意偏离 MP 保留原大小写) */
export function normalizeExt(ext: string): string {
const e = ext.replace(/^\.+/, "").toLowerCase();
return e ? `.${e}` : "";
}
/** 变量示例上下文(预览/校验用,所有变量都有值) */
export function exampleVars(kind: NamingKind): Record<string, string | number> {
const v: Record<string, string | number> = {};
for (const d of NAMING_VARIABLES) if (d.example !== "") v[d.name] = d.example;
v.type = KIND_LABEL[kind];
if (kind === "movie") {
for (const k of [
"season",
"season_fmt",
"season_year",
"episode",
"season_episode",
"episode_title",
"episode_date",
])
delete v[k];
v.total_episodes = 0;
}
return v;
}
+3 -2
View File
@@ -1,3 +1,4 @@
import type { NamingEntityInput } from "../naming/variables.ts";
import { DEFAULT_TEMPLATES, type NamingTemplateSet, renderMediaFileName } from "../path/binding.ts";
import { buildNamingContext } from "../path/template.ts";
import { type ParseOptions, parseReleasePath } from "../release-parser/index.ts";
@@ -38,8 +39,8 @@ export interface OrganizeInput {
/** 种子内容根(整季包目录);只在它之内取父目录名参与解析 */
sourceRoot?: string;
files: readonly SourceFile[];
/** 本地实体的命名信息(不用发布标题命名) */
entity: { title: string; originalTitle?: string; year?: number };
/** 本地实体的命名信息(不用发布标题命名;可带英文名/外部 ID/季集元数据等命名变量来源) */
entity: NamingEntityInput;
/** 目标目录(宿主绝对路径,来自 path.resolveTargetDirectory + 库根) */
target: { movieDir?: string; seasonDirs?: Record<number, string>; device?: string | number };
templates?: NamingTemplateSet;
+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);
}