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