- 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]>
203 lines
7.2 KiB
TypeScript
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" },
|
|
},
|
|
];
|