Files
UHD_Media_Tool/packages/domain/src/naming/variables.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

227 lines
11 KiB
TypeScript

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;
}