From f7c75ad781a5d679e074c3903c6d01b988b600d6 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 15:24:32 +0800 Subject: [PATCH] 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 --- .../__tests__/organize-move.test.ts | 10 +- apps/server/test/e2e/e7-tv-series.test.ts | 4 +- apps/server/test/file-manager/w5a2.test.ts | 2 +- packages/domain/src/index.ts | 1 + packages/domain/src/naming/defaults.ts | 202 +++++ packages/domain/src/naming/engine.ts | 704 ++++++++++++++++++ packages/domain/src/naming/index.ts | 59 ++ packages/domain/src/naming/layout.ts | 200 +++++ packages/domain/src/naming/variables.ts | 226 ++++++ packages/domain/src/organize/plan.ts | 5 +- packages/domain/src/path/binding.ts | 199 +++-- packages/domain/src/path/index.ts | 15 +- packages/domain/src/path/sanitize.ts | 27 +- packages/domain/src/path/template.ts | 154 +--- .../domain/test/differential/gen-naming-mp.py | 80 ++ .../test/differential/naming-cases.json | 420 +++++++++++ .../domain/test/differential/naming-mp.json | 110 +++ .../domain/test/differential/naming.test.ts | 62 ++ packages/domain/test/naming.test.ts | 391 ++++++++++ packages/domain/test/organize.test.ts | 16 +- packages/domain/test/path.test.ts | 64 +- packages/mapping/test/convert.test.ts | 4 +- 22 files changed, 2674 insertions(+), 281 deletions(-) create mode 100644 packages/domain/src/naming/defaults.ts create mode 100644 packages/domain/src/naming/engine.ts create mode 100644 packages/domain/src/naming/index.ts create mode 100644 packages/domain/src/naming/layout.ts create mode 100644 packages/domain/src/naming/variables.ts create mode 100644 packages/domain/test/differential/gen-naming-mp.py create mode 100644 packages/domain/test/differential/naming-cases.json create mode 100644 packages/domain/test/differential/naming-mp.json create mode 100644 packages/domain/test/differential/naming.test.ts create mode 100644 packages/domain/test/naming.test.ts diff --git a/apps/server/src/modules/acquisition-organize/__tests__/organize-move.test.ts b/apps/server/src/modules/acquisition-organize/__tests__/organize-move.test.ts index 152f9e3c..ce41163b 100644 --- a/apps/server/src/modules/acquisition-organize/__tests__/organize-move.test.ts +++ b/apps/server/src/modules/acquisition-organize/__tests__/organize-move.test.ts @@ -191,9 +191,11 @@ describe.skipIf(!hasPg)("整理:跨卷 Move 恢复 / 路径绑定固定 / 手动 input: { recordId: r1.body.data.recordId }, }); await executeRecord(h.d, r1.body.data.recordId); - const ep1 = join(lib.root, "旧译名 (2020)", "Season 1", "旧译名 - S01E01.mkv"); + const ep1 = join(lib.root, "旧译名 (2020)", "Season 1", "旧译名 - S01E01 - 第 1 集.mkv"); expect(existsSync(ep1)).toBe(true); - expect(existsSync(join(lib.root, "旧译名 (2020)", "Season 1", "旧译名 - S01E01.chi.zh-cn.srt"))).toBe(true); + expect(existsSync(join(lib.root, "旧译名 (2020)", "Season 1", "旧译名 - S01E01 - 第 1 集.chi.zh-cn.srt"))).toBe( + true, + ); const rec1 = await h.api("GET", `/organization-records/${r1.body.data.recordId}`, undefined, READ_KEY); expect(rec1.body.data).toMatchObject({ origin: "manual", status: "succeeded", counts: { total: 2, succeeded: 2 } }); expect(rec1.body.data.actor.type).toBe("api_key"); @@ -207,7 +209,7 @@ describe.skipIf(!hasPg)("整理:跨卷 Move 恢复 / 路径绑定固定 / 手动 EXEC_KEY, ); await executeRecord(h.d, r2.body.data.recordId); - expect(existsSync(join(lib.root, "旧译名 (2020)", "Season 1", "新译名 - S01E02.mkv"))).toBe(true); + expect(existsSync(join(lib.root, "旧译名 (2020)", "Season 1", "新译名 - S01E02 - 第 2 集.mkv"))).toBe(true); expect(existsSync(join(lib.root, "新译名 (2020)"))).toBe(false); const r3 = await h.api( @@ -217,7 +219,7 @@ describe.skipIf(!hasPg)("整理:跨卷 Move 恢复 / 路径绑定固定 / 手动 EXEC_KEY, ); await executeRecord(h.d, r3.body.data.recordId); - expect(existsSync(join(lib.root, "旧译名 (2020)", "Season 2", "新译名 - S02E01.mkv"))).toBe(true); + expect(existsSync(join(lib.root, "旧译名 (2020)", "Season 2", "新译名 - S02E01 - 第 1 集.mkv"))).toBe(true); const bindings = await h.db .selectFrom("path_binding") diff --git a/apps/server/test/e2e/e7-tv-series.test.ts b/apps/server/test/e2e/e7-tv-series.test.ts index 4d8b0aae..13caea9c 100644 --- a/apps/server/test/e2e/e7-tv-series.test.ts +++ b/apps/server/test/e2e/e7-tv-series.test.ts @@ -206,8 +206,8 @@ describe.skipIf(!hasPg)("E7 常规剧集纵切", () => { expect(existsSync(join(e.lib.root, f.target_path!))).toBe(true); } expect(files.map((f) => f.target_path!)).toEqual([ - expect.stringMatching(/S01E01\.mkv$/), - expect.stringMatching(/S01E02\.mkv$/), + expect.stringMatching(/S01E01 - 第 1 集\.mkv$/), + expect.stringMatching(/S01E02 - 第 2 集\.mkv$/), ]); // 路径绑定固定:没有因为第二集新建任何绑定 const bindings1 = await e.db diff --git a/apps/server/test/file-manager/w5a2.test.ts b/apps/server/test/file-manager/w5a2.test.ts index c38adb1f..7393392e 100644 --- a/apps/server/test/file-manager/w5a2.test.ts +++ b/apps/server/test/file-manager/w5a2.test.ts @@ -242,7 +242,7 @@ describe.skipIf(skip)("files W5-A2", () => { const tv = groups.find((g: { mediaEntityId: string }) => g.mediaEntityId === series); const mv = groups.find((g: { mediaEntityId: string }) => g.mediaEntityId === film); const tvTargets = tv.files.map((f: { target: string | null }) => f.target); - expect(tvTargets.some((t: string | null) => t?.endsWith("Season 2/剧 - S02E03-E04.mkv"))).toBe(true); + expect(tvTargets.some((t: string | null) => t?.endsWith("Season 2/剧 - S02E03-E04 - 第 3-4 集.mkv"))).toBe(true); // 非连续集号不应用(只按季覆写,文件名无集号 → 计划排除),其余覆写不进 warnings expect(tv.warnings).toEqual([expect.stringMatching(/weird-b\.mkv: 集号 5,7 不连续/)]); expect(mv.warnings).toEqual([expect.stringMatching(/电影不适用季\/集覆写/)]); diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index be8912fe..24657bd2 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -4,6 +4,7 @@ export const PACKAGE_NAME = "@umt/domain"; export * from "./acquisition/index.ts"; export * from "./download-state/index.ts"; export * from "./identity/index.ts"; +export * from "./naming/index.ts"; export * from "./organize/index.ts"; export * from "./path/index.ts"; export * from "./release-parser/index.ts"; diff --git a/packages/domain/src/naming/defaults.ts b/packages/domain/src/naming/defaults.ts new file mode 100644 index 00000000..98361c78 --- /dev/null +++ b/packages/domain/src/naming/defaults.ts @@ -0,0 +1,202 @@ +import type { NamingEntityInput, NamingKind } from "./variables.ts"; + +// 默认模板(与 MP 一致,综艺为我们特有)、v1 旧语法迁移、三级覆盖回落、预览内置样本。 + +export interface NamingTemplates { + movie: string; + tv: string; + variety: string; +} +export type NamingOverride = Partial; +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 = { + 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 = { + originalTitle: "original_title", + seasonEpisode: "season_episode", + resolution: "videoFormat", + source: "resourceType", + hdr: "effect", + episodeEnd: "episode", +}; +const V1_FORMAT: Record = { + 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 = { + 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 = 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 } { + 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" }, + }, +]; diff --git a/packages/domain/src/naming/engine.ts b/packages/domain/src/naming/engine.ts new file mode 100644 index 00000000..20630a62 --- /dev/null +++ b/packages/domain/src/naming/engine.ts @@ -0,0 +1,704 @@ +// 命名模板引擎:Jinja 写法的安全子集(docs/design/naming-template.md §2)。 +// 自写 词法 → 语法 → AST → 渲染,纯函数;不执行任意代码:没有属性/下标访问、函数调用、循环、赋值。 +// 支持:{{ expr }}、{% if/elif/else/endif %}、{# 注释 #}、`{%-`/`-%}` 去空白; +// expr = or/and/not、== != > < >= <=(整数比较)、括号、变量、'字符串'/"字符串"/整数、白名单过滤器链。 + +export type NamingScalar = string | number | boolean; +export type NamingValue = NamingScalar | readonly string[] | null | undefined; +export type NamingVars = Readonly>; + +export interface NamingIssue { + code: string; + message: string; + /** 1 起 */ + line: number; + /** 1 起 */ + col: number; +} + +export class NamingTemplateError extends Error { + override name = "NamingTemplateError"; + constructor(readonly issues: NamingIssue[]) { + super(issues.map((i) => `${i.line}:${i.col} ${i.message}`).join("; ")); + } +} + +export const NAMING_LIMITS = { sourceChars: 1024, nodes: 500, ifDepth: 4, outputBytes: 4096 } as const; + +// ── AST ── + +export type Expr = + | { t: "var"; name: string; pos: number } + | { t: "str"; value: string } + | { t: "int"; value: number } + | { t: "not"; e: Expr } + | { t: "and" | "or"; a: Expr; b: Expr } + | { t: "cmp"; op: CmpOp; a: Expr; b: Expr } + | { t: "filter"; name: FilterName; args: (string | number)[]; e: Expr }; + +type CmpOp = "==" | "!=" | ">" | "<" | ">=" | "<="; + +export type Node = + | { t: "text"; text: string } + | { t: "out"; e: Expr; pos: number } + | { t: "if"; branches: { cond: Expr; body: Node[] }[]; else?: Node[] }; + +export interface CompiledNaming { + source: string; + nodes: Node[]; + /** 模板引用的全部变量(去重,按首次出现) */ + vars: string[]; +} + +export interface CompileOptions { + /** 已知变量;不在其中的变量报 unknown_variable(带"是否指"建议)。不传则不检查 */ + knownVars?: readonly string[]; +} + +// ── 过滤器 ── + +export const NAMING_FILTERS = { + default: { args: [1, 2], doc: "值为空时用参数代替:default('未知')" }, + pad: { args: [1, 1], doc: "数字左补零:pad(2) → 03;区间 1-3 → 01-03" }, + pad2: { args: [0, 0], doc: "等同 pad(2)" }, + upper: { args: [0, 0], doc: "转大写" }, + lower: { args: [0, 0], doc: "转小写" }, + trim: { args: [0, 0], doc: "去首尾空白" }, + replace: { args: [2, 2], doc: "替换:replace('.', ' ')" }, + truncate: { args: [1, 1], doc: "按字符截断:truncate(40)" }, + date: { args: [1, 1], doc: "YYYYMMDD / YYYY-MM-DD 日期格式化:date('YYYY-MM-DD'|'YYYY.MM.DD'|'YYYYMMDD')" }, + join: { args: [0, 1], doc: "列表值按分隔符连接(默认空格);标量原样" }, +} as const; +export type FilterName = keyof typeof NAMING_FILTERS; +const DATE_FORMATS = ["YYYY-MM-DD", "YYYY.MM.DD", "YYYYMMDD"]; + +// ── 位置 ── + +function lineCol(src: string, pos: number): { line: number; col: number } { + let line = 1; + let col = 1; + for (let i = 0; i < pos && i < src.length; i++) { + if (src[i] === "\n") { + line++; + col = 1; + } else col++; + } + return { line, col }; +} + +class Fail extends Error { + constructor( + readonly code: string, + message: string, + readonly pos: number, + ) { + super(message); + } +} + +// ── 顶层切分:文本 / {{ }} / {% %} / {# #} ── + +type Chunk = + | { k: "text"; text: string; pos: number } + | { k: "out" | "tag"; body: string; pos: number; bodyPos: number }; + +function chunk(src: string): Chunk[] { + const out: Chunk[] = []; + let i = 0; + let text = ""; + let textPos = 0; + let trimNext = false; + const flush = (trimEnd: boolean) => { + let t = text; + if (trimEnd) t = t.replace(/\s+$/, ""); + if (t) out.push({ k: "text", text: t, pos: textPos }); + text = ""; + }; + while (i < src.length) { + const two = src.slice(i, i + 2); + if (two === "{{" || two === "{%" || two === "{#") { + const close = two === "{{" ? "}}" : two === "{%" ? "%}" : "#}"; + const end = src.indexOf(close, i + 2); + if (end === -1) throw new Fail("unclosed_tag", `${two} 没有对应的 ${close}`, i); + let bodyStart = i + 2; + let bodyEnd = end; + const lstrip = src[bodyStart] === "-"; + const rstrip = src[bodyEnd - 1] === "-" && bodyEnd - 1 >= bodyStart + (lstrip ? 1 : 0); + if (lstrip) bodyStart++; + if (rstrip) bodyEnd--; + flush(lstrip); + if (two !== "{#") { + const body = src.slice(bodyStart, bodyEnd); + out.push({ k: two === "{{" ? "out" : "tag", body, pos: i, bodyPos: bodyStart }); + } + i = end + 2; + trimNext = rstrip; + textPos = i; + continue; + } + if (two === "}}" || two === "%}" || two === "#}") throw new Fail("stray_close", `多余的 ${two}`, i); + if (trimNext && /\s/.test(src[i]!) && text === "") { + i++; + textPos = i; + continue; + } + trimNext = false; + text += src[i]; + i++; + } + flush(false); + return out; +} + +// ── 表达式词法 ── + +type Tok = + | { k: "id"; v: string; pos: number } + | { k: "str"; v: string; pos: number } + | { k: "int"; v: number; pos: number } + | { k: "op"; v: string; pos: number } + | { k: "end"; pos: number }; + +const own = (o: Readonly>, k: string): T | undefined => (Object.hasOwn(o, k) ? o[k] : undefined); + +const KEYWORDS_UNSUPPORTED: Record = { + for: "不支持 for 循环", + endfor: "不支持 for 循环", + set: "不支持 set 赋值", + macro: "不支持宏", + call: "不支持 call", + include: "不支持 include", + import: "不支持 import", + from: "不支持 import", + extends: "不支持模板继承", + block: "不支持 block", + raw: "不支持 raw", + filter: "不支持 filter 块", + with: "不支持 with", + in: "不支持 in 运算", + is: "不支持 is 测试", +}; + +function lex(body: string, base: number): Tok[] { + const toks: Tok[] = []; + let i = 0; + while (i < body.length) { + const c = body[i]!; + const pos = base + i; + if (/\s/.test(c)) { + i++; + continue; + } + if (/[A-Za-z_]/.test(c)) { + let j = i + 1; + while (j < body.length && /[A-Za-z0-9_]/.test(body[j]!)) j++; + toks.push({ k: "id", v: body.slice(i, j), pos }); + i = j; + continue; + } + if (/[0-9]/.test(c)) { + let j = i + 1; + while (j < body.length && /[0-9]/.test(body[j]!)) j++; + if (body[j] === ".") throw new Fail("unsupported_number", "只支持整数字面量", base + j); + toks.push({ k: "int", v: Number(body.slice(i, j)), pos }); + i = j; + continue; + } + if (c === "'" || c === '"') { + let j = i + 1; + let v = ""; + while (j < body.length && body[j] !== c) { + if (body[j] === "\\" && j + 1 < body.length) { + v += body[j + 1]; + j += 2; + continue; + } + v += body[j]; + j++; + } + if (j >= body.length) throw new Fail("unclosed_string", "字符串缺少结束引号", pos); + toks.push({ k: "str", v, pos }); + i = j + 1; + continue; + } + const two = body.slice(i, i + 2); + if (["==", "!=", ">=", "<="].includes(two)) { + toks.push({ k: "op", v: two, pos }); + i += 2; + continue; + } + if ("|(),<>".includes(c)) { + toks.push({ k: "op", v: c, pos }); + i++; + continue; + } + if (c === ".") throw new Fail("attribute_access", "不支持属性访问(a.b)", pos); + if (c === "[") throw new Fail("subscript", "不支持下标访问(a[0])", pos); + if (c === "~") throw new Fail("concat", "不支持 ~ 拼接,直接把两个 {{ }} 写在一起", pos); + if (c === "%") throw new Fail("arithmetic", "不支持 % 格式化/运算;补零用 {{ season | pad(2) }}", pos); + if ("+-*/".includes(c)) throw new Fail("arithmetic", "不支持算术运算", pos); + if (c === "=") throw new Fail("assignment", "不支持赋值;比较用 ==", pos); + throw new Fail("bad_char", `无法识别的字符 ${JSON.stringify(c)}`, pos); + } + toks.push({ k: "end", pos: base + body.length }); + return toks; +} + +// ── 表达式语法 ── + +function didYouMean(name: string, known: readonly string[]): string | undefined { + const lower = name.toLowerCase(); + const ci = known.find((k) => k.toLowerCase() === lower); + if (ci) return ci; + let best: { k: string; d: number } | undefined; + for (const k of known) { + const d = levenshtein(lower, k.toLowerCase()); + if (d <= 2 && (!best || d < best.d)) best = { k, d }; + } + return best?.k; +} + +function levenshtein(a: string, b: string): number { + const dp = Array.from({ length: b.length + 1 }, (_, j) => j); + for (let i = 1; i <= a.length; i++) { + let prev = dp[0]!; + dp[0] = i; + for (let j = 1; j <= b.length; j++) { + const tmp = dp[j]!; + dp[j] = Math.min(dp[j]! + 1, dp[j - 1]! + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1)); + prev = tmp; + } + } + return dp[b.length]!; +} + +class ExprParser { + private i = 0; + constructor( + private readonly toks: Tok[], + private readonly opts: CompileOptions, + private readonly vars: string[], + ) {} + + private peek(): Tok { + return this.toks[this.i]!; + } + private next(): Tok { + return this.toks[this.i++]!; + } + private isOp(v: string): boolean { + const t = this.peek(); + return t.k === "op" && t.v === v; + } + private isId(v: string): boolean { + const t = this.peek(); + return t.k === "id" && t.v === v; + } + private expectOp(v: string) { + const t = this.next(); + if (t.k !== "op" || t.v !== v) throw new Fail("syntax", `这里应为 ${v}`, t.pos); + } + + parseAll(): Expr { + const e = this.parseOr(); + const t = this.peek(); + if (t.k !== "end") { + if (t.k === "id" && t.v === "if") throw new Fail("inline_if", "不支持行内 if 表达式,改用 {% if %}", t.pos); + if (t.k === "id" && own(KEYWORDS_UNSUPPORTED, t.v)) + throw new Fail("unsupported", own(KEYWORDS_UNSUPPORTED, t.v)!, t.pos); + throw new Fail("syntax", "表达式多余内容", t.pos); + } + return e; + } + + private parseOr(): Expr { + let a = this.parseAnd(); + while (this.isId("or")) { + this.next(); + a = { t: "or", a, b: this.parseAnd() }; + } + return a; + } + private parseAnd(): Expr { + let a = this.parseNot(); + while (this.isId("and")) { + this.next(); + a = { t: "and", a, b: this.parseNot() }; + } + return a; + } + private parseNot(): Expr { + if (this.isId("not")) { + this.next(); + return { t: "not", e: this.parseNot() }; + } + return this.parseCmp(); + } + private parseCmp(): Expr { + const a = this.parsePostfix(); + const t = this.peek(); + if (t.k === "op" && ["==", "!=", ">", "<", ">=", "<="].includes(t.v)) { + this.next(); + return { t: "cmp", op: t.v as CmpOp, a, b: this.parsePostfix() }; + } + return a; + } + private parsePostfix(): Expr { + let e = this.parsePrimary(); + while (this.isOp("|")) { + this.next(); + const n = this.next(); + if (n.k !== "id") throw new Fail("syntax", "| 后应为过滤器名", n.pos); + const spec = own(NAMING_FILTERS as Record, n.v); + if (!spec) { + const hint = didYouMean(n.v, Object.keys(NAMING_FILTERS)); + throw new Fail( + "unknown_filter", + `不支持的过滤器 ${n.v}${hint ? `,是否指 ${hint}` : ""}(可用:${Object.keys(NAMING_FILTERS).join("/")})`, + n.pos, + ); + } + const args: (string | number)[] = []; + if (this.isOp("(")) { + this.next(); + while (!this.isOp(")")) { + const a = this.next(); + if (a.k === "str" || a.k === "int") args.push(a.v); + else if (a.k === "id" && (a.v === "true" || a.v === "false" || a.v === "True" || a.v === "False")) + args.push(a.v.toLowerCase() === "true" ? 1 : 0); + else throw new Fail("filter_arg", "过滤器参数只能是字符串或整数字面量", a.pos); + if (this.isOp(",")) this.next(); + else if (!this.isOp(")")) throw new Fail("syntax", "过滤器参数之间用逗号分隔", this.peek().pos); + } + this.expectOp(")"); + } + if (args.length < spec.args[0] || args.length > spec.args[1]) + throw new Fail("filter_arity", `过滤器 ${n.v} 参数个数不对`, n.pos); + const name = n.v as FilterName; + if ((name === "pad" || name === "truncate") && typeof args[0] !== "number") + throw new Fail("filter_arg", `${name} 的参数应为整数`, n.pos); + if (name === "pad" && ((args[0] as number) < 1 || (args[0] as number) > 6)) + throw new Fail("filter_arg", "pad 的位数应在 1–6", n.pos); + if (name === "date" && !DATE_FORMATS.includes(String(args[0]))) + throw new Fail("filter_arg", `date 只支持 ${DATE_FORMATS.join(" / ")}`, n.pos); + if (name === "replace" && (typeof args[0] !== "string" || args[0] === "")) + throw new Fail("filter_arg", "replace 的第一个参数应为非空字符串", n.pos); + e = { t: "filter", name, args, e }; + } + return e; + } + private parsePrimary(): Expr { + const t = this.next(); + if (t.k === "str") return { t: "str", value: t.v }; + if (t.k === "int") return { t: "int", value: t.v }; + if (t.k === "op" && t.v === "(") { + const e = this.parseOr(); + this.expectOp(")"); + return e; + } + if (t.k === "id") { + if (own(KEYWORDS_UNSUPPORTED, t.v)) throw new Fail("unsupported", own(KEYWORDS_UNSUPPORTED, t.v)!, t.pos); + if (["and", "or", "not", "if", "else", "elif", "endif"].includes(t.v)) + throw new Fail("syntax", `此处不能是关键字 ${t.v}`, t.pos); + if (t.v === "true" || t.v === "True") return { t: "int", value: 1 }; + if (t.v === "false" || t.v === "False" || t.v === "none" || t.v === "None") return { t: "str", value: "" }; + if (this.isOp("(")) throw new Fail("call", `不支持函数调用 ${t.v}()`, t.pos); + const known = this.opts.knownVars; + if (known && !known.includes(t.v)) { + const hint = didYouMean(t.v, known); + throw new Fail("unknown_variable", `未知变量 ${t.v}${hint ? `,是否指 ${hint}` : ""}`, t.pos); + } + if (!this.vars.includes(t.v)) this.vars.push(t.v); + return { t: "var", name: t.v, pos: t.pos }; + } + if (t.k === "end") throw new Fail("syntax", "表达式不完整", t.pos); + throw new Fail("syntax", `此处不能是 ${t.v}`, t.pos); + } +} + +// ── 语句 ── + +export function compileNaming(source: string, opts: CompileOptions = {}): CompiledNaming { + try { + return compileInner(source, opts); + } catch (e) { + if (e instanceof Fail) { + const { line, col } = lineCol(source, e.pos); + throw new NamingTemplateError([{ code: e.code, message: e.message, line, col }]); + } + throw e; + } +} + +/** 编译并收集问题(不抛) */ +export function tryCompileNaming( + source: string, + opts: CompileOptions = {}, +): { ok: true; compiled: CompiledNaming } | { ok: false; issues: NamingIssue[] } { + try { + return { ok: true, compiled: compileNaming(source, opts) }; + } catch (e) { + if (e instanceof NamingTemplateError) return { ok: false, issues: e.issues }; + throw e; + } +} + +function compileInner(source: string, opts: CompileOptions): CompiledNaming { + if (source.length > NAMING_LIMITS.sourceChars) + throw new Fail("too_long", `模板超过 ${NAMING_LIMITS.sourceChars} 字符`, NAMING_LIMITS.sourceChars); + const chunks = chunk(source); + const vars: string[] = []; + let count = 0; + type Frame = { node: Extract; body: Node[]; pos: number; sawElse: boolean }; + const root: Node[] = []; + const stack: Frame[] = []; + const cur = () => (stack.length ? stack[stack.length - 1]!.body : root); + const add = (n: Node, pos: number) => { + if (++count > NAMING_LIMITS.nodes) throw new Fail("too_many_nodes", `模板节点超过 ${NAMING_LIMITS.nodes}`, pos); + cur().push(n); + }; + const expr = (body: string, base: number) => new ExprParser(lex(body, base), opts, vars).parseAll(); + + for (const c of chunks) { + if (c.k === "text") { + // 同 MP:模板里的 \ 视为目录分隔 + add({ t: "text", text: c.text.replace(/\\/g, "/") }, c.pos); + continue; + } + if (c.k === "out") { + if (!c.body.trim()) throw new Fail("empty_output", "{{ }} 里没有内容", c.pos); + add({ t: "out", e: expr(c.body, c.bodyPos), pos: c.pos }, c.pos); + continue; + } + const m = /^\s*([A-Za-z_]+)/.exec(c.body); + if (!m) throw new Fail("syntax", "{% %} 里缺少语句", c.pos); + const kw = m[1]!; + const rest = c.body.slice(m[0].length); + const restPos = c.bodyPos + m[0].length; + if (kw === "if") { + if (stack.length >= NAMING_LIMITS.ifDepth) + throw new Fail("too_deep", `if 嵌套不能超过 ${NAMING_LIMITS.ifDepth} 层`, c.pos); + if (!rest.trim()) throw new Fail("syntax", "if 缺少条件", c.pos); + const body: Node[] = []; + const node: Extract = { t: "if", branches: [{ cond: expr(rest, restPos), body }] }; + add(node, c.pos); + stack.push({ node, body, pos: c.pos, sawElse: false }); + } else if (kw === "elif") { + const f = stack[stack.length - 1]; + if (!f) throw new Fail("stray_elif", "elif 没有对应的 if", c.pos); + if (f.sawElse) throw new Fail("syntax", "elif 不能在 else 之后", c.pos); + if (!rest.trim()) throw new Fail("syntax", "elif 缺少条件", c.pos); + const body: Node[] = []; + f.node.branches.push({ cond: expr(rest, restPos), body }); + f.body = body; + } else if (kw === "else") { + const f = stack[stack.length - 1]; + if (!f) throw new Fail("stray_else", "else 没有对应的 if", c.pos); + if (f.sawElse) throw new Fail("syntax", "重复的 else", c.pos); + if (rest.trim()) throw new Fail("syntax", "else 后不能带条件,用 elif", c.pos); + f.sawElse = true; + const body: Node[] = []; + f.node.else = body; + f.body = body; + } else if (kw === "endif") { + if (!stack.length) throw new Fail("stray_endif", "endif 没有对应的 if", c.pos); + if (rest.trim()) throw new Fail("syntax", "endif 后不能有内容", c.pos); + stack.pop(); + } else if (own(KEYWORDS_UNSUPPORTED, kw)) { + throw new Fail("unsupported", own(KEYWORDS_UNSUPPORTED, kw)!, c.pos); + } else { + throw new Fail("unsupported", `不支持的语句 ${kw}`, c.pos); + } + } + if (stack.length) throw new Fail("unclosed_if", "if 缺少 endif", stack[stack.length - 1]!.pos); + return { source, nodes: root, vars }; +} + +// ── 渲染 ── + +/** 渲染产出的片段:文本来自模板字面量(可含 `/`),值来自 {{ }}(调用方负责转义,不会产生目录) */ +export type RenderPiece = { k: "text"; text: string } | { k: "value"; text: string; vars: string[] }; + +type Val = string | number | boolean | readonly string[] | undefined; + +const isEmpty = (v: Val): boolean => + v === undefined || v === "" || v === false || v === 0 || (Array.isArray(v) && v.length === 0); + +const toStr = (v: Val): string => { + if (v === undefined) return ""; + if (Array.isArray(v)) return v.join(" "); + if (v === true) return "True"; + if (v === false) return "False"; + return String(v); +}; + +function toNum(v: Val): number | undefined { + if (typeof v === "number") return v; + if (typeof v === "boolean") return v ? 1 : 0; + if (typeof v === "string" && /^-?\d+$/.test(v.trim())) return Number(v.trim()); + return undefined; +} + +function exprVars(e: Expr, out: string[] = []): string[] { + switch (e.t) { + case "var": + if (!out.includes(e.name)) out.push(e.name); + break; + case "not": + case "filter": + exprVars(e.e, out); + break; + case "and": + case "or": + case "cmp": + exprVars(e.a, out); + exprVars(e.b, out); + break; + } + return out; +} + +function evalExpr(e: Expr, vars: NamingVars): Val { + switch (e.t) { + case "var": { + const v = own(vars, e.name); + return v === null ? undefined : (v as Val); + } + case "str": + return e.value; + case "int": + return e.value; + case "not": + return isEmpty(evalExpr(e.e, vars)); + case "and": { + const a = evalExpr(e.a, vars); + return isEmpty(a) ? a : evalExpr(e.b, vars); + } + case "or": { + const a = evalExpr(e.a, vars); + return isEmpty(a) ? evalExpr(e.b, vars) : a; + } + case "cmp": { + const a = evalExpr(e.a, vars); + const b = evalExpr(e.b, vars); + if (e.op === "==" || e.op === "!=") { + const na = toNum(a); + const nb = toNum(b); + const eq = na !== undefined && nb !== undefined ? na === nb : toStr(a) === toStr(b); + return e.op === "==" ? eq : !eq; + } + const na = toNum(a); + const nb = toNum(b); + if (na === undefined || nb === undefined) return false; + return e.op === ">" ? na > nb : e.op === "<" ? na < nb : e.op === ">=" ? na >= nb : na <= nb; + } + case "filter": + return applyFilter(e.name, e.args, evalExpr(e.e, vars)); + } +} + +function applyFilter(name: FilterName, args: (string | number)[], v: Val): Val { + switch (name) { + case "default": + return isEmpty(v) && v !== 0 ? args[0] : v; + case "pad": + case "pad2": { + const n = name === "pad2" ? 2 : (args[0] as number); + if (v === undefined) return v; + return toStr(v).replace(/\d+/g, (d) => d.padStart(n, "0")); + } + case "upper": + return v === undefined ? v : toStr(v).toUpperCase(); + case "lower": + return v === undefined ? v : toStr(v).toLowerCase(); + case "trim": + return v === undefined ? v : toStr(v).trim(); + case "replace": + return v === undefined ? v : toStr(v).split(String(args[0])).join(String(args[1])); + case "truncate": + return v === undefined ? v : [...toStr(v)].slice(0, args[0] as number).join(""); + case "date": { + if (v === undefined) return v; + const s = toStr(v); + const m = /^(\d{4})-?(\d{2})-?(\d{2})$/.exec(s.trim()); + if (!m) return s; + const fmt = String(args[0]); + const sep = fmt === "YYYY-MM-DD" ? "-" : fmt === "YYYY.MM.DD" ? "." : ""; + return [m[1], m[2], m[3]].join(sep); + } + case "join": + return Array.isArray(v) ? v.join(args[0] === undefined ? " " : String(args[0])) : v; + } +} + +/** 渲染为片段序列(不做路径处理);值原样(未转义),由调用方处理 */ +export function renderNamingPieces(compiled: CompiledNaming, vars: NamingVars): RenderPiece[] { + const out: RenderPiece[] = []; + let bytes = 0; + const enc = new TextEncoder(); + const push = (p: RenderPiece) => { + bytes += enc.encode(p.text).length; + if (bytes > NAMING_LIMITS.outputBytes) + throw new NamingTemplateError([ + { code: "output_too_long", message: `渲染结果超过 ${NAMING_LIMITS.outputBytes} 字节`, line: 1, col: 1 }, + ]); + out.push(p); + }; + const walk = (nodes: Node[]) => { + for (const n of nodes) { + if (n.t === "text") push({ k: "text", text: n.text }); + else if (n.t === "out") push({ k: "value", text: toStr(evalExpr(n.e, vars)), vars: exprVars(n.e) }); + else { + const hit = n.branches.find((b) => !isEmpty(evalExpr(b.cond, vars))); + if (hit) walk(hit.body); + else if (n.else) walk(n.else); + } + } + }; + walk(compiled.nodes); + return out; +} + +/** 纯字符串渲染(不转义、不切段);调试与非路径用途 */ +export function renderNamingString(tpl: CompiledNaming | string, vars: NamingVars, opts: CompileOptions = {}): string { + const c = typeof tpl === "string" ? compileNaming(tpl, opts) : tpl; + return renderNamingPieces(c, vars) + .map((p) => p.text) + .join(""); +} + +/** 最后一个顶层节点是否为 {{ fileExt }}(文件名段必须以扩展名结尾) */ +export function endsWithFileExt(c: CompiledNaming): boolean { + const last = c.nodes[c.nodes.length - 1]; + return last?.t === "out" && last.e.t === "var" && last.e.name === "fileExt"; +} + +/** + * 未被 {% if %} 保护、且可能为空的输出变量(用于校验提示:"year 可能为空,建议 {% if year %}")。 + * guardedBy:外层 if/elif 条件里出现过的变量。 + */ +export function unguardedOutputs(c: CompiledNaming): string[] { + const out: string[] = []; + const walk = (nodes: Node[], guard: Set) => { + for (const n of nodes) { + if (n.t === "out") { + // `a or b`、带 default 的输出不提示 + if (n.e.t === "var" && !guard.has(n.e.name) && !out.includes(n.e.name)) out.push(n.e.name); + if (n.e.t === "filter" && n.e.name !== "default") { + let inner: Expr = n.e; + while (inner.t === "filter" && inner.name !== "default") inner = inner.e; + if (inner.t === "var" && !guard.has(inner.name) && !out.includes(inner.name)) out.push(inner.name); + } + } else if (n.t === "if") { + for (const b of n.branches) walk(b.body, new Set([...guard, ...exprVars(b.cond)])); + if (n.else) walk(n.else, guard); + } + } + }; + walk(c.nodes, new Set()); + return out; +} + +export function issueAt(source: string, pos: number, code: string, message: string): NamingIssue { + return { code, message, ...lineCol(source, pos) }; +} diff --git a/packages/domain/src/naming/index.ts b/packages/domain/src/naming/index.ts new file mode 100644 index 00000000..3a6cc76b --- /dev/null +++ b/packages/domain/src/naming/index.ts @@ -0,0 +1,59 @@ +// 命名模板引擎(docs/design/naming-template.md):Jinja 安全子集 + MP 变量 + 段角色/清理 + 覆盖层级 + +export { + LEGACY_NAMING_VERSION, + LEGACY_V1_NAMING, + LEGACY_V1_TEMPLATES, + type LegacyNamingTemplateSet, + MP_DEFAULT_NAMING, + NAMING_SAMPLES, + type NamingOverride, + type NamingSample, + type NamingSource, + type NamingTemplates, + namingCanonicalJson, + namingKindOf, + resolveNamingTemplates, + upgradeLegacyTemplate, + upgradeLegacyTemplateSet, +} from "./defaults.ts"; +export { + type CompiledNaming, + type CompileOptions, + compileNaming, + endsWithFileExt, + NAMING_FILTERS, + NAMING_LIMITS, + type NamingIssue, + NamingTemplateError, + type NamingValue, + type NamingVars, + renderNamingPieces, + renderNamingString, + tryCompileNaming, + unguardedOutputs, +} from "./engine.ts"; +export { + type NamingPathResult, + type NamingSegment, + type NamingValidation, + PATH_MAX_BYTES, + type RenderPathOptions, + renderNamingPath, + SEGMENT_MAX_BYTES, + type SegmentRole, + validateNamingTemplate, +} from "./layout.ts"; +export { + buildNamingVars, + exampleVars, + NAMING_VARIABLE_NAMES, + NAMING_VARIABLES, + type NamingEntityInput, + type NamingKind, + type NamingVarGroup, + type NamingVariableDef, + normalizeExt, + SEASON_VARS, + TITLE_VARS, +} from "./variables.ts"; diff --git a/packages/domain/src/naming/layout.ts b/packages/domain/src/naming/layout.ts new file mode 100644 index 00000000..e42e8ac4 --- /dev/null +++ b/packages/domain/src/naming/layout.ts @@ -0,0 +1,200 @@ +import { cleanSegment, escapeNamingValue, utf8Length } from "../path/sanitize.ts"; +import { + type CompiledNaming, + compileNaming, + endsWithFileExt, + type NamingIssue, + NamingTemplateError, + type NamingVars, + renderNamingPieces, + tryCompileNaming, + unguardedOutputs, +} from "./engine.ts"; +import { + exampleVars, + NAMING_VARIABLE_NAMES, + NAMING_VARIABLES, + type NamingKind, + normalizeExt, + SEASON_VARS, + TITLE_VARS, +} from "./variables.ts"; + +// 整条模板 → 段列表(docs/design/naming-template.md §4、§5): +// 字面量 `/` 切段;{{ }} 的值先转义(值里的 `/` 变全角,不产生目录); +// 段角色:作品根 = 从后往前第一段含标题变量且不含季变量;其后含季变量的段 = 季目录;其余在根之后 = 子目录, +// 根之前 = 分类前缀;最后一段 = 文件名(必须以 {{fileExt}} 结尾)。空的分类/子目录段整段丢弃。 + +export type SegmentRole = "category" | "root" | "season" | "sub" | "file"; + +export interface NamingSegment { + text: string; + role: SegmentRole; + /** 该段里输出过的变量 */ + vars: string[]; +} + +export interface NamingPathResult { + ok: boolean; + /** 清理后的段(空的分类/子目录段已丢弃;作品根/文件名为空时保留空串并报错) */ + segments: NamingSegment[]; + /** 相对库根路径(ok 时) */ + relativePath: string; + errors: NamingIssue[]; + /** 输出为空的变量 */ + missing: string[]; +} + +export const PATH_MAX_BYTES = 4096; +export const SEGMENT_MAX_BYTES = 255; + +const at = (code: string, message: string): NamingIssue => ({ code, message, line: 1, col: 1 }); + +interface RawSeg { + text: string; + vars: string[]; +} + +function splitPieces(c: CompiledNaming, vars: NamingVars, missing: string[]): RawSeg[] { + const segs: RawSeg[] = [{ text: "", vars: [] }]; + for (const p of renderNamingPieces(c, vars)) { + const cur = segs[segs.length - 1]!; + if (p.k === "value") { + cur.text += escapeNamingValue(p.text); + for (const v of p.vars) if (!cur.vars.includes(v)) cur.vars.push(v); + if (p.text === "") for (const v of p.vars) if (!missing.includes(v)) missing.push(v); + continue; + } + const parts = p.text.split("/"); + cur.text += parts[0]; + for (const part of parts.slice(1)) segs.push({ text: part, vars: [] }); + } + return segs.filter((s) => s.text.trim() !== "" || s.vars.length > 0); +} + +/** 给段定角色;识别不出作品根返回 -1 */ +function assignRoles(raw: RawSeg[]): { roles: SegmentRole[]; rootIdx: number } { + const n = raw.length; + const roles: SegmentRole[] = new Array(n).fill("sub"); + if (n === 0) return { roles, rootIdx: -1 }; + roles[n - 1] = "file"; + let rootIdx = -1; + for (let i = n - 2; i >= 0; i--) { + const v = raw[i]!.vars; + if (v.some((x) => TITLE_VARS.includes(x)) && !v.some((x) => SEASON_VARS.includes(x))) { + rootIdx = i; + break; + } + } + if (rootIdx === -1) return { roles, rootIdx }; + for (let i = 0; i < n - 1; i++) { + if (i < rootIdx) roles[i] = "category"; + else if (i === rootIdx) roles[i] = "root"; + else if (raw[i]!.vars.some((x) => SEASON_VARS.includes(x))) roles[i] = "season"; + else roles[i] = "sub"; + } + return { roles, rootIdx }; +} + +export interface RenderPathOptions { + /** dirs:只关心目录(文件名段为空不报错,如没有 fileExt 的目录决策) */ + mode?: "full" | "dirs"; +} + +/** 渲染整条模板为相对路径;不抛,错误在 errors 里 */ +export function renderNamingPath( + tpl: CompiledNaming | string, + vars: NamingVars, + opts: RenderPathOptions = {}, +): NamingPathResult { + const missing: string[] = []; + const fail = (errors: NamingIssue[], segments: NamingSegment[] = []): NamingPathResult => ({ + ok: false, + segments, + relativePath: "", + errors, + missing, + }); + let c: CompiledNaming; + let raw: RawSeg[]; + try { + c = typeof tpl === "string" ? compileNaming(tpl, { knownVars: NAMING_VARIABLE_NAMES }) : tpl; + raw = splitPieces(c, vars, missing); + } catch (e) { + if (e instanceof NamingTemplateError) return fail(e.issues); + throw e; + } + const { roles, rootIdx } = assignRoles(raw); + if (raw.length < 2 || rootIdx === -1) + return fail([ + at( + "no_work_root", + "识别不出作品目录:文件名之前至少要有一层目录包含 title / title_year / en_title / original_title / name,且不含季变量", + ), + ]); + const errors: NamingIssue[] = []; + const segments: NamingSegment[] = []; + const ext = typeof vars.fileExt === "string" ? normalizeExt(vars.fileExt) : ""; + raw.forEach((r, i) => { + const role = roles[i]!; + let text: string; + if (role === "file") { + const lower = r.text.toLowerCase(); + if (ext && lower.endsWith(ext)) { + const stem = cleanSegment(r.text.slice(0, r.text.length - ext.length), SEGMENT_MAX_BYTES - utf8Length(ext)); + text = stem ? stem + ext : ""; + } else text = cleanSegment(r.text, SEGMENT_MAX_BYTES); + if (!text && opts.mode !== "dirs") errors.push(at("empty_segment", "文件名为空")); + } else { + text = cleanSegment(r.text, SEGMENT_MAX_BYTES); + if (!text && role === "root") + errors.push(at("empty_segment", `作品目录为空(${r.vars.join("、") || "无变量"} 没有值)`)); + if (!text && role === "season") errors.push(at("empty_segment", "季目录为空")); + } + if (text || role === "root" || role === "file" || role === "season") segments.push({ text, role, vars: r.vars }); + }); + const relativePath = segments + .filter((s) => s.text) + .map((s) => s.text) + .join("/"); + if (utf8Length(relativePath) > PATH_MAX_BYTES) errors.push(at("path_too_long", `路径超过 ${PATH_MAX_BYTES} 字节`)); + if (errors.length) return fail(errors, segments); + return { ok: true, segments, relativePath, errors: [], missing }; +} + +export interface NamingValidation { + ok: boolean; + errors: NamingIssue[]; + warnings: string[]; + /** 用示例值渲染出的段与角色 */ + roles: { segment: string; role: SegmentRole }[]; +} + +/** 保存前校验:语法 + 未知变量 + 文件名以 {{fileExt}} 结尾 + 能识别作品根;给出"可能为空"提示 */ +export function validateNamingTemplate(source: string, kind: NamingKind): NamingValidation { + const r = tryCompileNaming(source, { knownVars: NAMING_VARIABLE_NAMES }); + if (!r.ok) return { ok: false, errors: r.issues, warnings: [], roles: [] }; + const errors: NamingIssue[] = []; + if (!endsWithFileExt(r.compiled)) { + const lines = source.split("\n"); + errors.push({ + code: "missing_file_ext", + message: "文件名缺少 {{fileExt}}:模板必须以 {{fileExt}} 结尾", + line: lines.length, + col: (lines[lines.length - 1]?.length ?? 0) + 1, + }); + } + const sample = renderNamingPath(r.compiled, exampleVars(kind)); + if (!sample.ok) errors.push(...sample.errors); + const always = new Set(NAMING_VARIABLES.filter((v) => v.always?.includes(kind)).map((v) => v.name)); + if (kind === "tv") for (const v of ["episode", "season_episode"]) always.add(v); + const warnings = unguardedOutputs(r.compiled) + .filter((v) => !always.has(v)) + .map((v) => `${v} 可能为空,建议写成 {% if ${v} %}…{{${v}}}…{% endif %}`); + return { + ok: errors.length === 0, + errors, + warnings, + roles: sample.segments.map((s) => ({ segment: s.text, role: s.role })), + }; +} diff --git a/packages/domain/src/naming/variables.ts b/packages/domain/src/naming/variables.ts new file mode 100644 index 00000000..ddba12e9 --- /dev/null +++ b/packages/domain/src/naming/variables.ts @@ -0,0 +1,226 @@ +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> | undefined; + episodes?: Readonly> | undefined; +} + +const pad2 = (n: number) => String(n).padStart(2, "0"); +const KIND_LABEL: Record = { movie: "电影", tv: "电视剧", variety: "综艺" }; + +/** 我们的 videoCodec 规范值 → MP 写法(H.265 → H265);有 x264/x265 编码器时用编码器名(同 MP 保留压制器) */ +function codecOf(p: Partial): string | undefined { + if (p.encoder) return p.encoder; + return p.videoCodec?.replace(".", ""); +} + +/** 来源(MP 写法):原盘/压制 → BluRay,Remux → BluRay REMUX,标题带 UHD 时前缀 UHD */ +function sourceOf(p: Partial): 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, + extra: { fileExt?: string | undefined } = {}, +): Record { + const v: Record = {}; + 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 { + const v: Record = {}; + 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; +} diff --git a/packages/domain/src/organize/plan.ts b/packages/domain/src/organize/plan.ts index bbe8aede..821953aa 100644 --- a/packages/domain/src/organize/plan.ts +++ b/packages/domain/src/organize/plan.ts @@ -1,3 +1,4 @@ +import type { NamingEntityInput } from "../naming/variables.ts"; import { DEFAULT_TEMPLATES, type NamingTemplateSet, renderMediaFileName } from "../path/binding.ts"; import { buildNamingContext } from "../path/template.ts"; import { type ParseOptions, parseReleasePath } from "../release-parser/index.ts"; @@ -38,8 +39,8 @@ export interface OrganizeInput { /** 种子内容根(整季包目录);只在它之内取父目录名参与解析 */ sourceRoot?: string; files: readonly SourceFile[]; - /** 本地实体的命名信息(不用发布标题命名) */ - entity: { title: string; originalTitle?: string; year?: number }; + /** 本地实体的命名信息(不用发布标题命名;可带英文名/外部 ID/季集元数据等命名变量来源) */ + entity: NamingEntityInput; /** 目标目录(宿主绝对路径,来自 path.resolveTargetDirectory + 库根) */ target: { movieDir?: string; seasonDirs?: Record; device?: string | number }; templates?: NamingTemplateSet; diff --git a/packages/domain/src/path/binding.ts b/packages/domain/src/path/binding.ts index 1619f15b..d0f57f47 100644 --- a/packages/domain/src/path/binding.ts +++ b/packages/domain/src/path/binding.ts @@ -1,5 +1,7 @@ -import { sanitizeFileName, sanitizeSegment } from "./sanitize.ts"; -import { compileTemplate, type NamingContext, renderTemplate, type TemplateVar } from "./template.ts"; +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; @@ -19,32 +21,17 @@ export interface PathBinding { active?: boolean; } -export interface NamingTemplateSet { +/** 生效的模板集:三条全路径模板 + 版本号(写进新建 path_binding.template_version) */ +export interface NamingTemplateSet extends NamingTemplates { version: number; - movieDir: string; - tvDir: string; - seasonDir: string; - movieFile: string; - episodeFile: string; - /** 综艺按日期命名 */ - dailyFile: string; } -export const DEFAULT_TEMPLATES: NamingTemplateSet = { - version: 1, - movieDir: "{title}[[ ({year})]]", - tvDir: "{title}[[ ({year})]]", - seasonDir: "Season {season}", - movieFile: "{title}[[ ({year})]][[ - {cut}]][[ - {part}]][[ - {resolution}]]", - episodeFile: "{title} - {seasonEpisode}[[ - {part}]]", - dailyFile: "{title} - {airDate:dash}[[ - 第{issue}期]]", -}; +/** 全局默认:MP 默认模板,版本 2(1 = v1 旧模板,见 LEGACY_V1_NAMING) */ +export const DEFAULT_TEMPLATES: NamingTemplateSet = { version: 2, ...MP_DEFAULT_NAMING }; -/** 校验模板集合(白名单变量、括号成对);发布模板版本前调用 */ -export function validateTemplateSet(t: NamingTemplateSet): void { - for (const k of ["movieDir", "tvDir", "seasonDir", "movieFile", "episodeFile", "dailyFile"] as const) { - compileTemplate(t[k]); - } +/** 校验模板集合(语法、白名单变量);发布前调用。完整校验(文件名/作品根)用 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 { @@ -65,12 +52,19 @@ export type TargetDirectoryDecision = reusedBindingIds: string[]; } | { kind: "conflict"; reason: "multiple_entity_bindings" | "multiple_season_bindings"; bindings: PathBinding[] } - | { kind: "error"; reason: "season_required" | "template_missing_vars"; missing?: TemplateVar[] }; + | { + kind: "error"; + reason: "season_required" | "template_missing_vars" | "template_error"; + missing?: string[]; + message?: string; + }; export interface ResolveTargetInput { entity: { id: string; kind: "movie" | "tv" }; - /** 命名上下文(首次绑定才会用到) */ - naming: NamingContext; + /** 命名变量(buildNamingContext 的结果;首次绑定与子目录段才会用到) */ + naming: NamingVars; + /** 命名类型;不传按 entity.kind(tv 且无集号有播出日期 = 综艺) */ + namingKind?: NamingKind; season?: number; bindings: readonly PathBinding[]; /** 首次绑定时使用的库根 */ @@ -86,9 +80,73 @@ function distinct(bs: PathBinding[]): PathBinding[] { 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 { @@ -98,22 +156,32 @@ export function resolveTargetDirectory(input: ResolveTargetInput): TargetDirecto 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" }; - if (input.entity.kind === "movie") { + 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: entityBinding.relativePath, + relativePath: joinRel(entityBinding.relativePath, ...L.season, ...L.tail), bindingIds: [entityBinding.id], }; - const dir = renderTemplate(t.movieDir, input.naming); - if (!dir.ok) return { kind: "error", reason: "template_missing_vars", missing: dir.missing }; - const rel = sanitizeSegment(dir.value); + const miss = needRoot(); + if (miss) return miss; + const rel = joinRel(...L.entity); return { kind: "new", libraryRootId: input.libraryRoot.id, - relativePath: rel, + relativePath: joinRel(rel, ...L.season, ...L.tail), newBindings: [ { scope: "entity", libraryRootId: input.libraryRoot.id, relativePath: rel, templateVersion: t.version }, ], @@ -121,7 +189,6 @@ export function resolveTargetDirectory(input: ResolveTargetInput): TargetDirecto }; } - if (input.season === undefined) return { kind: "error", reason: "season_required" }; 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 }; @@ -130,22 +197,19 @@ export function resolveTargetDirectory(input: ResolveTargetInput): TargetDirecto return { kind: "existing", libraryRootId: seasonBinding.libraryRootId, - relativePath: seasonBinding.relativePath, + relativePath: joinRel(seasonBinding.relativePath, ...L.tail), bindingIds: [seasonBinding.id], }; - const seasonDir = renderTemplate(t.seasonDir, { ...input.naming, season: input.season }); - if (!seasonDir.ok) return { kind: "error", reason: "template_missing_vars", missing: seasonDir.missing }; - const seasonSeg = sanitizeSegment(seasonDir.value); if (entityBinding) { - const rel = joinRel(entityBinding.relativePath, seasonSeg); + const rel = joinRel(entityBinding.relativePath, ...L.season); return { kind: "new", libraryRootId: entityBinding.libraryRootId, - relativePath: rel, + relativePath: joinRel(rel, ...L.tail), newBindings: [ { scope: "season", - season: input.season, + season: input.season!, libraryRootId: entityBinding.libraryRootId, relativePath: rel, templateVersion: t.version, @@ -154,19 +218,19 @@ export function resolveTargetDirectory(input: ResolveTargetInput): TargetDirecto reusedBindingIds: [entityBinding.id], }; } - const showDir = renderTemplate(t.tvDir, input.naming); - if (!showDir.ok) return { kind: "error", reason: "template_missing_vars", missing: showDir.missing }; - const showRel = sanitizeSegment(showDir.value); - const rel = joinRel(showRel, seasonSeg); + 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: rel, + relativePath: joinRel(rel, ...L.tail), newBindings: [ { scope: "entity", libraryRootId: input.libraryRoot.id, relativePath: showRel, templateVersion: t.version }, { scope: "season", - season: input.season, + season: input.season!, libraryRootId: input.libraryRoot.id, relativePath: rel, templateVersion: t.version, @@ -176,23 +240,32 @@ export function resolveTargetDirectory(input: ResolveTargetInput): TargetDirecto }; } -export type FileNameResult = - | { ok: true; fileName: string } - | { ok: false; missing: TemplateVar[] | ["episode_or_air_date"] }; +export type FileNameResult = { ok: true; fileName: string } | { ok: false; missing: string[]; message?: string }; -/** 渲染视频文件名:电影用 movieFile;剧集有集号用 episodeFile;只有播出日期用 dailyFile */ +/** + * 渲染视频文件名 = 整条模板的最后一段(每次入库按当前模板;目录由路径绑定决定)。 + * 剧集既无集号也无播出日期 → missing episode_or_air_date。 + */ export function renderMediaFileName( - kind: "movie" | "tv", - templates: NamingTemplateSet, - ctx: NamingContext, + kind: "movie" | "tv" | NamingKind, + templates: NamingTemplates, + ctx: NamingVars, ext: string, ): FileNameResult { - let tpl: string; - if (kind === "movie") tpl = templates.movieFile; - else if (ctx.seasonEpisode !== undefined) tpl = templates.episodeFile; - else if (ctx.airDate !== undefined) tpl = templates.dailyFile; - else return { ok: false, missing: ["episode_or_air_date"] }; - const r = renderTemplate(tpl, ctx); - if (!r.ok) return r; - return { ok: true, fileName: sanitizeFileName(r.value, ext) }; + 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 }; } diff --git a/packages/domain/src/path/index.ts b/packages/domain/src/path/index.ts index 82156a09..93aa3719 100644 --- a/packages/domain/src/path/index.ts +++ b/packages/domain/src/path/index.ts @@ -5,6 +5,7 @@ export { type NamingTemplateSet, type NewBinding, type PathBinding, + pickNamingTemplate, type ResolveTargetInput, renderMediaFileName, resolveTargetDirectory, @@ -13,15 +14,5 @@ export { } from "./binding.ts"; export { detectPathConflicts, type ExistingPath, type PathConflict, type PlannedPath } from "./conflicts.ts"; export { type MapResult, type PathMapping, toDownloaderPath, toHostPath, validatePathMappings } from "./mapping.ts"; -export { sanitizeFileName, sanitizeSegment, utf8Length } from "./sanitize.ts"; -export { - buildNamingContext, - type CompiledTemplate, - compileTemplate, - type NamingContext, - type RenderResult, - renderTemplate, - TEMPLATE_VARS, - TemplateError, - type TemplateVar, -} from "./template.ts"; +export { cleanSegment, escapeNamingValue, sanitizeFileName, sanitizeSegment, utf8Length } from "./sanitize.ts"; +export { buildNamingContext, type NamingContext } from "./template.ts"; diff --git a/packages/domain/src/path/sanitize.ts b/packages/domain/src/path/sanitize.ts index 1a44430e..a452c8e7 100644 --- a/packages/domain/src/path/sanitize.ts +++ b/packages/domain/src/path/sanitize.ts @@ -26,24 +26,37 @@ function truncateBytes(s: string, maxBytes: number): string { return out; } -/** - * 路径段非法字符处理:`\/:*?"<>|` 转全角(对照 MP),去控制字符、首尾空白与点, - * Windows 保留名加下划线,按 UTF-8 字节截断(默认 255),空串变 `_`。 - */ -export function sanitizeSegment(input: string, maxBytes = 255): string { +/** 变量值转义:`\/:*?"<>|` 转全角(对照 MP,但作用于所有变量),删控制字符;值里的 `/` 因此不会产生目录 */ +export function escapeNamingValue(input: string): string { let s = ""; for (const c of input.normalize("NFC")) { const code = c.codePointAt(0)!; if (code < 0x20 || code === 0x7f) continue; s += INVALID[c] ?? c; } - s = s + return s; +} + +/** + * 路径段清理:非法字符转全角、去控制字符、合并空白、去首尾空白与点, + * Windows 保留名加下划线,按 UTF-8 字节截断(默认 255)。结果可能为空串。 + */ +export function cleanSegment(input: string, maxBytes = 255): string { + let s = escapeNamingValue(input) .replace(/\s+/g, " ") .trim() .replace(/^[.\s]+/, "") .replace(/[.\s]+$/, ""); if (RESERVED.test(s)) s = `${s}_`; - s = truncateBytes(s, maxBytes).replace(/[.\s]+$/, ""); + return truncateBytes(s, maxBytes).replace(/[.\s]+$/, ""); +} + +/** + * 路径段非法字符处理:`\/:*?"<>|` 转全角(对照 MP),去控制字符、首尾空白与点, + * Windows 保留名加下划线,按 UTF-8 字节截断(默认 255),空串变 `_`。 + */ +export function sanitizeSegment(input: string, maxBytes = 255): string { + const s = cleanSegment(input, maxBytes); return s.length > 0 ? s : "_"; } diff --git a/packages/domain/src/path/template.ts b/packages/domain/src/path/template.ts index ea52224e..7f527417 100644 --- a/packages/domain/src/path/template.ts +++ b/packages/domain/src/path/template.ts @@ -1,151 +1,13 @@ +import { buildNamingVars, type NamingEntityInput } from "../naming/variables.ts"; import type { ParsedRelease } from "../release-parser/types.ts"; -import { sanitizeSegment } from "./sanitize.ts"; -/** 受限模板(白名单变量 + 可选片段),不用 Jinja/任意表达式 */ -export const TEMPLATE_VARS = [ - "title", - "originalTitle", - "year", - "season", - "episode", - "episodeEnd", - "seasonEpisode", - "airDate", - "issue", - "part", - "cut", - "resolution", - "source", - "videoCodec", - "hdr", - "releaseGroup", -] as const; -export type TemplateVar = (typeof TEMPLATE_VARS)[number]; -export type NamingContext = Partial>; +// 命名上下文入口(兼容旧调用点):新引擎见 ../naming/**;v1 `{var}`/`[[ ]]` 语法已由 +// naming/defaults.ts 的 upgradeLegacyTemplate 一次性迁移,不再单独解析。 -export class TemplateError extends Error { - override name = "TemplateError"; -} +/** 渲染上下文:MP 变量名 → 值(空值不出现) */ +export type NamingContext = Record; -type Part = { kind: "text"; text: string } | { kind: "var"; name: TemplateVar; format?: string }; -interface Piece { - optional: boolean; - parts: Part[]; -} -export interface CompiledTemplate { - source: string; - pieces: Piece[]; - vars: TemplateVar[]; -} - -function parseParts(src: string): Part[] { - const parts: Part[] = []; - const re = /\{([A-Za-z]+)(?::([A-Za-z0-9-]+))?\}/g; - let last = 0; - for (const m of src.matchAll(re)) { - if (m.index > last) parts.push({ kind: "text", text: src.slice(last, m.index) }); - const name = m[1] as TemplateVar; - if (!TEMPLATE_VARS.includes(name)) throw new TemplateError(`未知模板变量: ${m[1]}`); - parts.push({ kind: "var", name, ...(m[2] ? { format: m[2] } : {}) }); - last = m.index + m[0].length; - } - if (last < src.length) parts.push({ kind: "text", text: src.slice(last) }); - for (const p of parts) if (p.kind === "text" && /[{}]/.test(p.text)) throw new TemplateError(`花括号不成对: ${src}`); - return parts; -} - -export function compileTemplate(source: string): CompiledTemplate { - if (source.length > 512) throw new TemplateError("模板过长"); - const pieces: Piece[] = []; - let i = 0; - while (i < source.length) { - const open = source.indexOf("[[", i); - const close = source.indexOf("]]", i); - if (close !== -1 && (open === -1 || close < open)) throw new TemplateError("可选片段 ]] 没有对应的 [["); - if (open === -1) { - pieces.push({ optional: false, parts: parseParts(source.slice(i)) }); - break; - } - if (open > i) pieces.push({ optional: false, parts: parseParts(source.slice(i, open)) }); - const end = source.indexOf("]]", open + 2); - if (end === -1) throw new TemplateError("可选片段缺少 ]]"); - const inner = source.slice(open + 2, end); - if (inner.includes("[[")) throw new TemplateError("可选片段不能嵌套"); - pieces.push({ optional: true, parts: parseParts(inner) }); - i = end + 2; - } - const vars = [ - ...new Set(pieces.flatMap((p) => p.parts.flatMap((x) => (x.kind === "var" ? [x.name] : [])))), - ] as TemplateVar[]; - return { source, pieces, vars }; -} - -function formatValue(v: string | number, format?: string): string { - if (!format) return String(v); - const pad = /^0(\d)$/.exec(format); - if (pad && typeof v === "number") return String(v).padStart(Number(pad[1]), "0"); - if (format === "dash" && typeof v === "string" && /^\d{8}$/.test(v)) - return `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6)}`; - if (format === "dot" && typeof v === "string" && /^\d{8}$/.test(v)) - return `${v.slice(0, 4)}.${v.slice(4, 6)}.${v.slice(6)}`; - return String(v); -} - -const present = (v: unknown) => v !== undefined && v !== null && v !== ""; - -export type RenderResult = { ok: true; value: string } | { ok: false; missing: TemplateVar[] }; - -/** 渲染:必选部分缺变量 → missing;可选片段缺任一变量则整段省略。变量值内的路径分隔符等被转全角 */ -export function renderTemplate(tpl: CompiledTemplate | string, ctx: NamingContext): RenderResult { - const c = typeof tpl === "string" ? compileTemplate(tpl) : tpl; - const missing: TemplateVar[] = []; - let out = ""; - for (const piece of c.pieces) { - const lack = piece.parts.filter((p) => p.kind === "var" && !present(ctx[p.name])) as Extract< - Part, - { kind: "var" } - >[]; - if (lack.length > 0) { - if (!piece.optional) for (const l of lack) if (!missing.includes(l.name)) missing.push(l.name); - continue; - } - for (const p of piece.parts) { - out += p.kind === "text" ? p.text : sanitizeSegment(formatValue(ctx[p.name]!, p.format)); - } - } - if (missing.length > 0) return { ok: false, missing }; - return { ok: true, value: out.replace(/\s+/g, " ").trim() }; -} - -const pad2 = (n: number) => String(n).padStart(2, "0"); - -/** 由作品信息 + 解析结果构造命名上下文(标题用本地实体的绑定名,不用发布标题) */ -export function buildNamingContext( - entity: { title: string; originalTitle?: string; year?: number }, - parsed?: Partial, -): NamingContext { - const ctx: NamingContext = { title: entity.title }; - if (entity.originalTitle) ctx.originalTitle = entity.originalTitle; - if (entity.year !== undefined) ctx.year = entity.year; - if (!parsed) return ctx; - if (parsed.season !== undefined) ctx.season = parsed.season; - if (parsed.episodes) { - ctx.episode = parsed.episodes.start; - if (parsed.episodes.end > parsed.episodes.start) ctx.episodeEnd = parsed.episodes.end; - if (parsed.season !== undefined) { - const { start, end } = parsed.episodes; - // 多集文件保留区间(偏离 MP 的 >2 集截断) - ctx.seasonEpisode = `S${pad2(parsed.season)}E${pad2(start)}${end > start ? `-E${pad2(end)}` : ""}`; - } - } - if (parsed.airDate) ctx.airDate = parsed.airDate; - if (parsed.issue !== undefined) ctx.issue = parsed.issue; - if (parsed.part) ctx.part = parsed.part; - if (parsed.cut) ctx.cut = parsed.cut; - if (parsed.resolution) ctx.resolution = parsed.resolution; - if (parsed.source) ctx.source = parsed.source; - if (parsed.videoCodec) ctx.videoCodec = parsed.videoCodec; - if (parsed.hdr?.length) ctx.hdr = parsed.hdr.join(" "); - if (parsed.releaseGroup) ctx.releaseGroup = parsed.releaseGroup.name; - return ctx; +/** 由作品信息 + 解析结果构造命名上下文(标题用本地实体名,不用发布标题) */ +export function buildNamingContext(entity: NamingEntityInput, parsed?: Partial): NamingContext { + return buildNamingVars(entity, parsed); } diff --git a/packages/domain/test/differential/gen-naming-mp.py b/packages/domain/test/differential/gen-naming-mp.py new file mode 100644 index 00000000..a51354e3 --- /dev/null +++ b/packages/domain/test/differential/gen-naming-mp.py @@ -0,0 +1,80 @@ +"""命名模板差分:用 MP 上游(MetaInfo + TemplateContextBuilder + jinja2.Template)生成 naming-mp.json。 + +用法(MP 仓库根目录,用其 .venv): + cd /opt/moviepilot-plus/upstream/MoviePilot && PYTHONPATH=. \ + .venv/bin/python /opt/uhd_media_tool/packages/domain/test/differential/gen-naming-mp.py + +只记录行为输出,不复制 MP 源码。默认模板取 settings.RENAME_FORMAT(与 MP 规范化一致); +MP 没有综艺类型,综艺样例用剧集类型渲染我们的模板(预期 MP 报错,属我们特有)。 +""" + +import json +import pathlib +import subprocess +from pathlib import PurePosixPath + +from jinja2 import Template + +from app.application.messaging.message import TemplateHelper +from app.core.config import settings +from app.core.context import MediaInfo +from app.core.metainfo import MetaInfo +from app.schemas.types import MediaType + +HERE = pathlib.Path(__file__).parent +cases = json.loads((HERE / "naming-cases.json").read_text(encoding="utf-8"))["cases"] + + +def normalize_template(s: str) -> str: + # 同 settings.RENAME_FORMAT:\ → /,合并 /,去首尾 / + import re + + s = s.replace("\\", "/") + s = re.sub(r"/+", "/", s) + return s.strip("/") + + +def run(case: dict) -> dict: + kind = case["kind"] + mtype = MediaType.MOVIE if kind == "movie" else MediaType.TV + meta = MetaInfo(case["fileName"]) + if mtype == MediaType.TV and meta.begin_episode is not None: + # transhandler.plan_transfer:单文件 3 集及以上截成首集 + meta.end_season = None + if meta.total_season: + meta.total_season = 1 + if meta.total_episode > 2: + meta.total_episode = 1 + meta.end_episode = None + e = case["entity"] + mi = MediaInfo() + mi.type = mtype + mi.title = e["title"] + mi.year = str(e["year"]) if e.get("year") else None + mi.en_title = e.get("enTitle") + mi.original_title = e.get("originalTitle") + mi.category = e.get("category", "") + if e.get("tmdbId") is not None: + mi.tmdb_id = e["tmdbId"] + ext = "." + case["fileName"].rsplit(".", 1)[1] + ctx = TemplateHelper().builder.build(meta=meta, mediainfo=mi, file_extension=ext, include_raw_objects=False) + ctx.pop("media_source", None) + ctx.pop("media_id", None) + ctx.update(case.get("vars") or {}) + tpl = case.get("template") or settings.RENAME_FORMAT(mtype) + try: + out = Template(normalize_template(tpl)).render(ctx) + except Exception as ex: # noqa: BLE001 - 记录 MP 的报错 + return {"error": f"{type(ex).__name__}: {ex}"} + return {"path": str(PurePosixPath(out))} + + +def main() -> None: + commit = subprocess.run(["git", "rev-parse", "--short", "HEAD"], capture_output=True, text=True).stdout.strip() + result = {"generatedFrom": {"repo": "MoviePilot", "commit": commit}, "mp": {c["id"]: run(c) for c in cases}} + (HERE / "naming-mp.json").write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(f"wrote {len(cases)} cases") + + +if __name__ == "__main__": + main() diff --git a/packages/domain/test/differential/naming-cases.json b/packages/domain/test/differential/naming-cases.json new file mode 100644 index 00000000..749d3c76 --- /dev/null +++ b/packages/domain/test/differential/naming-cases.json @@ -0,0 +1,420 @@ +{ + "note": "命名模板差分样例:ours 期望 = expect/expectError;MP 输出见 naming-mp.json(gen-naming-mp.py 生成)。deviation 标注故意偏离/我们特有/表示差异,其余要求与 MP 逐字一致。", + "cases": [ + { + "id": "N01", + "kind": "movie", + "fileName": "Inception.2010.2160p.BluRay.x265-CMCT.mkv", + "entity": { + "title": "Inception", + "year": 2010 + }, + "expect": "Inception (2010)/Inception (2010) - 2160p.mkv" + }, + { + "id": "N02", + "kind": "movie", + "fileName": "Inception.2010.mkv", + "entity": { + "title": "Inception", + "year": 2010 + }, + "expect": "Inception (2010)/Inception (2010).mkv" + }, + { + "id": "N03", + "kind": "movie", + "fileName": "Inception.2010.CD1.1080p.BluRay.x264-CMCT.mkv", + "entity": { + "title": "Inception", + "year": 2010 + }, + "expect": "Inception (2010)/Inception (2010)-CD1 - 1080p.mkv" + }, + { + "id": "N04", + "kind": "movie", + "fileName": "Some.Movie.mkv", + "entity": { + "title": "A/B:C" + }, + "expect": "A/B:C/A/B:C.mkv" + }, + { + "id": "N05", + "kind": "tv", + "fileName": "Breaking.Bad.S01E01.1080p.WEB-DL.H264-NTb.mkv", + "entity": { + "title": "绝命毒师", + "year": 2008, + "enTitle": "Breaking Bad" + }, + "expect": "绝命毒师 (2008)/Season 1/绝命毒师 - S01E01 - 第 1 集.mkv" + }, + { + "id": "N06", + "kind": "tv", + "fileName": "Breaking.Bad.S01E01-E02.1080p.WEB-DL.H264-NTb.mkv", + "entity": { + "title": "绝命毒师", + "year": 2008, + "enTitle": "Breaking Bad" + }, + "expect": "绝命毒师 (2008)/Season 1/绝命毒师 - S01E01-E02 - 第 1-2 集.mkv" + }, + { + "id": "N07", + "kind": "tv", + "fileName": "Breaking.Bad.S01E01-E03.1080p.WEB-DL.H264-NTb.mkv", + "entity": { + "title": "绝命毒师", + "year": 2008, + "enTitle": "Breaking Bad" + }, + "expect": "绝命毒师 (2008)/Season 1/绝命毒师 - S01E01-E03 - 第 1-3 集.mkv", + "deviation": { + "kind": "intentional", + "note": "MP 3 集及以上截成首集(S01E01 - 第 1 集);我们保留区间,集账本按 3 集登记" + } + }, + { + "id": "N08", + "kind": "tv", + "fileName": "Breaking.Bad.S00E03.1080p.WEB-DL.H264-NTb.mkv", + "entity": { + "title": "绝命毒师", + "year": 2008, + "enTitle": "Breaking Bad" + }, + "expect": "绝命毒师 (2008)/Season 0/绝命毒师 - S00E03 - 第 3 集.mkv" + }, + { + "id": "N09", + "kind": "tv", + "fileName": "Some.Show.E05.1080p.ass", + "entity": { + "title": "某剧" + }, + "expect": "某剧/Season 1/某剧 - S01E05 - 第 5 集.ass" + }, + { + "id": "N10", + "kind": "tv", + "fileName": "X.S12E100.1080p.mkv", + "entity": { + "title": "X" + }, + "expect": "X/Season 12/X - S12E100 - 第 100 集.mkv" + }, + { + "id": "N11", + "kind": "movie", + "fileName": "Inception.2010.1080p.BluRay.x264-CMCT.MKV", + "entity": { + "title": "Inception", + "year": 2010 + }, + "expect": "Inception (2010)/Inception (2010) - 1080p.mkv", + "deviation": { + "kind": "intentional", + "note": "MP 保留原扩展名大小写 .MKV;我们统一小写" + } + }, + { + "id": "N12", + "kind": "tv", + "fileName": "Breaking.Bad.S02E01.1080p.WEB-DL.H264-NTb.mkv", + "entity": { + "title": "绝命毒师", + "year": 2008, + "enTitle": "Breaking Bad" + }, + "template": "{{title_year}}/{{title}} {{season_fmt}}{{fileExt}}", + "expect": "绝命毒师 (2008)/绝命毒师 S02.mkv" + }, + { + "id": "N13", + "kind": "movie", + "fileName": "X.2020.1080p.BluRay.DV.x265-A.mkv", + "entity": { + "title": "X", + "year": 2020 + }, + "template": "{{title}}/{{edition}}/{{title}}{{fileExt}}", + "expect": "X/BluRay DV/X.mkv" + }, + { + "id": "N14", + "kind": "movie", + "fileName": "X.2020.1080p.WEB-DL.H264-A.mkv", + "entity": { + "title": "X", + "year": 2020 + }, + "template": "{{title}}/{{title}} - {{resource_term}}{{fileExt}}", + "expect": "X/X - WEB-DL 1080p.mkv", + "deviation": { + "kind": "intentional", + "note": "MP resource_term 恒带前导空格,渲染出两个空格;我们合并连续空白" + } + }, + { + "id": "N15", + "kind": "movie", + "fileName": "X.2020.1080p.WEB-DL.H264-A.mkv", + "entity": { + "title": "X", + "year": 2020 + }, + "template": "{{title}}/{{title}} - {{releaseGroup}}{{fileExt}}", + "vars": { + "releaseGroup": "A/B" + }, + "expect": "X/X - A/B.mkv", + "deviation": { + "kind": "intentional", + "note": "MP 只转义标题类变量,releaseGroup 里的 / 会多出一层目录 A;我们所有变量值都转全角,值不产生目录" + } + }, + { + "id": "N16", + "kind": "movie", + "fileName": "Inception.2010.mkv", + "entity": { + "title": "盗梦空间", + "year": 2010 + }, + "template": "{{en_title or title}}/{{en_title or title}}{{fileExt}}", + "expect": "盗梦空间/盗梦空间.mkv" + }, + { + "id": "N17", + "kind": "tv", + "fileName": "Breaking.Bad.S03E01.1080p.WEB-DL.H264-NTb.mkv", + "entity": { + "title": "绝命毒师", + "year": 2008, + "enTitle": "Breaking Bad" + }, + "template": "{{title}}/S{{season | pad(2)}}/{{title}}{{fileExt}}", + "expect": "绝命毒师/S03/绝命毒师.mkv", + "deviation": { + "kind": "intentional", + "note": "pad 是我们的过滤器,MP(Jinja2)没有,报 No filter named 'pad';MP 写法 {{ '%02d' % season|int }} 我们拒绝并提示用 pad" + } + }, + { + "id": "N18", + "kind": "movie", + "fileName": "Some.Movie.mkv", + "entity": { + "title": "X" + }, + "template": "{{title}} ({{year}})/{{title}}{{fileExt}}", + "expect": "X ()/X.mkv" + }, + { + "id": "N19", + "kind": "movie", + "fileName": "Some.Movie.mkv", + "entity": { + "title": "长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长" + }, + "expect": "长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长/长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长.mkv", + "deviation": { + "kind": "intentional", + "note": "每段按 UTF-8 255 字节截断,文件名保留扩展名;MP 不截,超长由文件系统报错" + } + }, + { + "id": "N20", + "kind": "variety", + "fileName": "快乐大本营.20260927.第12期.1080p.WEB-DL.H264.mp4", + "entity": { + "title": "快乐大本营", + "kind": "variety" + }, + "expect": "快乐大本营/Season 1/快乐大本营 - 2026-09-27 - 第 12 期.mp4", + "deviation": { + "kind": "ours", + "note": "综艺默认模板是我们特有(date 过滤器、issue 变量),MP 无对应" + } + }, + { + "id": "N21", + "kind": "tv", + "fileName": "Breaking.Bad.S01E02.1080p.WEB-DL.H264-NTb.mkv", + "entity": { + "title": "绝命毒师", + "year": 2008, + "enTitle": "Breaking Bad" + }, + "template": "{{type}}/{{category}}/{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {{season_episode}}{{fileExt}}", + "expect": "电视剧/绝命毒师 (2008)/Season 1/绝命毒师 - S01E02.mkv" + }, + { + "id": "N22", + "kind": "movie", + "fileName": "Some.Movie.mkv", + "entity": { + "title": "X" + }, + "template": "{% for x in y %}{{x}}{% endfor %}{{title}}/{{title}}{{fileExt}}", + "expectError": "unsupported", + "deviation": { + "kind": "intentional", + "note": "安全子集不支持 for;MP 照常渲染" + } + }, + { + "id": "N23", + "kind": "movie", + "fileName": "Some.Movie.mkv", + "entity": { + "title": "X" + }, + "template": "{{title.__class__}}/{{title}}{{fileExt}}", + "expectError": "attribute_access", + "deviation": { + "kind": "intentional", + "note": "安全子集不支持属性访问;MP 渲染出 " + } + }, + { + "id": "N24", + "kind": "movie", + "fileName": "Some.Movie.mkv", + "entity": { + "title": "X" + }, + "template": "{{title}}/{{title}}", + "expectError": "missing_file_ext", + "deviation": { + "kind": "intentional", + "note": "文件名必须以 {{fileExt}} 结尾;MP 会生成无扩展名文件" + } + }, + { + "id": "N25", + "kind": "movie", + "fileName": "X.2020.1080p.WEB-DL.H265-A.mkv", + "entity": { + "title": "X", + "year": 2020 + }, + "template": "{{title}}/{{title}} - {{videoCodec}}{{fileExt}}", + "expect": "X/X - H265.mkv" + }, + { + "id": "N26", + "kind": "movie", + "fileName": "X.2020.1080p.WEB-DL.HEVC-A.mkv", + "entity": { + "title": "X", + "year": 2020 + }, + "template": "{{title}}/{{title}} - {{videoCodec}}{{fileExt}}", + "expect": "X/X - H265.mkv", + "deviation": { + "kind": "representation", + "note": "解析器把 HEVC/H.265/H265 归一成 H.265 再输出 H265;MP 保留原词 HEVC" + } + }, + { + "id": "N27", + "kind": "movie", + "fileName": "X.2020.2160p.WEB-DL.10bit.H265-A.mkv", + "entity": { + "title": "X", + "year": 2020 + }, + "template": "{{title}}/{{title}}{% if videoBit %} {{videoBit}}{% endif %}{{fileExt}}", + "expect": "X/X 10bit.mkv" + }, + { + "id": "N28", + "kind": "movie", + "fileName": "Inception.2010.mkv", + "entity": { + "title": "Inception", + "year": 2010, + "enTitle": "Inception Movie" + }, + "template": "{{ en_title | replace(' ', '.') }}/{{ title | upper }}{{fileExt}}", + "expect": "Inception.Movie/INCEPTION.mkv" + }, + { + "id": "N29", + "kind": "tv", + "fileName": "Breaking.Bad.S00E03.1080p.WEB-DL.H264-NTb.mkv", + "entity": { + "title": "绝命毒师", + "year": 2008, + "enTitle": "Breaking Bad" + }, + "template": "{{title}}/{% if season == '0' %}Specials{% else %}Season {{season}}{% endif %}/{{title}} - {{season_episode}}{{fileExt}}", + "expect": "绝命毒师/Specials/绝命毒师 - S00E03.mkv" + }, + { + "id": "N30", + "kind": "movie", + "fileName": "X.2020.2160p.WEB-DL.H265-A.mkv", + "entity": { + "title": "X", + "year": 2020 + }, + "template": "{{title}}/{{title}} [{% if videoFormat == '2160p' %}4K{% elif videoFormat %}{{videoFormat}}{% else %}SD{% endif %}]{{fileExt}}", + "expect": "X/X [4K].mkv" + }, + { + "id": "N31", + "kind": "movie", + "fileName": "Some.Movie.mkv", + "entity": { + "title": "X" + }, + "template": "{{title}}/{{title}} - {{ releaseGroup | default('NOGRP') }}{{fileExt}}", + "expect": "X/X - NOGRP.mkv" + }, + { + "id": "N32", + "kind": "movie", + "fileName": "Inception.2010.mkv", + "entity": { + "title": "Inception", + "year": 2010, + "tmdbId": 27205 + }, + "template": "{{title_year}} [tmdbid={{tmdbid}}]/{{title_year}}{{fileExt}}", + "expect": "Inception (2010) [tmdbid=27205]/Inception (2010).mkv" + }, + { + "id": "N33", + "kind": "movie", + "fileName": "Some.Movie.mkv", + "entity": { + "title": "CON" + }, + "expect": "CON_/CON_.mkv", + "deviation": { + "kind": "intentional", + "note": "Windows 保留名加 _;MP 不处理" + } + }, + { + "id": "N34", + "kind": "movie", + "fileName": "Inception.2010.mkv", + "entity": { + "title": "Inception", + "year": 2010 + }, + "template": "{{title}}: {{year}}/{{title}}{{fileExt}}", + "expect": "Inception: 2010/Inception.mkv", + "deviation": { + "kind": "intentional", + "note": "模板字面量里的非法字符也转全角;MP 原样保留 :" + } + } + ] +} diff --git a/packages/domain/test/differential/naming-mp.json b/packages/domain/test/differential/naming-mp.json new file mode 100644 index 00000000..07111179 --- /dev/null +++ b/packages/domain/test/differential/naming-mp.json @@ -0,0 +1,110 @@ +{ + "generatedFrom": { + "repo": "MoviePilot", + "commit": "e6820a8" + }, + "mp": { + "N01": { + "path": "Inception (2010)/Inception (2010) - 2160p.mkv" + }, + "N02": { + "path": "Inception (2010)/Inception (2010).mkv" + }, + "N03": { + "path": "Inception (2010)/Inception (2010)-CD1 - 1080p.mkv" + }, + "N04": { + "path": "A/B:C/A/B:C.mkv" + }, + "N05": { + "path": "绝命毒师 (2008)/Season 1/绝命毒师 - S01E01 - 第 1 集.mkv" + }, + "N06": { + "path": "绝命毒师 (2008)/Season 1/绝命毒师 - S01E01-E02 - 第 1-2 集.mkv" + }, + "N07": { + "path": "绝命毒师 (2008)/Season 1/绝命毒师 - S01E01 - 第 1 集.mkv" + }, + "N08": { + "path": "绝命毒师 (2008)/Season 0/绝命毒师 - S00E03 - 第 3 集.mkv" + }, + "N09": { + "path": "某剧/Season 1/某剧 - S01E05 - 第 5 集.ass" + }, + "N10": { + "path": "X/Season 12/X - S12E100 - 第 100 集.mkv" + }, + "N11": { + "path": "Inception (2010)/Inception (2010) - 1080p.MKV" + }, + "N12": { + "path": "绝命毒师 (2008)/绝命毒师 S02.mkv" + }, + "N13": { + "path": "X/BluRay DV/X.mkv" + }, + "N14": { + "path": "X/X - WEB-DL 1080p.mkv" + }, + "N15": { + "path": "X/X - A/B.mkv" + }, + "N16": { + "path": "盗梦空间/盗梦空间.mkv" + }, + "N17": { + "error": "TemplateAssertionError: No filter named 'pad'." + }, + "N18": { + "path": "X ()/X.mkv" + }, + "N19": { + "path": "长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长/长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长.mkv" + }, + "N20": { + "path": "快乐大本营/Season 1/快乐大本营 - S01E12 - 第 12 集.mp4" + }, + "N21": { + "path": "电视剧/绝命毒师 (2008)/Season 1/绝命毒师 - S01E02.mkv" + }, + "N22": { + "path": "X/X.mkv" + }, + "N23": { + "path": "/X.mkv" + }, + "N24": { + "path": "X/X" + }, + "N25": { + "path": "X/X - H265.mkv" + }, + "N26": { + "path": "X/X - HEVC.mkv" + }, + "N27": { + "path": "X/X 10bit.mkv" + }, + "N28": { + "path": "Inception.Movie/INCEPTION.mkv" + }, + "N29": { + "path": "绝命毒师/Specials/绝命毒师 - S00E03.mkv" + }, + "N30": { + "path": "X/X [4K].mkv" + }, + "N31": { + "path": "X/X - NOGRP.mkv" + }, + "N32": { + "path": "Inception (2010) [tmdbid=27205]/Inception (2010).mkv" + }, + "N33": { + "path": "CON/CON.mkv" + }, + "N34": { + "path": "Inception: 2010/Inception.mkv" + } + } +} diff --git a/packages/domain/test/differential/naming.test.ts b/packages/domain/test/differential/naming.test.ts new file mode 100644 index 00000000..10d15b77 --- /dev/null +++ b/packages/domain/test/differential/naming.test.ts @@ -0,0 +1,62 @@ +/** + * 命名模板差分:MP(MetaInfo + TemplateContextBuilder + jinja2.Template)vs 我们的 parseRelease + buildNamingVars + renderNamingPath。 + * MP 输出由 gen-naming-mp.py 生成(naming-mp.json)。没有登记 deviation 的样例必须与 MP 逐字一致; + * 登记了的必须确实不同(差异消除后删掉登记)。说明见 docs/runbook/naming.md。 + */ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { + buildNamingVars, + MP_DEFAULT_NAMING, + type NamingEntityInput, + type NamingKind, + parseRelease, + renderNamingPath, + validateNamingTemplate, +} from "../../src/index.ts"; + +interface Case { + id: string; + kind: NamingKind; + fileName: string; + entity: NamingEntityInput; + template?: string; + vars?: Record; + expect?: string; + expectError?: string; + deviation?: { kind: "intentional" | "ours" | "representation"; note: string }; +} + +const dir = new URL(".", import.meta.url); +const { cases } = JSON.parse(readFileSync(new URL("naming-cases.json", dir), "utf8")) as { cases: Case[] }; +const { mp } = JSON.parse(readFileSync(new URL("naming-mp.json", dir), "utf8")) as { + mp: Record; +}; + +function ours(c: Case): { path?: string; error?: string } { + const template = c.template ?? MP_DEFAULT_NAMING[c.kind]; + const v = validateNamingTemplate(template, c.kind); + if (!v.ok) return { error: v.errors[0]!.code }; + const ext = c.fileName.slice(c.fileName.lastIndexOf(".")); + const vars = { ...buildNamingVars(c.entity, parseRelease(c.fileName), { fileExt: ext }), ...c.vars }; + const r = renderNamingPath(template, vars); + return r.ok ? { path: r.relativePath } : { error: r.errors[0]!.code }; +} + +describe("命名模板差分(MP ↔ naming 引擎)", () => { + it("样例 ≥ 25 且每条都有 MP 输出", () => { + expect(cases.length).toBeGreaterThanOrEqual(25); + for (const c of cases) expect(mp[c.id], c.id).toBeDefined(); + }); + + for (const c of cases) { + it(`${c.id} ${c.template ?? `默认 ${c.kind}`} ← ${c.fileName}`, () => { + const got = ours(c); + if (c.expectError) expect(got).toEqual({ error: c.expectError }); + else expect(got).toEqual({ path: c.expect }); + const m = mp[c.id]!; + if (c.deviation) expect(m.path, `${c.id} 登记为偏离但与 MP 一致,删掉 deviation`).not.toBe(got.path); + else expect(m.path, `${c.id} 与 MP 不一致`).toBe(got.path); + }); + } +}); diff --git a/packages/domain/test/naming.test.ts b/packages/domain/test/naming.test.ts new file mode 100644 index 00000000..cf333f11 --- /dev/null +++ b/packages/domain/test/naming.test.ts @@ -0,0 +1,391 @@ +import { describe, expect, it } from "vitest"; +import { + buildNamingContext, + buildNamingVars, + compileNaming, + DEFAULT_TEMPLATES, + LEGACY_V1_NAMING, + MP_DEFAULT_NAMING, + NAMING_SAMPLES, + NAMING_VARIABLE_NAMES, + NAMING_VARIABLES, + NamingTemplateError, + namingKindOf, + parseRelease, + renderMediaFileName, + renderNamingPath, + renderNamingString, + resolveNamingTemplates, + resolveTargetDirectory, + tryCompileNaming, + upgradeLegacyTemplate, + validateNamingTemplate, +} from "../src/index.ts"; + +const known = { knownVars: NAMING_VARIABLE_NAMES }; +const r = (tpl: string, vars: Record = {}) => renderNamingString(tpl, vars); +const issue = (tpl: string) => { + const c = tryCompileNaming(tpl, known); + return c.ok ? undefined : c.issues[0]; +}; + +describe("语法", () => { + it("输出、or、默认值、注释、去空白", () => { + expect(r("{{ title }}", { title: "A" })).toBe("A"); + expect(r("{{ en_title or title }}", { title: "A" })).toBe("A"); + expect(r("{{ en_title or title or 'x' }}", {})).toBe("x"); + expect(r("{{ year | default('未知') }}", {})).toBe("未知"); + expect(r("a{# 注释 #}b")).toBe("ab"); + expect(r("a {%- if 1 -%} b {%- endif %}")).toBe("ab"); + expect(r("{{ missing }}")).toBe(""); + }); + it("if / elif / else 嵌套与比较", () => { + const t = "{% if n > 10 %}big{% elif n == 1 %}one{% elif not n %}none{% else %}mid{% endif %}"; + expect(r(t, { n: 11 })).toBe("big"); + expect(r(t, { n: "1" })).toBe("one"); + expect(r(t, {})).toBe("none"); + expect(r(t, { n: 5 })).toBe("mid"); + expect(r("{% if a and (b or c) %}y{% endif %}", { a: 1, c: "x" })).toBe("y"); + expect(r("{% if a != 'x' %}y{% endif %}", { a: "z" })).toBe("y"); + expect(r("{% if a >= 2 and a <= 3 %}y{% endif %}", { a: "3" })).toBe("y"); + expect(r("{% if a > 1 %}y{% endif %}", { a: "abc" })).toBe(""); + expect(r("{% if total_episodes %}y{% else %}n{% endif %}", { total_episodes: 0 })).toBe("n"); + }); + it("过滤器", () => { + expect(r("{{ season | pad(2) }}", { season: "3" })).toBe("03"); + expect(r("{{ episode | pad(3) }}", { episode: "1-3" })).toBe("001-003"); + expect(r("{{ season | pad2 }}", { season: 7 })).toBe("07"); + expect(r("{{ t | upper }}|{{ t | lower }}|{{ ' a ' | trim }}", { t: "Ab" })).toBe("AB|ab|a"); + expect(r("{{ t | replace(' ', '.') }}", { t: "a b c" })).toBe("a.b.c"); + expect(r("{{ t | truncate(3) }}", { t: "绝命毒师" })).toBe("绝命毒"); + expect(r("{{ d | date('YYYY-MM-DD') }}/{{ d | date('YYYY.MM.DD') }}", { d: "20260927" })).toBe( + "2026-09-27/2026.09.27", + ); + expect(r("{{ d | date('YYYYMMDD') }}", { d: "2026-09-27" })).toBe("20260927"); + expect(renderNamingString("{{ l | join(',') }}", { l: ["a", "b"] })).toBe("a,b"); + expect(r("{{ x | default('-') | upper }}", {})).toBe("-"); + }); + it("编译错误带行列号与原因", () => { + expect(issue("{% for x in y %}{% endfor %}")).toMatchObject({ code: "unsupported", line: 1, col: 1 }); + expect(issue("a\n{{ title.__class__ }}")).toMatchObject({ code: "attribute_access", line: 2, col: 9 }); + expect(issue("{{ title[0] }}")?.code).toBe("subscript"); + expect(issue("{{ lipsum() }}")?.code).toBe("call"); + expect(issue("{{ title ~ year }}")?.code).toBe("concat"); + expect(issue("{{ '%02d' % season }}")?.message).toContain("pad(2)"); + expect(issue("{{ title if year else 'x' }}")?.code).toBe("inline_if"); + expect(issue("{% set a = 1 %}")?.code).toBe("unsupported"); + expect(issue("{{ title")?.code).toBe("unclosed_tag"); + expect(issue("{% if title %}x")?.code).toBe("unclosed_if"); + expect(issue("{% endif %}")?.code).toBe("stray_endif"); + expect(issue("{% else %}")?.code).toBe("stray_else"); + expect(issue("{% if title %}{% else %}{% elif year %}{% endif %}")?.code).toBe("syntax"); + expect(issue("{{ }}")?.code).toBe("empty_output"); + expect(issue("x }}")?.code).toBe("stray_close"); + expect(issue("{{ title | shout }}")?.code).toBe("unknown_filter"); + expect(issue("{{ title | uper }}")?.message).toContain("是否指 upper"); + expect(issue("{{ season | pad('x') }}")?.code).toBe("filter_arg"); + expect(issue("{{ airDate | date('MM/DD') }}")?.code).toBe("filter_arg"); + expect(issue("{{ title | replace('a') }}")?.code).toBe("filter_arity"); + expect(issue("{{ videoformat }}")?.message).toBe("未知变量 videoformat,是否指 videoFormat"); + expect(issue("{{ constructor }}")?.code).toBe("unknown_variable"); + expect(issue("{{ title | constructor }}")?.code).toBe("unknown_filter"); + expect(tryCompileNaming("{% constructor %}").ok).toBe(false); + expect(renderNamingString("{{ constructor }}{{ __proto__ }}", {})).toBe(""); + expect(issue("{{ 1.5 }}")?.code).toBe("unsupported_number"); + expect(() => compileNaming("{{ a.b }}")).toThrow(NamingTemplateError); + }); + it("限制:长度、嵌套深度、节点数、输出字节", () => { + expect(issue("x".repeat(1025))?.code).toBe("too_long"); + const deep = `${"{% if title %}".repeat(5)}x${"{% endif %}".repeat(5)}`; + expect(issue(deep)?.code).toBe("too_deep"); + expect(tryCompileNaming("{{a}}".repeat(200)).ok).toBe(true); + expect(tryCompileNaming("{{a}}".repeat(205)).ok).toBe(false); + expect(() => renderNamingString("{{a}}{{a}}", { a: "x".repeat(3000) })).toThrow(/4096/); + }); + it("收集引用变量", () => { + expect(compileNaming(MP_DEFAULT_NAMING.movie).vars).toEqual(["title", "year", "part", "videoFormat", "fileExt"]); + }); +}); + +describe("变量表与上下文", () => { + it("变量名唯一,覆盖 MP 命名变量", () => { + expect(new Set(NAMING_VARIABLE_NAMES).size).toBe(NAMING_VARIABLES.length); + const mp = + "title en_title original_title name year title_year season season_fmt season_year episode season_episode total_episodes episode_title episode_date part videoFormat resourceType effect edition resource_term releaseGroup videoCodec videoBit audioCodec webSource fps customization fileExt type category tmdbid imdbid doubanid".split( + " ", + ); + for (const v of mp) expect(NAMING_VARIABLE_NAMES, v).toContain(v); + expect(NAMING_VARIABLES.filter((v) => v.ours).map((v) => v.name)).toEqual([ + "cut", + "contentType", + "segment", + "airDate", + "issue", + "slotName", + "libraryRoot", + ]); + }); + it("季集元数据、版本目标、库根、外部 ID", () => { + const v = buildNamingVars( + { + title: "绝命毒师", + year: 2008, + tmdbId: 1396, + imdbId: "tt0903747", + slotName: "4K HDR", + libraryRoot: "电视剧-A", + category: "欧美剧", + seasons: { 1: { year: 2008, episodeTotal: 7 } }, + episodes: { "1:1": { title: "Pilot", airDate: "2008-01-20" } }, + }, + parseRelease("Breaking.Bad.S01E01.2160p.UHD.BluRay.REMUX.DV.HDR10.HEVC.TrueHD.7.1-FGT.mkv"), + { fileExt: "MKV" }, + ); + expect(v).toMatchObject({ + season: "1", + season_fmt: "S01", + season_year: "2008", + total_episodes: 7, + episode_title: "Pilot", + episode_date: "2008-01-20", + resourceType: "UHD BluRay REMUX", + tmdbid: "1396", + imdbid: "tt0903747", + slotName: "4K HDR", + libraryRoot: "电视剧-A", + category: "欧美剧", + fileExt: ".mkv", + type: "电视剧", + }); + const daily = buildNamingVars({ title: "奔跑吧", kind: "variety" }, parseRelease("奔跑吧 20240426 第1期")); + expect(daily).toMatchObject({ type: "综艺", season: "1", airDate: "20240426", issue: "1" }); + expect(daily.episode).toBeUndefined(); + expect(buildNamingVars({ title: "T" }, parseRelease("T.S01-S02.1080p")).season_fmt).toBe("S01-S02"); + }); + it("namingKindOf", () => { + expect(["movie", "series", "variety", "collection"].map(namingKindOf)).toEqual(["movie", "tv", "variety", "movie"]); + }); +}); + +describe("路径:段、角色、清理", () => { + it("段角色:分类前缀 / 作品根 / 季 / 子目录 / 文件", () => { + const res = renderNamingPath( + "{{type}}/{{category}}/{{title_year}}/Season {{season}}/{{edition}}/{{title}} - {{season_episode}}{{fileExt}}", + { + type: "电视剧", + category: "", + title: "X", + title_year: "X (2008)", + season: "1", + season_episode: "S01E01", + edition: "WEB-DL", + fileExt: ".mkv", + }, + ); + expect(res.ok).toBe(true); + expect(res.relativePath).toBe("电视剧/X (2008)/Season 1/WEB-DL/X - S01E01.mkv"); + expect(res.segments.map((s) => s.role)).toEqual(["category", "root", "season", "sub", "file"]); + }); + it("模板里的 \\ 视为目录分隔,多余的 / 合并", () => { + expect(renderNamingPath("/{{title}}\\\\{{title}}{{fileExt}}/", { title: "A", fileExt: ".mkv" }).relativePath).toBe( + "A/A.mkv", + ); + }); + it("值里的非法字符与 / 转全角,控制字符删除,合并空白", () => { + const res = renderNamingPath("{{title}}/{{title}} - {{releaseGroup}}{{fileExt}}", { + title: 'A/B:C*D?"EG|H\\I\u0001', + releaseGroup: "x/y", + fileExt: ".mkv", + }); + expect(res.relativePath).toBe("A/B:C*D?"E<F>G|H\I/A/B:C*D?"E<F>G|H\I - x/y.mkv"); + }); + it("保留名、首尾点、长度截断", () => { + expect(renderNamingPath("{{title}}/{{title}}{{fileExt}}", { title: "nul", fileExt: ".mkv" }).relativePath).toBe( + "nul_/nul_.mkv", + ); + expect(renderNamingPath("{{title}}/{{title}}{{fileExt}}", { title: "..a..", fileExt: ".mkv" }).relativePath).toBe( + "a/a.mkv", + ); + const long = renderNamingPath("{{title}}/{{title}}{{fileExt}}", { title: "é".repeat(300), fileExt: ".mkv" }); + const [dir, file] = long.relativePath.split("/"); + expect(new TextEncoder().encode(dir).length).toBeLessThanOrEqual(255); + expect(new TextEncoder().encode(file).length).toBeLessThanOrEqual(255); + expect(file!.endsWith(".mkv")).toBe(true); + const tooLong = renderNamingPath(`${"{{title}}/".repeat(20)}{{title}}{{fileExt}}`, { + title: "长".repeat(85), + fileExt: ".mkv", + }); + expect(tooLong.ok).toBe(false); + expect(tooLong.errors[0]?.code).toMatch(/_too_long$/); + }); + it("空段:分类/子目录丢弃,作品根/文件名为空报错,识别不出作品根报错", () => { + const empty = renderNamingPath("{{title}}/{{title}}{{fileExt}}", { fileExt: ".mkv" }); + expect(empty.ok).toBe(false); + expect(empty.errors[0]?.code).toBe("empty_segment"); + expect(empty.missing).toEqual(["title"]); + expect(renderNamingPath("{{title}}{{fileExt}}", { title: "A", fileExt: ".mkv" }).errors[0]?.code).toBe( + "no_work_root", + ); + expect( + renderNamingPath("{{type}}/{{title}}{{fileExt}}", { type: "电影", title: "A", fileExt: ".mkv" }).errors[0]?.code, + ).toBe("no_work_root"); + }); +}); + +describe("校验(保存前)", () => { + it("默认模板全部通过且角色正确", () => { + for (const k of ["movie", "tv", "variety"] as const) { + const v = validateNamingTemplate(MP_DEFAULT_NAMING[k], k); + expect(v, k).toMatchObject({ ok: true, errors: [] }); + expect(v.roles.map((x) => x.role)).toEqual(k === "movie" ? ["root", "file"] : ["root", "season", "file"]); + } + for (const k of ["movie", "tv", "variety"] as const) + expect(validateNamingTemplate(LEGACY_V1_NAMING[k], k).ok, k).toBe(true); + }); + it("缺 fileExt、未知变量、作品根;可能为空的提示", () => { + expect(validateNamingTemplate("{{title}}/{{title}}", "movie").errors[0]).toMatchObject({ + code: "missing_file_ext", + message: "文件名缺少 {{fileExt}}:模板必须以 {{fileExt}} 结尾", + }); + expect(validateNamingTemplate("{{title}}/{{titel}}{{fileExt}}", "movie").errors[0]?.message).toContain( + "是否指 title", + ); + expect(validateNamingTemplate("{{title}} ({{year}})/{{title}}{{fileExt}}", "movie").warnings).toEqual([ + "year 可能为空,建议写成 {% if year %}…{{year}}…{% endif %}", + ]); + expect(validateNamingTemplate(MP_DEFAULT_NAMING.tv, "tv").warnings).toEqual([]); + }); +}); + +describe("v1 旧语法迁移", () => { + it("{var}、{var:02}、{airDate:dash}、[[ ]] → 新语法", () => { + expect(upgradeLegacyTemplate("{title}[[ ({year})]]")).toBe("{{title}}{% if year %} ({{year}}){% endif %}"); + expect(upgradeLegacyTemplate("S{season:02}")).toBe("S{{season | pad(2)}}"); + expect(upgradeLegacyTemplate("{airDate:dash}[[ - 第{issue}期]]")).toBe( + "{{airDate | date('YYYY-MM-DD')}}{% if issue %} - 第{{issue}}期{% endif %}", + ); + expect(upgradeLegacyTemplate("{resolution} {seasonEpisode}")).toBe("{{videoFormat}} {{season_episode}}"); + }); + it("v1 模板迁移后输出与旧实现一致", () => { + const movie = buildNamingContext({ title: "指环王", year: 2001 }, parseRelease("LOTR.2001.Extended.1080p.BluRay")); + expect(renderMediaFileName("movie", LEGACY_V1_NAMING, movie, "mkv")).toEqual({ + ok: true, + fileName: "指环王 (2001) - Extended - 1080p.mkv", + }); + const ep = buildNamingContext({ title: "庆余年" }, parseRelease("Show.S02E01-E03.1080p")); + expect(renderMediaFileName("tv", LEGACY_V1_NAMING, ep, "mkv")).toMatchObject({ + fileName: "庆余年 - S02E01-E03.mkv", + }); + const daily = buildNamingContext({ title: "奔跑吧" }, parseRelease("奔跑吧 第十二季 20240426 第1期 1080p")); + expect(renderMediaFileName("tv", LEGACY_V1_NAMING, daily, "mp4")).toMatchObject({ + fileName: "奔跑吧 - 2024-04-26 - 第1期.mp4", + }); + }); +}); + +describe("覆盖顺序:作品 > 库根 > 全局(按类型回落)", () => { + it("resolveNamingTemplates", () => { + const res = resolveNamingTemplates({ + global: MP_DEFAULT_NAMING, + root: { tv: "R-tv", movie: "" }, + entity: { tv: "E-tv", variety: "E-var" }, + }); + expect(res.templates).toEqual({ movie: MP_DEFAULT_NAMING.movie, tv: "E-tv", variety: "E-var" }); + expect(res.sources).toEqual({ movie: "global", tv: "entity", variety: "entity" }); + expect(resolveNamingTemplates({ global: MP_DEFAULT_NAMING, root: { tv: "R-tv" }, entity: null }).sources.tv).toBe( + "root", + ); + }); +}); + +describe("路径绑定不变", () => { + const root = { id: "L", hostPath: "/media/tv" }; + it("已有作品绑定:目录用绑定路径,文件名用新模板(差分 #24)", () => { + const naming = buildNamingContext({ title: "新名", year: 2008 }, parseRelease("Show.S01E02.1080p")); + const dec = resolveTargetDirectory({ + entity: { id: "e", kind: "tv" }, + naming, + season: 1, + bindings: [ + { + id: "b", + entityId: "e", + scope: "entity", + libraryRootId: "L", + relativePath: "旧名 (2008)", + templateVersion: 1, + }, + ], + libraryRoot: root, + templates: DEFAULT_TEMPLATES, + }); + expect(dec).toMatchObject({ kind: "new", relativePath: "旧名 (2008)/Season 1", reusedBindingIds: ["b"] }); + expect(renderMediaFileName("tv", DEFAULT_TEMPLATES, naming, "mkv")).toMatchObject({ + fileName: "新名 - S01E02 - 第 2 集.mkv", + }); + }); + it("首次绑定:作品根 + 分类前缀进绑定,子目录段在绑定之后照常渲染", () => { + const templates = { + ...DEFAULT_TEMPLATES, + version: 7, + movie: "{{type}}/{{title_year}}/{{edition}}/{{title}}{{fileExt}}", + }; + const naming = buildNamingContext({ title: "X", year: 2020 }, parseRelease("X.2020.1080p.BluRay.DV.x265-A")); + const first = resolveTargetDirectory({ + entity: { id: "m", kind: "movie" }, + naming, + bindings: [], + libraryRoot: root, + templates, + }); + expect(first).toMatchObject({ + kind: "new", + relativePath: "电影/X (2020)/BluRay DV", + newBindings: [{ scope: "entity", relativePath: "电影/X (2020)", templateVersion: 7 }], + }); + const again = resolveTargetDirectory({ + entity: { id: "m", kind: "movie" }, + naming: { ...naming, title_year: "改名 (2020)" }, + bindings: [ + { + id: "b", + entityId: "m", + scope: "entity", + libraryRootId: "L", + relativePath: "电影/X (2020)", + templateVersion: 7, + }, + ], + libraryRoot: root, + templates, + }); + expect(again).toMatchObject({ kind: "existing", relativePath: "电影/X (2020)/BluRay DV" }); + }); + it("没有季目录的模板:季绑定等于作品目录", () => { + const dec = resolveTargetDirectory({ + entity: { id: "e", kind: "tv" }, + naming: buildNamingContext({ title: "X" }, parseRelease("X.S02E01")), + season: 2, + bindings: [], + libraryRoot: root, + templates: { ...DEFAULT_TEMPLATES, tv: "{{title_year}}/{{title}} {{season_fmt}}{{fileExt}}" }, + }); + expect(dec).toMatchObject({ + kind: "new", + relativePath: "X", + newBindings: [{ relativePath: "X" }, { relativePath: "X" }], + }); + }); +}); + +describe("内置样本", () => { + it("六条样本都能用默认模板渲染", () => { + expect(NAMING_SAMPLES).toHaveLength(6); + for (const s of NAMING_SAMPLES) { + const ext = s.fileName.slice(s.fileName.lastIndexOf(".")); + const vars = buildNamingVars({ ...s.entity, kind: s.kind }, parseRelease(s.fileName), { fileExt: ext }); + const res = renderNamingPath(MP_DEFAULT_NAMING[s.kind], vars); + expect(res.ok, `${s.label} ${JSON.stringify(res.errors)}`).toBe(true); + } + }); +}); diff --git a/packages/domain/test/organize.test.ts b/packages/domain/test/organize.test.ts index 54f9b94e..17b62ca3 100644 --- a/packages/domain/test/organize.test.ts +++ b/packages/domain/test/organize.test.ts @@ -32,10 +32,10 @@ describe("planOrganize 整季包", () => { it("主视频 + 随行字幕,样例/nfo/图片排除,跨卷标记", () => { const plan = planOrganize(tvInput()); expect(plan.files.map((f) => [f.role, f.dst])).toEqual([ - ["main", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E01.mkv"], - ["main", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E02.mkv"], - ["sidecar", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E01.chi.zh-cn.srt"], - ["sidecar", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E01.eng.default.srt"], + ["main", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E01 - 第 1 集.mkv"], + ["main", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E02 - 第 2 集.mkv"], + ["sidecar", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E01 - 第 1 集.chi.zh-cn.srt"], + ["sidecar", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E01 - 第 1 集.eng.default.srt"], ]); expect(plan.files[0]).toMatchObject({ kind: "video", @@ -291,10 +291,10 @@ describe("W4-E4:字幕 .default 与 latest 旧版本判定口径(对照 MP)", () }, }); expect(plan.files.map((f) => f.dst)).toEqual([ - "/lib/剧/Season 2/剧 - S02E03-E04.mkv", - "/lib/剧/Season 1/剧 - S01E06.mkv", - "/lib/剧/Season 2/剧 - S02E07.mkv", - "/lib/剧/Season 2/剧 - S02E03-E04.chi.zh-cn.srt", + "/lib/剧/Season 2/剧 - S02E03-E04 - 第 3-4 集.mkv", + "/lib/剧/Season 1/剧 - S01E06 - 第 6 集.mkv", + "/lib/剧/Season 2/剧 - S02E07 - 第 7 集.mkv", + "/lib/剧/Season 2/剧 - S02E03-E04 - 第 3-4 集.chi.zh-cn.srt", ]); expect(plan.files[0]).toMatchObject({ season: 2, episodes: { start: 3, end: 4 } }); const movie = planOrganize({ diff --git a/packages/domain/test/path.test.ts b/packages/domain/test/path.test.ts index cd9ee871..5088e51e 100644 --- a/packages/domain/test/path.test.ts +++ b/packages/domain/test/path.test.ts @@ -1,17 +1,15 @@ import { describe, expect, it } from "vitest"; import { buildNamingContext, - compileTemplate, DEFAULT_TEMPLATES, detectPathConflicts, + NamingTemplateError, type PathBinding, parseRelease, renderMediaFileName, - renderTemplate, resolveTargetDirectory, sanitizeFileName, sanitizeSegment, - TemplateError, toDownloaderPath, toHostPath, utf8Length, @@ -61,7 +59,7 @@ describe("路径绑定", () => { libraryRootId: "lib-tv", relativePath: "最后生还者 (2023)/Season 2", reusedBindingIds: ["b1"], - newBindings: [{ scope: "season", season: 2, templateVersion: 1 }], + newBindings: [{ scope: "season", season: 2, templateVersion: 2 }], }); }); it("首次:作品 + 季两级绑定,非法字符转全角", () => { @@ -150,13 +148,13 @@ describe("路径绑定", () => { ...tv, naming: { title: "x" }, season: 1, - templates: { ...DEFAULT_TEMPLATES, seasonDir: "S{episode}" }, + templates: { ...DEFAULT_TEMPLATES, tv: "{{title}}/{{evil}}/{{title}}{{fileExt}}" }, }), - ).toMatchObject({ kind: "error", missing: ["episode"] }); + ).toMatchObject({ kind: "error", reason: "template_error" }); }); }); -describe("命名模板", () => { +describe("命名模板(旧入口 buildNamingContext / renderMediaFileName)", () => { it("电影 / 剧集 / 多集区间 / 综艺日期", () => { const movie = buildNamingContext( { title: "沙丘2", year: 2024 }, @@ -168,56 +166,54 @@ describe("命名模板", () => { }); const cut = buildNamingContext({ title: "指环王", year: 2001 }, parseRelease("LOTR.2001.Extended.1080p.BluRay")); expect(renderMediaFileName("movie", DEFAULT_TEMPLATES, cut, "mkv")).toMatchObject({ - fileName: "指环王 (2001) - Extended - 1080p.mkv", + fileName: "指环王 (2001) - 1080p.mkv", }); const ep = buildNamingContext({ title: "庆余年" }, parseRelease("Show.S02E01-E03.1080p")); - expect(ep.seasonEpisode).toBe("S02E01-E03"); + expect(ep.season_episode).toBe("S02E01-E03"); expect(renderMediaFileName("tv", DEFAULT_TEMPLATES, ep, "mkv")).toMatchObject({ - fileName: "庆余年 - S02E01-E03.mkv", + fileName: "庆余年 - S02E01-E03 - 第 1-3 集.mkv", }); const daily = buildNamingContext({ title: "奔跑吧" }, parseRelease("奔跑吧 第十二季 20240426 第1期 1080p")); expect(renderMediaFileName("tv", DEFAULT_TEMPLATES, daily, "mp4")).toMatchObject({ - fileName: "奔跑吧 - 2024-04-26 - 第1期.mp4", + fileName: "奔跑吧 - 2024-04-26 - 第 1 期.mp4", }); expect(renderMediaFileName("tv", DEFAULT_TEMPLATES, { title: "x" }, "mp4")).toEqual({ ok: false, missing: ["episode_or_air_date"], }); - expect(renderMediaFileName("movie", DEFAULT_TEMPLATES, {}, "mp4")).toEqual({ ok: false, missing: ["title"] }); + expect(renderMediaFileName("movie", DEFAULT_TEMPLATES, {}, "mp4")).toMatchObject({ ok: false, missing: ["title"] }); }); - it("上下文完整字段", () => { + it("上下文完整字段(MP 变量名)", () => { const ctx = buildNamingContext( { title: "T", originalTitle: "O", year: 2020 }, parseRelease("T.2020.S01E05.CD2.2160p.WEB-DL.DV.HEVC-GRP"), ); expect(ctx).toMatchObject({ - originalTitle: "O", - episode: 5, + original_title: "O", + season: "1", + episode: "5", + season_episode: "S01E05", part: "CD2", - source: "WEB-DL", - videoCodec: "H.265", - hdr: "DV", + resourceType: "WEB-DL", + videoCodec: "H265", + effect: "DV", + edition: "WEB-DL DV", + resource_term: "WEB-DL DV 2160p", releaseGroup: "GRP", + type: "电视剧", }); - expect(buildNamingContext({ title: "T" })).toEqual({ title: "T" }); - }); - it("格式化与可选片段", () => { - expect(renderTemplate("S{season:02}E{episode:03}", { season: 1, episode: 5 })).toEqual({ - ok: true, - value: "S01E005", + expect(buildNamingContext({ title: "T" })).toEqual({ + title: "T", + title_year: "T", + type: "电影", + total_episodes: 0, }); - expect(renderTemplate("{airDate:dot}", { airDate: "20240101" })).toEqual({ ok: true, value: "2024.01.01" }); - expect(renderTemplate("{title:zz}", { title: "a" })).toEqual({ ok: true, value: "a" }); - expect(renderTemplate("A[[ - {part}]] B", { part: "" })).toEqual({ ok: true, value: "A B" }); - expect(renderTemplate("{title}/{year}", { title: "a/b" })).toEqual({ ok: false, missing: ["year"] }); - expect(renderTemplate(compileTemplate("{title}"), { title: "a/b" })).toEqual({ ok: true, value: "a/b" }); - expect(compileTemplate("{title}[[ ({year})]]").vars).toEqual(["title", "year"]); }); - it("模板校验:白名单、括号", () => { - for (const bad of ["{evil}", "{title", "a]]", "[[a", "[[ [[x]] ]]", "x".repeat(600)]) - expect(() => compileTemplate(bad), bad).toThrow(TemplateError); + it("模板集校验", () => { expect(() => validateTemplateSet(DEFAULT_TEMPLATES)).not.toThrow(); - expect(() => validateTemplateSet({ ...DEFAULT_TEMPLATES, dailyFile: "{constructor}" })).toThrow(TemplateError); + expect(() => validateTemplateSet({ ...DEFAULT_TEMPLATES, variety: "{{constructor}}" })).toThrow( + NamingTemplateError, + ); }); }); diff --git a/packages/mapping/test/convert.test.ts b/packages/mapping/test/convert.test.ts index d15a07e1..ee6a8b97 100644 --- a/packages/mapping/test/convert.test.ts +++ b/packages/mapping/test/convert.test.ts @@ -120,8 +120,8 @@ describe("PathBinding", () => { const creates = r.newBindings.map((nb) => M.toPathBindingCreate(nb, { mediaEntityId: U(1) })); for (const c of creates) expect(C.PathBindingCreate.safeParse(c).success, JSON.stringify(c)).toBe(true); expect(creates.map((c) => [c.bindingKind, c.seasonNumber ?? null, c.templateVersion])).toEqual([ - ["entity", null, "1"], - ["season", 2, "1"], + ["entity", null, "2"], + ["season", 2, "2"], ]); }); });