Files
UHD_Media_Tool/packages/domain/src/naming/defaults.ts
T
rootandClaude Opus 5.5 f7c75ad781 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]>
2026-09-28 15:24:32 +08:00

203 lines
7.2 KiB
TypeScript

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" },
},
];