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