- root deno.json workspace (nodeModulesDir auto), npm versions registered once in imports, deno.lock; remove pnpm-lock/pnpm-workspace/root package.json; members keep minimal package.json (name/type/exports) - tasks: dev, typecheck (tsc 7.0.2 per member + deno check), lint/fmt (biome, useImportExtensions), test (vitest via deno), check:tokens, build (web + deno compile), ci, db:migrate/reset, gen:types - tsconfig base moved to config/ (no root tsconfig.json); deno types -> types/deno.d.ts; web drops baseUrl - server: Deno.serve entry with graceful shutdown, --role migrate|preflight|probe, WEB_ROOT static SPA hosting; drop @hono/node-server, tsx, esbuild build.mjs - logger: redact child() bindings and serialize err by message/stack; sync stdout destination; tests - relative imports get explicit extensions (sites/mp-sync paths left for follow-up) - crash-recovery tests spawn `deno run -A`; tasks tests use unique per-file schemas - single-container Dockerfile + entrypoint (migrate -> preflight -> api+worker watchdog), docker-compose.yml - docs: README, runbook, ADR-001 superseded, plan §1 validation row Co-Authored-By: Claude Fable 5.1 <[email protected]>
304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import {
|
||
buildNamingContext,
|
||
compileTemplate,
|
||
DEFAULT_TEMPLATES,
|
||
detectPathConflicts,
|
||
type PathBinding,
|
||
parseRelease,
|
||
renderMediaFileName,
|
||
renderTemplate,
|
||
resolveTargetDirectory,
|
||
sanitizeFileName,
|
||
sanitizeSegment,
|
||
TemplateError,
|
||
toDownloaderPath,
|
||
toHostPath,
|
||
utf8Length,
|
||
validatePathMappings,
|
||
validateTemplateSet,
|
||
} from "../src/index.ts";
|
||
|
||
const root = { id: "lib-tv", hostPath: "/media/tv" };
|
||
const binding = (over: Partial<PathBinding>): 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: 1 }],
|
||
});
|
||
});
|
||
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, seasonDir: "S{episode}" },
|
||
}),
|
||
).toMatchObject({ kind: "error", missing: ["episode"] });
|
||
});
|
||
});
|
||
|
||
describe("命名模板", () => {
|
||
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) - Extended - 1080p.mkv",
|
||
});
|
||
const ep = buildNamingContext({ title: "庆余年" }, parseRelease("Show.S02E01-E03.1080p"));
|
||
expect(ep.seasonEpisode).toBe("S02E01-E03");
|
||
expect(renderMediaFileName("tv", DEFAULT_TEMPLATES, ep, "mkv")).toMatchObject({
|
||
fileName: "庆余年 - S02E01-E03.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")).toEqual({ ok: false, missing: ["title"] });
|
||
});
|
||
it("上下文完整字段", () => {
|
||
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,
|
||
part: "CD2",
|
||
source: "WEB-DL",
|
||
videoCodec: "H.265",
|
||
hdr: "DV",
|
||
releaseGroup: "GRP",
|
||
});
|
||
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(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);
|
||
expect(() => validateTemplateSet(DEFAULT_TEMPLATES)).not.toThrow();
|
||
expect(() => validateTemplateSet({ ...DEFAULT_TEMPLATES, dailyFile: "{constructor}" })).toThrow(TemplateError);
|
||
});
|
||
});
|
||
|
||
describe("非法字符处理", () => {
|
||
it("转全角、去控制字符、首尾点空白、保留名、空串", () => {
|
||
expect(sanitizeSegment('A:B*C?"D<E>F|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([]);
|
||
});
|
||
});
|