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,