- 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]>
227 lines
11 KiB
TypeScript
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;
|
|
}
|