import { describe, expect, it } from "vitest"; import { buildNamingContext, DEFAULT_TEMPLATES, detectPathConflicts, NamingTemplateError, type PathBinding, parseRelease, renderMediaFileName, resolveTargetDirectory, sanitizeFileName, sanitizeSegment, toDownloaderPath, toHostPath, utf8Length, validatePathMappings, validateTemplateSet, } from "../src/index.ts"; const root = { id: "lib-tv", hostPath: "/media/tv" }; const binding = (over: Partial): PathBinding => ({ id: "b1", entityId: "e1", scope: "entity", libraryRootId: "lib-tv", relativePath: "最后生还者 (2023)", templateVersion: 1, ...over, }); describe("路径绑定", () => { it("已有作品/季绑定优先且不随译名变化", () => { const r = resolveTargetDirectory({ entity: { id: "e1", kind: "tv" }, naming: { title: "最后的生还者", year: 2023 }, season: 1, bindings: [binding({}), binding({ id: "b2", scope: "season", season: 1, relativePath: "最后生还者 (2023)/S1" })], libraryRoot: root, templates: DEFAULT_TEMPLATES, }); expect(r).toEqual({ kind: "existing", libraryRootId: "lib-tv", relativePath: "最后生还者 (2023)/S1", bindingIds: ["b2"], }); }); it("作品已绑定、新季:只按模板渲染季目录,作品目录复用", () => { const r = resolveTargetDirectory({ entity: { id: "e1", kind: "tv" }, naming: { title: "新译名", year: 2023 }, season: 2, bindings: [binding({})], libraryRoot: { id: "other", hostPath: "/x" }, templates: DEFAULT_TEMPLATES, }); expect(r).toMatchObject({ kind: "new", libraryRootId: "lib-tv", relativePath: "最后生还者 (2023)/Season 2", reusedBindingIds: ["b1"], newBindings: [{ scope: "season", season: 2, templateVersion: 2 }], }); }); it("首次:作品 + 季两级绑定,非法字符转全角", () => { const r = resolveTargetDirectory({ entity: { id: "e9", kind: "tv" }, naming: { title: "Show: Part/2" }, season: 1, bindings: [binding({})], libraryRoot: root, templates: DEFAULT_TEMPLATES, }); expect(r).toMatchObject({ kind: "new", relativePath: "Show: Part/2/Season 1" }); if (r.kind === "new") expect(r.newBindings).toHaveLength(2); }); it("电影:已有 / 首次 / 模板缺变量", () => { const mv = { entity: { id: "m1", kind: "movie" as const }, libraryRoot: root, templates: DEFAULT_TEMPLATES }; expect( resolveTargetDirectory({ ...mv, naming: { title: "X" }, bindings: [binding({ entityId: "m1", relativePath: "沙丘2 (2024)" })], }), ).toMatchObject({ kind: "existing", relativePath: "沙丘2 (2024)" }); expect( resolveTargetDirectory({ ...mv, naming: { title: "沙丘:第二部", year: 2024 }, bindings: [] }), ).toMatchObject({ kind: "new", relativePath: "沙丘:第二部 (2024)", }); expect(resolveTargetDirectory({ ...mv, naming: {}, bindings: [] })).toEqual({ kind: "error", reason: "template_missing_vars", missing: ["title"], }); }); it("两个历史目录 → conflict,不自动合并;停用绑定忽略", () => { const two = [binding({}), binding({ id: "b3", relativePath: "The Last of Us (2023)" })]; expect( resolveTargetDirectory({ entity: { id: "e1", kind: "tv" }, naming: { title: "x" }, season: 1, bindings: two, libraryRoot: root, templates: DEFAULT_TEMPLATES, }), ).toMatchObject({ kind: "conflict", reason: "multiple_entity_bindings" }); const inactive = [binding({}), binding({ id: "b3", relativePath: "Old", active: false })]; expect( resolveTargetDirectory({ entity: { id: "e1", kind: "tv" }, naming: { title: "x" }, season: 1, bindings: inactive, libraryRoot: root, templates: DEFAULT_TEMPLATES, }).kind, ).toBe("new"); const seasons = [ binding({ id: "s1", scope: "season", season: 1, relativePath: "A/S1" }), binding({ id: "s2", scope: "season", season: 1, relativePath: "A/Season 1" }), ]; expect( resolveTargetDirectory({ entity: { id: "e1", kind: "tv" }, naming: { title: "x" }, season: 1, bindings: seasons, libraryRoot: root, templates: DEFAULT_TEMPLATES, }), ).toMatchObject({ kind: "conflict", reason: "multiple_season_bindings" }); }); it("剧集缺季号 / 模板缺变量", () => { const tv = { entity: { id: "e5", kind: "tv" as const }, bindings: [], libraryRoot: root }; expect(resolveTargetDirectory({ ...tv, naming: { title: "x" }, templates: DEFAULT_TEMPLATES })).toEqual({ kind: "error", reason: "season_required", }); expect(resolveTargetDirectory({ ...tv, naming: {}, season: 1, templates: DEFAULT_TEMPLATES })).toMatchObject({ kind: "error", missing: ["title"], }); expect( resolveTargetDirectory({ ...tv, naming: { title: "x" }, season: 1, templates: { ...DEFAULT_TEMPLATES, tv: "{{title}}/{{evil}}/{{title}}{{fileExt}}" }, }), ).toMatchObject({ kind: "error", reason: "template_error" }); }); }); describe("命名模板(旧入口 buildNamingContext / renderMediaFileName)", () => { it("电影 / 剧集 / 多集区间 / 综艺日期", () => { const movie = buildNamingContext( { title: "沙丘2", year: 2024 }, parseRelease("Dune.Part.Two.2024.2160p.UHD.BluRay.x265-CHD"), ); expect(renderMediaFileName("movie", DEFAULT_TEMPLATES, movie, "MKV")).toEqual({ ok: true, fileName: "沙丘2 (2024) - 2160p.mkv", }); const cut = buildNamingContext({ title: "指环王", year: 2001 }, parseRelease("LOTR.2001.Extended.1080p.BluRay")); expect(renderMediaFileName("movie", DEFAULT_TEMPLATES, cut, "mkv")).toMatchObject({ fileName: "指环王 (2001) - 1080p.mkv", }); const ep = buildNamingContext({ title: "庆余年" }, parseRelease("Show.S02E01-E03.1080p")); expect(ep.season_episode).toBe("S02E01-E03"); expect(renderMediaFileName("tv", DEFAULT_TEMPLATES, ep, "mkv")).toMatchObject({ 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", }); expect(renderMediaFileName("tv", DEFAULT_TEMPLATES, { title: "x" }, "mp4")).toEqual({ ok: false, missing: ["episode_or_air_date"], }); expect(renderMediaFileName("movie", DEFAULT_TEMPLATES, {}, "mp4")).toMatchObject({ ok: false, missing: ["title"] }); }); 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({ original_title: "O", season: "1", episode: "5", season_episode: "S01E05", part: "CD2", 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", title_year: "T", type: "电影", total_episodes: 0, }); }); it("模板集校验", () => { expect(() => validateTemplateSet(DEFAULT_TEMPLATES)).not.toThrow(); expect(() => validateTemplateSet({ ...DEFAULT_TEMPLATES, variety: "{{constructor}}" })).toThrow( NamingTemplateError, ); }); }); describe("非法字符处理", () => { it("转全角、去控制字符、首尾点空白、保留名、空串", () => { expect(sanitizeSegment('A:B*C?"DF|G\\H/I')).toBe("A:B*C?"D<E>F|G\H/I"); expect(sanitizeSegment(" ..hidden. ")).toBe("hidden"); expect(sanitizeSegment("a\u0000b\u001f\u007fc")).toBe("abc"); expect(sanitizeSegment("CON")).toBe("CON_"); expect(sanitizeSegment("...")).toBe("_"); expect(sanitizeSegment("a b")).toBe("a b"); }); it("UTF-8 字节截断,扩展名保留", () => { const long = "长".repeat(200); const s = sanitizeSegment(long); expect(utf8Length(s)).toBeLessThanOrEqual(255); const f = sanitizeFileName(long, ".MKV"); expect(f.endsWith(".mkv")).toBe(true); expect(utf8Length(f)).toBeLessThanOrEqual(255); expect(sanitizeFileName("a", "")).toBe("a"); }); }); describe("冲突检测", () => { it("计划内重复 / 已存在 / 大小写碰撞", () => { const out = detectPathConflicts( [ { src: "1", dst: "/m/A.mkv" }, { src: "2", dst: "/m/A.mkv" }, { src: "3", dst: "/m/b.mkv" }, { src: "4", dst: "/m/C.mkv" }, { src: "5", dst: "/m/c.mkv" }, ], [{ path: "/m/A.mkv", size: 1 }, { path: "/m/B.mkv" }], { caseInsensitive: true }, ); expect(out.map((c) => `${c.kind}:${c.dst}`)).toEqual([ "duplicate_in_plan:/m/A.mkv", "exists:/m/A.mkv", "case_collision:/m/b.mkv", "case_collision:/m/c.mkv", ]); expect(detectPathConflicts([{ src: "3", dst: "/m/b.mkv" }], [{ path: "/m/B.mkv" }])).toEqual([]); }); }); describe("下载器 ↔ 宿主路径映射", () => { const maps = [ { instanceId: "qb1", downloaderPath: "/downloads", hostPath: "/mnt/cache/qb1" }, { instanceId: "qb1", downloaderPath: "/downloads/tv", hostPath: "/mnt/array/tv-dl" }, { instanceId: "qb2", downloaderPath: "D:\\Downloads\\", hostPath: "/mnt/win" }, { instanceId: "qb3", downloaderPath: "/", hostPath: "/srv/qb3" }, ]; it("最长前缀、按段边界、多实例", () => { expect(toHostPath("/downloads/movie/a.mkv", "qb1", maps)).toMatchObject({ ok: true, path: "/mnt/cache/qb1/movie/a.mkv", }); expect(toHostPath("/downloads/tv/s/a.mkv", "qb1", maps)).toMatchObject({ ok: true, path: "/mnt/array/tv-dl/s/a.mkv", }); expect(toHostPath("/downloads2/a.mkv", "qb1", maps)).toEqual({ ok: false, reason: "no_mapping" }); expect(toHostPath("/downloads", "qb1", maps)).toMatchObject({ ok: true, path: "/mnt/cache/qb1" }); expect(toHostPath("D:\\Downloads\\x\\a.mkv", "qb2", maps)).toMatchObject({ ok: true, path: "/mnt/win/x/a.mkv" }); expect(toHostPath("/any/a.mkv", "qb3", maps)).toMatchObject({ ok: true, path: "/srv/qb3/any/a.mkv" }); expect(toHostPath("/downloads/a", "nope", maps)).toEqual({ ok: false, reason: "no_mapping" }); expect(toDownloaderPath("/mnt/cache/qb1/movie/a.mkv", "qb1", maps)).toMatchObject({ ok: true, path: "/downloads/movie/a.mkv", }); expect(toDownloaderPath("/srv/qb3/a", "qb3", maps)).toMatchObject({ ok: true, path: "/a" }); }); it("同前缀多目标 → ambiguous / 配置校验", () => { const bad = [ { instanceId: "q", downloaderPath: "/d", hostPath: "/a" }, { instanceId: "q", downloaderPath: "/d/", hostPath: "/b" }, ]; expect(toHostPath("/d/x", "q", bad)).toEqual({ ok: false, reason: "ambiguous" }); expect(validatePathMappings(bad)).toHaveLength(1); expect(validatePathMappings(maps)).toEqual([]); }); });