feat(organize): naming endpoints and override chain for target paths

- GET /naming/variables, POST /naming/preview (builtin/fileName/sourceRelease/asset samples,
  line:col errors, warnings, binding-aware), GET/PUT /naming/templates,
  PUT /library-roots/{id}/naming, PUT /media-entities/{id}/naming-override
- planImport picks templates entity > library root > global, registers the template
  version for new path bindings; naming vars include en title, external ids, season/
  episode metadata, slot and root names; optional lock of file-name template for bound works
- runbook: docs/runbook/naming.md

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 15:25:08 +08:00
1 parent dfb08ab552
commit 8651a35594
6 files changed
+805 -12

No files matched your search

@@ -0,0 +1,175 @@
import { createHash } from "node:crypto";
import { DEFAULT_NAMING_TEMPLATES } from "@umt/contracts";
import { LEGACY_V1_NAMING, MP_DEFAULT_NAMING, namingCanonicalJson } from "@umt/domain";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { ensureNamingVersion, namingByVersion } from "../services/naming.ts";
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
// W9-C 命名模板:变量表、预览(样本/错误/路径绑定)、三级模板读写与保存前校验、模板版本登记。
const hasPg = await pgAvailable();
it("contracts 默认模板 = domain MP 默认", () => {
expect(DEFAULT_NAMING_TEMPLATES).toEqual(MP_DEFAULT_NAMING);
});
describe.skipIf(!hasPg)("命名模板 API(真实 PG)", () => {
let h: Harness;
let rootId: string;
beforeAll(async () => {
h = await createHarness("naming");
({ rootId } = await seedBase(h.db, {
libraryPath: "/media/lib",
cachePath: "/cache",
qbUrl: "http://127.0.0.1:1",
}));
}, 60_000);
afterAll(async () => {
await h?.close();
});
it("迁移种子:版本 1 = v1 旧模板,2 = MP 默认,指纹与 domain 同算法;新模板集按内容登记版本", async () => {
const rows = await h.db.selectFrom("naming_template_version").selectAll().orderBy("version").execute();
const fp = (t: typeof MP_DEFAULT_NAMING) => createHash("sha256").update(namingCanonicalJson(t)).digest("hex");
expect(rows.map((r) => [r.version, r.fingerprint])).toEqual([
[1, fp(LEGACY_V1_NAMING)],
[2, fp(MP_DEFAULT_NAMING)],
]);
expect((await ensureNamingVersion(h.db, { ...MP_DEFAULT_NAMING })).version).toBe(2);
const custom = { ...MP_DEFAULT_NAMING, movie: "{{title}}/{{title}}{{fileExt}}" };
const v = (await ensureNamingVersion(h.db, custom)).version;
expect(v).toBeGreaterThanOrEqual(3);
expect((await ensureNamingVersion(h.db, custom)).version).toBe(v);
expect(await namingByVersion(h.db, v)).toEqual({ ...custom, version: v });
expect(await namingByVersion(h.db, 1)).toEqual({ ...LEGACY_V1_NAMING, version: 1 });
expect(await namingByVersion(h.db, 9999)).toBeUndefined();
});
it("GET /naming/variables", async () => {
const r = await h.api("GET", "/naming/variables");
expect(r.status).toBe(200);
const names = r.body.data.items.map((i: { name: string }) => i.name);
expect(names).toEqual(expect.arrayContaining(["title", "videoFormat", "season_episode", "fileExt", "slotName"]));
expect(r.body.data.items.find((i: { name: string }) => i.name === "airDate")).toMatchObject({ ours: true });
expect(r.body.data.filters.map((f: { name: string }) => f.name)).toContain("pad");
expect(r.body.data.samples).toHaveLength(6);
});
it("POST /naming/preview:内置样本 / 手填文件名 / 编译错误行列号 / 提示", async () => {
const a = await h.api("POST", "/naming/preview", { kind: "tv", sample: { builtin: 3 } });
expect(a.status).toBe(200);
expect(a.body.data).toMatchObject({
ok: true,
relativePath: "绝命毒师 (2008)/Season 1/绝命毒师 - S01E01 - 第 1 集.mkv",
usesBinding: false,
templateSource: "global",
segments: [
{ text: "绝命毒师 (2008)", role: "root" },
{ text: "Season 1", role: "season" },
{ text: "绝命毒师 - S01E01 - 第 1 集.mkv", role: "file" },
],
});
const b = await h.api("POST", "/naming/preview", {
kind: "movie",
template: "{{title}} ({{year}})/{{title}} - {{videoFormat}}{{fileExt}}",
sample: { fileName: "Inception.2010.1080p.BluRay.x264-CMCT.mkv" },
});
expect(b.body.data).toMatchObject({
ok: true,
relativePath: "Inception (2010)/Inception - 1080p.mkv",
templateSource: "input",
});
expect(b.body.data.warnings).toEqual(expect.arrayContaining([expect.stringContaining("year 可能为空")]));
const c = await h.api("POST", "/naming/preview", {
kind: "movie",
template: "{{title}}/\n{{videoformat}}{{fileExt}}",
});
expect(c.body.data).toMatchObject({ ok: false, relativePath: "" });
expect(c.body.data.errors[0]).toMatchObject({ code: "unknown_variable", line: 2, col: 3 });
expect(c.body.data.errors[0].message).toContain("videoFormat");
});
it("三级覆盖:全局保存校验、库根与作品覆盖、GET /naming/templates 生效来源", async () => {
const bad = await h.api("PUT", "/naming/templates", { movie: "{{title}}/{{title}}" });
expect(bad.status).toBe(422);
const ok = await h.api("PUT", "/naming/templates", {
movie: "{{title_year}}/{{title}}{{fileExt}}",
lockFileNameForBound: true,
});
expect(ok.status).toBe(200);
expect(ok.body.data).toMatchObject({ movie: "{{title_year}}/{{title}}{{fileExt}}", lockFileNameForBound: true });
expect((await h.ctx.settings.get("naming")).lockFileNameForBound).toBe(true);
const e = await seedEntity(h.db, { kind: "series", title: "绝命毒师", year: 2008 });
expect((await h.api("PUT", `/library-roots/${rootId}/naming`, { naming: { tv: "{{title}}" } })).status).toBe(422);
expect(
(
await h.api("PUT", `/library-roots/${rootId}/naming`, {
naming: { tv: "{{title}}/{{title}} {{season_episode}}{{fileExt}}" },
})
).status,
).toBe(200);
const put = await h.api("PUT", `/media-entities/${e}/naming-override`, {
namingOverride: { variety: "{{title}}/{{title}} {{airDate}}{{fileExt}}" },
});
expect(put.body.data).toEqual({ namingOverride: { variety: "{{title}}/{{title}} {{airDate}}{{fileExt}}" } });
const view = await h.api("GET", `/naming/templates?libraryRootId=${rootId}&entityId=${e}`);
expect(view.body.data.sources).toEqual({ movie: "global", tv: "root", variety: "entity" });
expect(view.body.data.effective.tv).toBe("{{title}}/{{title}} {{season_episode}}{{fileExt}}");
const pv = await h.api("POST", "/naming/preview", {
kind: "tv",
entityId: e,
libraryRootId: rootId,
sample: { fileName: "Breaking.Bad.S01E02.1080p.WEB-DL.H264-NTb.mkv" },
});
expect(pv.body.data).toMatchObject({
ok: true,
relativePath: "绝命毒师/绝命毒师 S01E02.mkv",
templateSource: "root",
});
await h.api("PUT", `/library-roots/${rootId}/naming`, { naming: null });
await h.api("PUT", "/naming/templates", { ...MP_DEFAULT_NAMING, lockFileNameForBound: false });
});
it("预览:已有路径绑定时目录用绑定路径,文件名按模板", async () => {
const e = await seedEntity(h.db, { kind: "series", title: "新译名", year: 2008 });
await h.db
.insertInto("path_binding")
.values([
{
media_entity_id: e,
binding_kind: "entity",
library_root_id: rootId,
relative_path: "旧名 (2008)",
template_version: "1",
},
{
media_entity_id: e,
binding_kind: "season",
season_number: 1,
library_root_id: rootId,
relative_path: "旧名 (2008)/S1",
template_version: "1",
},
])
.execute();
const r = await h.api("POST", "/naming/preview", {
kind: "tv",
entityId: e,
sample: { fileName: "Show.S01E03.1080p.WEB-DL.H264-NTb.mkv" },
});
expect(r.body.data).toMatchObject({
ok: true,
usesBinding: true,
relativePath: "旧名 (2008)/S1/新译名 - S01E03 - 第 3 集.mkv",
segments: [
{ text: "旧名 (2008)", role: "root" },
{ text: "S1", role: "season" },
{ text: "新译名 - S01E03 - 第 3 集.mkv", role: "file" },
],
});
});
});
@@ -9,6 +9,7 @@ import { registerAssetRoutes } from "./routes/assets.ts";
import { registerDashboardRoutes } from "./routes/dashboard.ts";
import { registerDownloadRoutes } from "./routes/downloads.ts";
import { registerLibraryRoutes } from "./routes/library.ts";
import { registerNamingRoutes } from "./routes/naming.ts";
import { registerOrganizationRoutes } from "./routes/organization.ts";
import { registerRuleRoutes } from "./routes/rules.ts";
import { type AoDeps, automationBlock, enqueueSweep, enqueueTracked, timeSlot } from "./services/common.ts";
@@ -71,6 +72,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
registerLibraryRoutes(app, d);
registerDashboardRoutes(app, d);
registerAssetRoutes(app, d);
registerNamingRoutes(app, d);
},
registerTasks(registry, ctx) {
registerAoTasks(registry, lazy(ctx));
@@ -0,0 +1,327 @@
import {
Api,
DEFAULT_SETTINGS,
type NamingPreviewInput,
type NamingPreviewResult,
NamingSettings,
type NamingTemplatesView,
type PathBinding,
} from "@umt/contracts";
import type { LibraryRootRow, MediaEntityRow } from "@umt/db";
import {
buildNamingVars,
MP_DEFAULT_NAMING,
NAMING_FILTERS,
NAMING_SAMPLES,
NAMING_VARIABLES,
type NamingEntityInput,
type NamingKind,
type NamingSegment,
parseRelease,
renderNamingPath,
resolveTargetDirectory,
validateNamingTemplate,
} from "@umt/domain";
import { toDomainPathBinding } from "@umt/mapping";
import { ApiException } from "../../../http/errors.ts";
import { type CachedSettingsReader, SETTINGS_CHANGED_EVENT } from "../../auth-settings/provided.ts";
import type { ModuleApp } from "../../types.ts";
import type { AoDeps } from "../services/common.ts";
import { effectiveNaming, loadNamingEntity, namingErrors, toOverride } from "../services/naming.ts";
import { actorOf, jsonb, notFound, toApi } from "../util.ts";
import { mount } from "./helpers.ts";
// 整理命名模板(docs/design/naming-template.md §8):变量表、预览、三级模板读写(保存前逐条编译校验)。
const TAG = "naming";
const isCached = (s: unknown): s is CachedSettingsReader =>
typeof (s as CachedSettingsReader)?.invalidate === "function";
export function registerNamingRoutes(app: ModuleApp, d: AoDeps) {
const db = () => d.ctx.db;
const namingSettings = async () =>
(await d.ctx.settings.get("naming").catch(() => undefined)) ?? DEFAULT_SETTINGS.naming;
const entityRow = async (id: string) => {
const e = await db().selectFrom("media_entity").selectAll().where("id", "=", id).executeTakeFirst();
if (!e) throw notFound("作品");
return e;
};
const rootRow = async (id: string) => {
const r = await db().selectFrom("library_root").selectAll().where("id", "=", id).executeTakeFirst();
if (!r) throw notFound("库根");
return r;
};
const audit = (
c: Parameters<typeof actorOf>[0],
action: string,
targetType: string,
targetId: string | null,
payload: unknown,
) =>
db()
.insertInto("audit_event")
.values({
actor_type: actorOf(c).startsWith("api_key:") ? "api_key" : "user",
actor_id: actorOf(c),
action,
target_type: targetType,
target_id: targetId,
payload: jsonb(payload),
})
.execute();
mount(
app,
Api.naming.variables,
async () => ({
items: NAMING_VARIABLES.map(({ name, group, label, example, ours }) => ({ name, group, label, example, ours })),
filters: Object.entries(NAMING_FILTERS).map(([name, f]) => ({ name, doc: f.doc })),
samples: NAMING_SAMPLES.map(({ id, label, kind, fileName }) => ({ id, label, kind, fileName })),
}),
{ tag: TAG },
);
mount(
app,
Api.naming.templates,
async (_c, { query }) => {
const q = query as { libraryRootId?: string; entityId?: string };
const global = await namingSettings();
const root = q.libraryRootId ? await rootRow(q.libraryRootId) : null;
const entity = q.entityId ? await entityRow(q.entityId) : null;
const eff = effectiveNaming(global, root, entity);
return {
global,
defaults: { ...MP_DEFAULT_NAMING },
root: toOverride(root?.naming),
entity: toOverride(entity?.naming_override),
effective: eff.templates,
sources: eff.sources,
} satisfies NamingTemplatesView;
},
{ tag: TAG },
);
mount(
app,
Api.naming.saveTemplates,
async (c, { body }) => {
const errors = namingErrors(body as Partial<NamingSettings>);
if (errors.length) throw new ApiException("validation_failed", errors.join("; "));
const next = NamingSettings.parse({ ...(await namingSettings()), ...(body as object) });
await db()
.insertInto("settings")
.values({ key: "naming", value: JSON.stringify(next), updated_at: new Date() })
.onConflict((oc) => oc.column("key").doUpdateSet({ value: JSON.stringify(next), updated_at: new Date() }))
.execute();
if (isCached(d.ctx.settings)) d.ctx.settings.invalidate();
void d.ctx.events?.publish(SETTINGS_CHANGED_EVENT, { keys: ["naming"] });
await audit(c, "naming.update", "settings", "naming", { patch: body });
return next;
},
{ tag: TAG },
);
mount(
app,
Api.naming.libraryRoot,
async (c, { params, body }) => {
await rootRow(params.id);
const naming = toOverride((body as { naming: unknown }).naming);
const errors = naming ? namingErrors(naming) : [];
if (errors.length) throw new ApiException("validation_failed", errors.join("; "));
await db()
.updateTable("library_root")
.set({ naming: naming ? JSON.stringify(naming) : null })
.where("id", "=", params.id)
.execute();
await audit(c, "library_root.naming", "library_root", params.id, { naming });
return { naming };
},
{ tag: TAG },
);
mount(
app,
Api.naming.entity,
async (c, { params, body }) => {
await entityRow(params.id);
const namingOverride = toOverride((body as { namingOverride: unknown }).namingOverride);
const errors = namingOverride ? namingErrors(namingOverride) : [];
if (errors.length) throw new ApiException("validation_failed", errors.join("; "));
await db()
.updateTable("media_entity")
.set({ naming_override: namingOverride ? JSON.stringify(namingOverride) : null })
.where("id", "=", params.id)
.execute();
await audit(c, "media_entity.naming_override", "media_entity", params.id, { namingOverride });
return { namingOverride };
},
{ tag: TAG },
);
mount(app, Api.naming.preview, async (_c, { body }) => preview(d, body as NamingPreviewInput, namingSettings), {
tag: TAG,
status: 200,
});
}
interface SampleSource {
fileName: string;
entity: MediaEntityRow | null;
fallback: NamingEntityInput;
}
async function loadSample(d: AoDeps, input: NamingPreviewInput): Promise<SampleSource> {
const db = d.ctx.db;
const s = input.sample;
const pseudo = (fileName: string): NamingEntityInput => {
const p = parseRelease(fileName);
return { title: p.title ?? "示例作品", ...(p.year ? { year: p.year } : {}) };
};
if (!s) {
const b = NAMING_SAMPLES.find((x) => x.kind === input.kind) ?? NAMING_SAMPLES[0]!;
return { fileName: b.fileName, entity: null, fallback: b.entity };
}
if ("builtin" in s) {
const b = NAMING_SAMPLES.find((x) => x.id === s.builtin);
if (!b) throw new ApiException("validation_failed", `没有内置样本 ${s.builtin}`);
return { fileName: b.fileName, entity: null, fallback: b.entity };
}
if ("fileName" in s) return { fileName: s.fileName, entity: null, fallback: pseudo(s.fileName) };
if ("sourceReleaseId" in s) {
const rel = await db
.selectFrom("source_release")
.select(["title", "media_entity_id"])
.where("id", "=", s.sourceReleaseId)
.executeTakeFirst();
if (!rel) throw notFound("资源");
const e = rel.media_entity_id
? await db.selectFrom("media_entity").selectAll().where("id", "=", rel.media_entity_id).executeTakeFirst()
: undefined;
return { fileName: rel.title, entity: e ?? null, fallback: pseudo(rel.title) };
}
const asset = await db
.selectFrom("media_asset as a")
.leftJoin("asset_location as l", "l.media_asset_id", "a.id")
.select(["a.media_entity_id", "l.relative_path"])
.where("a.id", "=", s.assetId)
.orderBy("l.created_at", "desc")
.executeTakeFirst();
if (!asset) throw notFound("资产");
const fileName = (asset.relative_path ?? "").split("/").pop() || "asset.mkv";
const e = asset.media_entity_id
? await db.selectFrom("media_entity").selectAll().where("id", "=", asset.media_entity_id).executeTakeFirst()
: undefined;
return { fileName, entity: e ?? null, fallback: pseudo(fileName) };
}
async function preview(
d: AoDeps,
input: NamingPreviewInput,
namingSettings: () => Promise<NamingSettings>,
): Promise<NamingPreviewResult> {
const db = d.ctx.db;
const kind: NamingKind = input.kind;
const sample = await loadSample(d, input);
let entity: MediaEntityRow | null = sample.entity;
if (input.entityId) {
entity =
(await db.selectFrom("media_entity").selectAll().where("id", "=", input.entityId).executeTakeFirst()) ?? null;
if (!entity) throw notFound("作品");
}
const bindingRows = entity
? await db
.selectFrom("path_binding")
.selectAll()
.where("media_entity_id", "=", entity.id)
.where("status", "=", "active")
.execute()
: [];
const bindings = bindingRows.map((r) => toDomainPathBinding(toApi<PathBinding>(r)));
const rootId = input.libraryRootId ?? bindings[0]?.libraryRootId;
let root: LibraryRootRow | null = null;
if (rootId) {
root = (await db.selectFrom("library_root").selectAll().where("id", "=", rootId).executeTakeFirst()) ?? null;
if (!root && input.libraryRootId) throw notFound("库根");
}
const eff = effectiveNaming(await namingSettings(), root, entity);
const template = input.template ?? eff.templates[kind];
const templateSource = input.template !== undefined ? ("input" as const) : eff.sources[kind];
const namingEntity: NamingEntityInput = entity
? { ...(await loadNamingEntity(db, entity, { libraryRoot: root?.name })), kind }
: { ...sample.fallback, kind, ...(root ? { libraryRoot: root.name } : {}) };
const parsed = parseRelease(sample.fileName);
const ext = parsed.extension ? `.${parsed.extension}` : ".mkv";
const vars = buildNamingVars(namingEntity, parsed, { fileExt: ext });
const base = {
templateSource,
template,
sample: { fileName: sample.fileName, entityTitle: namingEntity.title },
vars,
};
const v = validateNamingTemplate(template, kind);
if (!v.ok)
return {
ok: false,
relativePath: "",
segments: v.roles.map((r) => ({ text: r.segment, role: r.role })),
errors: v.errors,
warnings: v.warnings,
missing: [],
usesBinding: false,
...base,
};
const r = renderNamingPath(template, vars);
const out = (segments: { text: string; role: NamingSegment["role"] }[], usesBinding: boolean) => ({
ok: r.ok,
relativePath: r.ok ? segments.map((s) => s.text).join("/") : "",
segments,
errors: r.errors,
warnings: v.warnings,
missing: r.missing,
usesBinding,
...base,
});
const plain = r.segments.filter((s) => s.text).map(({ text, role }) => ({ text, role }));
const hasBinding =
entity !== null &&
bindings.some((b) => b.scope === "entity" || (kind !== "movie" && b.season === Number(vars.season)));
if (!r.ok || !entity || !hasBinding) return out(plain, false);
// 已有路径绑定:目录用绑定路径(模板只决定文件名与绑定之后的子目录段)
const season = kind === "movie" ? undefined : Number(vars.season);
const dec = resolveTargetDirectory({
entity: { id: entity.id, kind: kind === "movie" ? "movie" : "tv" },
naming: vars,
namingKind: kind,
...(season !== undefined ? { season } : {}),
bindings,
libraryRoot: { id: root?.id ?? "preview", hostPath: root?.local_path ?? "" },
templates: { ...eff.templates, [kind]: template, version: 0 },
});
if (dec.kind !== "existing" && dec.kind !== "new") return out(plain, false);
const entityPath = bindings.find((b) => b.scope === "entity")?.relativePath ?? "";
const seasonPath =
dec.kind === "new"
? (dec.newBindings.find((b) => b.scope === "season")?.relativePath ?? "")
: (bindings.find((b) => b.scope === "season" && b.season === season)?.relativePath ?? "");
const parts = dec.relativePath.split("/").filter(Boolean);
const nEntity = entityPath ? entityPath.split("/").length : 0;
const nSeason = seasonPath ? seasonPath.split("/").length : nEntity;
const segs = parts.map((text, i) => ({
text,
role: (i < nEntity - 1
? "category"
: i === nEntity - 1
? "root"
: i < nSeason
? "season"
: "sub") as NamingSegment["role"],
}));
const file = plain.find((s) => s.role === "file");
return out(file ? [...segs, file] : segs, true);
}
@@ -0,0 +1,176 @@
import { createHash } from "node:crypto";
import type { NamingOverride as NamingOverrideDto, NamingSettings } from "@umt/contracts";
import type { LibraryRootRow, MediaEntityRow } from "@umt/db";
import {
LEGACY_NAMING_VERSION,
LEGACY_V1_NAMING,
MP_DEFAULT_NAMING,
type NamingEntityInput,
type NamingKind,
type NamingSource,
type NamingTemplateSet,
type NamingTemplates,
namingCanonicalJson,
namingKindOf,
resolveNamingTemplates,
validateNamingTemplate,
} from "@umt/domain";
import type { Exec } from "./common.ts";
// 命名模板的存取:三级回落(作品 > 库根 > 全局)、生效模板集的版本登记、命名变量来源(英文名/外部 ID/季集元数据)。
const KINDS: readonly NamingKind[] = ["movie", "tv", "variety"];
/** jsonb 覆盖值 → 只保留非空字符串的类型键 */
export function toOverride(v: unknown): NamingOverrideDto | null {
if (!v || typeof v !== "object" || Array.isArray(v)) return null;
const out: NamingOverrideDto = {};
for (const k of KINDS) {
const s = (v as Record<string, unknown>)[k];
if (typeof s === "string" && s.trim()) out[k] = s;
}
return Object.keys(out).length ? out : null;
}
export function globalTemplates(s: NamingSettings | undefined): NamingTemplates {
return s ? { movie: s.movie, tv: s.tv, variety: s.variety } : { ...MP_DEFAULT_NAMING };
}
export interface EffectiveNaming {
templates: NamingTemplates;
sources: Record<NamingKind, NamingSource>;
}
export function effectiveNaming(
global: NamingSettings | undefined,
root: Pick<LibraryRootRow, "naming"> | null | undefined,
entity: Pick<MediaEntityRow, "naming_override"> | null | undefined,
): EffectiveNaming {
return resolveNamingTemplates({
global: globalTemplates(global),
root: toOverride(root?.naming),
entity: toOverride(entity?.naming_override),
});
}
/** 保存前校验一组模板;返回 `类型: 行:列 原因` 列表(空 = 通过) */
export function namingErrors(t: Partial<Record<NamingKind, string | undefined>>): string[] {
const out: string[] = [];
for (const k of KINDS) {
const src = t[k];
if (src === undefined) continue;
for (const e of validateNamingTemplate(src, k).errors) out.push(`${k}: ${e.line}:${e.col} ${e.message}`);
}
return out;
}
const fingerprint = (t: NamingTemplates) => createHash("sha256").update(namingCanonicalJson(t)).digest("hex");
/** 生效模板集 → 版本号(按内容指纹登记,相同内容复用同一版本) */
export async function ensureNamingVersion(exec: Exec, t: NamingTemplates): Promise<NamingTemplateSet> {
const fp = fingerprint(t);
const found = await exec
.selectFrom("naming_template_version")
.select("version")
.where("fingerprint", "=", fp)
.executeTakeFirst();
if (found) return { ...t, version: found.version };
await exec
.insertInto("naming_template_version")
.values((eb) => ({
version: eb.fn<number>("nextval", [eb.val("naming_template_version_seq")]),
fingerprint: fp,
templates: JSON.stringify(t),
}))
.onConflict((oc) => oc.column("fingerprint").doNothing())
.execute();
const row = await exec
.selectFrom("naming_template_version")
.select("version")
.where("fingerprint", "=", fp)
.executeTakeFirstOrThrow();
return { ...t, version: row.version };
}
/** 按版本取回当时的模板集(锁定文件名模板用);找不到返回 undefined */
export async function namingByVersion(exec: Exec, version: number): Promise<NamingTemplateSet | undefined> {
if (version === LEGACY_NAMING_VERSION) return { ...LEGACY_V1_NAMING, version };
const row = await exec
.selectFrom("naming_template_version")
.select(["version", "templates"])
.where("version", "=", version)
.executeTakeFirst();
if (!row) return undefined;
const t = row.templates as Partial<NamingTemplates>;
if (typeof t.movie !== "string" || typeof t.tv !== "string" || typeof t.variety !== "string") return undefined;
return { movie: t.movie, tv: t.tv, variety: t.variety, version: row.version };
}
/** 命名变量来源:作品 + 英文名(title_alias en*)+ 外部 ID + 季/集元数据 + 库根名 + 版本目标名 */
export async function loadNamingEntity(
exec: Exec,
entity: MediaEntityRow,
extra: { libraryRoot?: string | undefined; slotId?: string | undefined } = {},
): Promise<NamingEntityInput> {
const [aliases, ids, units, slot] = await Promise.all([
exec
.selectFrom("title_alias")
.select(["alias", "language", "locked"])
.where("media_entity_id", "=", entity.id)
.where("content_unit_id", "is", null)
.execute(),
exec
.selectFrom("external_identity")
.select(["provider", "external_id"])
.where("media_entity_id", "=", entity.id)
.where("content_unit_id", "is", null)
.where("status", "=", "accepted")
.execute(),
exec
.selectFrom("content_unit")
.select(["kind", "season_number", "episode_number", "title", "air_date", "episode_total", "metadata"])
.where("media_entity_id", "=", entity.id)
.where("season_number", "is not", null)
.execute(),
extra.slotId
? exec.selectFrom("acquisition_slot").select("name").where("id", "=", extra.slotId).executeTakeFirst()
: Promise.resolve(undefined),
]);
const en = aliases
.filter((a) => /^en\b|^en-/i.test(a.language ?? ""))
.sort((a, b) => Number(b.locked) - Number(a.locked))[0]?.alias;
const idOf = (p: string) => ids.find((i) => i.provider === p)?.external_id;
const seasons: Record<number, { year?: number; episodeTotal?: number }> = {};
const episodes: Record<string, { title?: string; airDate?: string }> = {};
for (const u of units) {
const s = u.season_number!;
if (u.episode_number === null) {
const year = u.air_date ? Number(String(u.air_date).slice(0, 4)) : undefined;
seasons[s] = {
...(year ? { year } : {}),
...(u.episode_total !== null ? { episodeTotal: u.episode_total } : {}),
};
} else {
episodes[`${s}:${u.episode_number}`] = {
...(u.title ? { title: u.title } : {}),
...(u.air_date ? { airDate: String(u.air_date).slice(0, 10) } : {}),
};
}
}
const meta = (entity.metadata ?? {}) as Record<string, unknown>;
return {
title: entity.title,
...(entity.original_title ? { originalTitle: entity.original_title } : {}),
...(en ? { enTitle: en } : {}),
...(entity.year ? { year: entity.year } : {}),
kind: namingKindOf(entity.kind),
...(typeof meta.category === "string" && meta.category ? { category: meta.category } : {}),
...(idOf("tmdb") ? { tmdbId: idOf("tmdb") } : {}),
...(idOf("imdb") ? { imdbId: idOf("imdb") } : {}),
...(idOf("douban") ? { doubanId: idOf("douban") } : {}),
...(extra.libraryRoot ? { libraryRoot: extra.libraryRoot } : {}),
...(slot?.name ? { slotName: slot.name } : {}),
seasons,
episodes,
};
}
@@ -19,7 +19,6 @@ import type { DownloadTaskRow, LibraryRootRow, MediaEntityRow, OrganizationRecor
import { sql } from "@umt/db";
import {
buildNamingContext,
DEFAULT_TEMPLATES,
type PathBinding as DomainBinding,
type ExistingPolicy,
type ExistingTarget,
@@ -40,6 +39,7 @@ import { errMessage, hashJson, jsonb, toApi } from "../util.ts";
import { publishedAcquisitionPolicies, unitKeysOf } from "./acquisition.ts";
import { type AoDeps, type Exec, hostPathOf, loadInstances, upsertDecision } from "./common.ts";
import { applySlotEvent } from "./downloads.ts";
import { effectiveNaming, ensureNamingVersion, loadNamingEntity, namingByVersion } from "./naming.ts";
import { PathGuardError } from "./organize-guard.ts";
import { type FrozenTransfer, finalizeTransfer, planTransfer, type TransferSpec } from "./organize-transfer.ts";
import { hnrSatisfied } from "./retention.ts";
@@ -414,11 +414,16 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
return r;
}
const naming = buildNamingContext({
title: entity.title,
...(entity.original_title ? { originalTitle: entity.original_title } : {}),
...(entity.year ? { year: entity.year } : {}),
});
// ── 命名:模板按 作品 > 库根 > 全局 回落并登记版本;目录只在首次绑定时渲染,文件名每次按当前模板 ──
const namingSettings = await d.ctx.settings.get("naming").catch(() => undefined);
const namingEntity = await loadNamingEntity(db, entity, { libraryRoot: root.name, slotId: req.slotId });
const templates = await ensureNamingVersion(db, effectiveNaming(namingSettings, root, entity).templates);
const boundVersion = bindings.find((b) => b.scope === "entity")?.templateVersion;
const fileTemplates =
namingSettings?.lockFileNameForBound && boundVersion
? ((await namingByVersion(db, boundVersion)) ?? templates)
: templates;
const naming = buildNamingContext(namingEntity);
const facts: FrozenPlan["facts"] = {};
const seasons = new Set<number>();
for (const f of files) {
@@ -445,10 +450,11 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
resolveTargetDirectory({
entity: { id: entity.id, kind: mediaType },
naming,
namingKind: namingEntity.kind!,
...(season !== undefined ? { season } : {}),
bindings: working,
libraryRoot: libRoot,
templates: DEFAULT_TEMPLATES,
templates,
});
const handle = async (dec: ReturnType<typeof decide>, season?: number): Promise<PlanResult | string> => {
if (dec.kind === "conflict" || dec.kind === "error") {
@@ -514,11 +520,8 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
mediaType,
...(sourceRoot ? { sourceRoot } : {}),
files,
entity: {
title: entity.title,
...(entity.original_title ? { originalTitle: entity.original_title } : {}),
...(entity.year ? { year: entity.year } : {}),
},
entity: namingEntity,
templates: fileTemplates,
target: { ...target, ...(health.kind === "online" ? { device: health.dev } : {}) },
retention: { hnrSatisfied: hnr },
minVideoBytes: req.minSizeBytes ?? d.ports.options.minVideoBytes,
+110
View File
@@ -0,0 +1,110 @@
# 整理命名模板运行手册(W9-C)
设计:`docs/design/naming-template.md`。实现:
| 位置 | 内容 |
| --- | --- |
| `packages/domain/src/naming/engine.ts` | 词法 → 语法 → AST → 渲染,纯函数,错误带行列号 |
| `packages/domain/src/naming/variables.ts` | 变量全表 `NAMING_VARIABLES`、`buildNamingVars`(解析结果 + 作品 + 季集元数据 + 版本目标 + 库根) |
| `packages/domain/src/naming/layout.ts` | 切段、段角色、非法字符/长度处理、保存前校验 `validateNamingTemplate` |
| `packages/domain/src/naming/defaults.ts` | MP 默认模板、v1 旧语法迁移、三级回落 `resolveNamingTemplates`、6 条内置样本 |
| `packages/domain/src/path/binding.ts` | `resolveTargetDirectory`(路径绑定不变)、`renderMediaFileName`(文件名 = 模板最后一段) |
| `apps/server/src/modules/acquisition-organize/services/naming.ts` | 读三级模板、模板版本登记、命名变量来源 |
| `apps/server/src/modules/acquisition-organize/routes/naming.ts` | `/naming/*` 与两个覆盖端点 |
| 迁移 `0032_naming` | `library_root.naming`、`media_entity.naming_override`、`naming_template_version` |
## 语法
Jinja 写法的安全子集,MP 的模板可以直接粘贴。
| 写法 | 说明 |
| --- | --- |
| `{{ var }}` | 输出变量;没有值输出空串(同 MP) |
| `{{ a or b or 'x' }}` | 取第一个有值的 |
| `{{ var \| 过滤器(参数) }}` | 过滤器可串联 |
| `{% if 条件 %}…{% elif 条件 %}…{% else %}…{% endif %}` | 最多嵌套 4 层 |
| 条件 | `var`、`not var`、`==`/`!=`(数字按数值比)、`> < >= <=`(整数)、`and`/`or`/括号 |
| `{# 注释 #}`、`{%-`/`-%}` | 注释丢弃;`-` 去掉相邻空白 |
过滤器:`default('x')`、`pad(n)`(数字补零,`1-3` → `01-03`)、`pad2`、`upper`、`lower`、`trim`、`replace('a','b')`、`truncate(n)`(按字符)、`date('YYYY-MM-DD'|'YYYY.MM.DD'|'YYYYMMDD')`、`join(sep)`。
拒绝(编译错误):`for`/`set`/宏/`include` 等语句、属性访问 `a.b`、下标 `a[0]`、函数调用、`~` 拼接、`%` 格式化与算术、行内 `x if c else y`、未知变量(提示"是否指 videoFormat")、未知过滤器。限制:模板 ≤ 1024 字符,渲染结果 ≤ 4096 字节。
## 变量
`GET /naming/variables` 返回完整清单(名称、分组、说明、示例值、`ours`)。名称照抄 MP:
- 标题:`title` `en_title` `original_title` `name` `year` `title_year` `type` `category`
- 季集:`season`(不补零,剧集缺季号为 1)`season_fmt` `season_year` `episode`(多集 `1-3`)`season_episode`(多集 `S01E01-E03`)`total_episodes` `episode_title` `episode_date` `part`
- 技术:`videoFormat` `resourceType` `effect` `edition` `resource_term` `releaseGroup` `videoCodec` `videoBit` `audioCodec` `webSource` `fps` `customization` `fileExt`(带点,小写)
- ID:`tmdbid` `imdbid` `doubanid`
- 我们特有:`cut` `contentType` `segment` `airDate` `issue` `slotName` `libraryRoot`
来源:作品名/原名/年份取 `media_entity`;英文名取 `title_alias`(language 以 en 开头,锁定优先);外部 ID 取已接受的 `external_identity`;`season_year`/`total_episodes`/`episode_title`/`episode_date` 取 `content_unit`;`category` 取 `media_entity.metadata.category`;`slotName` 取整理请求的版本目标;其余来自文件名解析。
## 目录层级与段角色
模板里的 `/` 切段(`\` 视同 `/`,多余的 `/` 合并)。最后一段是文件名,必须以 `{{fileExt}}` 结尾。从后往前第一个含标题变量(`title`/`title_year`/`en_title`/`original_title`/`name`)且不含季变量的目录段是**作品根**;作品根之后含季变量的段是**季目录**,其余是**子目录**;作品根之前是**分类前缀**。分类/子目录段渲染为空时整段省略,作品根或文件名为空报错。
## 非法字符与长度
- 变量值里的 `\/:*?"<>|` 转全角,删控制字符。所有变量都转(MP 只转标题类),值里的 `/` 不会多出目录。
- 模板字面量里的这些字符(`/` 除外)也转全角。
- 每段合并连续空白、去首尾空白和点;Windows 保留名(CON、NUL、COM1…)加 `_`。
- 每段 ≤ 255 字节(UTF-8),文件名截主干保留扩展名;全路径 ≤ 4096 字节。
## 覆盖顺序与路径绑定
- 生效顺序:作品(`media_entity.naming_override`)> 库根(`library_root.naming`)> 全局(`settings.naming`),按 movie/tv/variety 分别回落。
- 作品类型:`movie`(及 collection/other)用 movie;`series` 用 tv;`variety` 用 variety。剧集文件只有播出日期没有集号时也用 variety。
- **路径绑定不变**:作品根与季目录只在首次建立 `path_binding` 时按模板渲染;之后入库直接用绑定路径,模板和译名变化都不改已有目录。绑定之后模板里剩余的子目录段(如 `{{edition}}`)照常渲染。
- 文件名每次入库按当前生效模板渲染,已入库文件不回溯改名。设置 `naming.lockFileNameForBound = true` 后,已有作品绑定的作品按绑定记录的模板版本出文件名。
- 模板版本:生效模板集按内容指纹(sha256)登记在 `naming_template_version`,新建绑定的 `template_version` 就是这个版本号。1 = v1 旧模板(迁移后的新语法写法),2 = MP 默认,之后的从 3 起。
## 接口
| 方法 路径 | 权限 | 说明 |
| --- | --- | --- |
| `GET /naming/variables` | read | 变量表、过滤器说明、6 条内置样本 |
| `POST /naming/preview` | read | `{template?, kind, sample?: {fileName}\|{sourceReleaseId}\|{assetId}\|{builtin}, libraryRootId?, entityId?}` → `{ok, relativePath, segments[{text, role}], errors[{code, message, line, col}], warnings, missing, usesBinding, templateSource, template, sample, vars}`。不传 template 用三级回落的生效模板;作品已有绑定时目录用绑定路径 |
| `GET /naming/templates?libraryRootId&entityId` | read | 全局、库根、作品三级模板,MP 默认,按类型的生效模板与来源 |
| `PUT /naming/templates` | admin | 保存全局模板(部分字段),逐条编译,有错 422 |
| `PUT /library-roots/{id}/naming` | admin | `{naming: {movie?, tv?, variety?} \| null}` |
| `PUT /media-entities/{id}/naming-override` | admin | `{namingOverride: {movie?, tv?, variety?} \| null}` |
`PATCH /settings` 也能改 `naming`,但只做形状校验,不编译模板;前端保存请走 `PUT /naming/templates`。
## 与 MP 的差分
`packages/domain/test/differential/naming-cases.json`(34 条)+ `naming-mp.json`(MP 输出)。没有登记 deviation 的 21 条与 MP 逐字一致;登记的 13 条:
| # | 差异 | 理由 |
| --- | --- | --- |
| N07 | E01-E03 保留区间;MP 截成 `S01E01 - 第 1 集` | 集账本要登记 3 集,截断会让后两集看起来缺失 |
| N11 | 扩展名统一小写;MP 保留 `.MKV` | 避免同一文件因大小写不同被当成两个目标 |
| N14 | 合并连续空白;MP 的 `resource_term` 带前导空格,出现 `- WEB-DL` | 只在变量为空或 MP 自带空格时有差异 |
| N15 | `releaseGroup` 里的 `/` 转全角;MP 多出一层目录 | 值不应该决定目录层级 |
| N17 | `pad` 是我们的过滤器,MP 报错 | MP 的 `'%02d' % season\|int` 需要运算,安全子集不开放 |
| N19 | 每段截到 255 字节;MP 不截 | 超长由文件系统报错太晚 |
| N20 | 综艺模板(`airDate`、`issue`、`date()`)是我们特有 | MP 没有综艺类型 |
| N22、N23 | `for`、属性访问编译报错;MP 照常渲染 | 安全子集 |
| N24 | 模板缺 `{{fileExt}}` 报错;MP 生成无扩展名文件 | |
| N26 | `videoCodec` 输出规范值 `H265`;MP 保留原词 `HEVC` | 解析器把 HEVC/H.265/x265 归一 |
| N33 | Windows 保留名加 `_` | MP 不处理 |
| N34 | 模板字面量里的 `:` 转全角 | MP 原样保留 |
另外(不在差分文件里):已有作品绑定 `旧名 (2008)`、新模板渲染出 `新名 (2008)` 时,目录仍用 `旧名 (2008)/Season 1/…`,文件名用新模板(`packages/domain/test/naming.test.ts` "路径绑定不变")。
重新生成 MP 输出(改了样例或跟进上游后):
```sh
cd /opt/moviepilot-plus/upstream/MoviePilot && PYTHONPATH=. \
.venv/bin/python /opt/uhd_media_tool/packages/domain/test/differential/gen-naming-mp.py
```
测试断言:无 deviation 的样例与 MP 一致;有 deviation 的必须确实不同,差异消除后删掉登记。
## 迁移说明
- 0032 之前的默认模板(v1:`{var}` + `[[可选段]]`)已由 `upgradeLegacyTemplate` 改写为新语法,作为版本 1 登记;已有绑定(`template_version = 1`)不动。
- 升级后新建的绑定和所有新入库文件按 MP 默认(版本 2)命名:剧集文件名多出 ` - 第 N 集`。要保持旧文件名,打开 `naming.lockFileNameForBound`(只对已有绑定的作品生效),或把全局模板改回 v1 写法(`LEGACY_V1_NAMING`)。