- 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]>
272 lines
10 KiB
TypeScript
272 lines
10 KiB
TypeScript
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;
|
|
/** 宿主机上的根路径(由 settings/挂载映射解析) */
|
|
hostPath: string;
|
|
}
|
|
|
|
export interface PathBinding {
|
|
id: string;
|
|
entityId: string;
|
|
scope: "entity" | "season";
|
|
season?: number;
|
|
libraryRootId: string;
|
|
/** 相对库根的路径,POSIX 风格 */
|
|
relativePath: string;
|
|
templateVersion: number;
|
|
active?: boolean;
|
|
}
|
|
|
|
/** 生效的模板集:三条全路径模板 + 版本号(写进新建 path_binding.template_version) */
|
|
export interface NamingTemplateSet extends NamingTemplates {
|
|
version: number;
|
|
}
|
|
|
|
/** 全局默认:MP 默认模板,版本 2(1 = v1 旧模板,见 LEGACY_V1_NAMING) */
|
|
export const DEFAULT_TEMPLATES: NamingTemplateSet = { version: 2, ...MP_DEFAULT_NAMING };
|
|
|
|
/** 校验模板集合(语法、白名单变量);发布前调用。完整校验(文件名/作品根)用 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 {
|
|
scope: "entity" | "season";
|
|
season?: number;
|
|
libraryRootId: string;
|
|
relativePath: string;
|
|
templateVersion: number;
|
|
}
|
|
|
|
export type TargetDirectoryDecision =
|
|
| { kind: "existing"; libraryRootId: string; relativePath: string; bindingIds: string[] }
|
|
| {
|
|
kind: "new";
|
|
libraryRootId: string;
|
|
relativePath: string;
|
|
newBindings: NewBinding[];
|
|
reusedBindingIds: string[];
|
|
}
|
|
| { kind: "conflict"; reason: "multiple_entity_bindings" | "multiple_season_bindings"; bindings: PathBinding[] }
|
|
| {
|
|
kind: "error";
|
|
reason: "season_required" | "template_missing_vars" | "template_error";
|
|
missing?: string[];
|
|
message?: string;
|
|
};
|
|
|
|
export interface ResolveTargetInput {
|
|
entity: { id: string; kind: "movie" | "tv" };
|
|
/** 命名变量(buildNamingContext 的结果;首次绑定与子目录段才会用到) */
|
|
naming: NamingVars;
|
|
/** 命名类型;不传按 entity.kind(tv 且无集号有播出日期 = 综艺) */
|
|
namingKind?: NamingKind;
|
|
season?: number;
|
|
bindings: readonly PathBinding[];
|
|
/** 首次绑定时使用的库根 */
|
|
libraryRoot: LibraryRoot;
|
|
templates: NamingTemplateSet;
|
|
}
|
|
|
|
const joinRel = (...parts: string[]) => parts.filter((p) => p.length > 0).join("/");
|
|
|
|
function distinct(bs: PathBinding[]): PathBinding[] {
|
|
const seen = new Map<string, PathBinding>();
|
|
for (const b of bs) seen.set(`${b.libraryRootId}\u0000${b.relativePath}`, b);
|
|
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 {
|
|
const mine = input.bindings.filter((b) => b.entityId === input.entity.id && b.active !== false);
|
|
const entityBindings = mine.filter((b) => b.scope === "entity");
|
|
if (distinct(entityBindings).length > 1)
|
|
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" };
|
|
|
|
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: joinRel(entityBinding.relativePath, ...L.season, ...L.tail),
|
|
bindingIds: [entityBinding.id],
|
|
};
|
|
const miss = needRoot();
|
|
if (miss) return miss;
|
|
const rel = joinRel(...L.entity);
|
|
return {
|
|
kind: "new",
|
|
libraryRootId: input.libraryRoot.id,
|
|
relativePath: joinRel(rel, ...L.season, ...L.tail),
|
|
newBindings: [
|
|
{ scope: "entity", libraryRootId: input.libraryRoot.id, relativePath: rel, templateVersion: t.version },
|
|
],
|
|
reusedBindingIds: [],
|
|
};
|
|
}
|
|
|
|
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 };
|
|
const seasonBinding = seasonBindings[0];
|
|
if (seasonBinding)
|
|
return {
|
|
kind: "existing",
|
|
libraryRootId: seasonBinding.libraryRootId,
|
|
relativePath: joinRel(seasonBinding.relativePath, ...L.tail),
|
|
bindingIds: [seasonBinding.id],
|
|
};
|
|
if (entityBinding) {
|
|
const rel = joinRel(entityBinding.relativePath, ...L.season);
|
|
return {
|
|
kind: "new",
|
|
libraryRootId: entityBinding.libraryRootId,
|
|
relativePath: joinRel(rel, ...L.tail),
|
|
newBindings: [
|
|
{
|
|
scope: "season",
|
|
season: input.season!,
|
|
libraryRootId: entityBinding.libraryRootId,
|
|
relativePath: rel,
|
|
templateVersion: t.version,
|
|
},
|
|
],
|
|
reusedBindingIds: [entityBinding.id],
|
|
};
|
|
}
|
|
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: joinRel(rel, ...L.tail),
|
|
newBindings: [
|
|
{ scope: "entity", libraryRootId: input.libraryRoot.id, relativePath: showRel, templateVersion: t.version },
|
|
{
|
|
scope: "season",
|
|
season: input.season!,
|
|
libraryRootId: input.libraryRoot.id,
|
|
relativePath: rel,
|
|
templateVersion: t.version,
|
|
},
|
|
],
|
|
reusedBindingIds: [],
|
|
};
|
|
}
|
|
|
|
export type FileNameResult = { ok: true; fileName: string } | { ok: false; missing: string[]; message?: string };
|
|
|
|
/**
|
|
* 渲染视频文件名 = 整条模板的最后一段(每次入库按当前模板;目录由路径绑定决定)。
|
|
* 剧集既无集号也无播出日期 → missing episode_or_air_date。
|
|
*/
|
|
export function renderMediaFileName(
|
|
kind: "movie" | "tv" | NamingKind,
|
|
templates: NamingTemplates,
|
|
ctx: NamingVars,
|
|
ext: string,
|
|
): FileNameResult {
|
|
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 };
|
|
}
|