Files
UHD_Media_Tool/apps/server/src/modules/file-manager/suggest-name.ts
T
rootandClaude Opus 5.5 99a3e17210 feat(file-manager): suggest-name via naming engine; plan.naming source from override chain (W9-H G9)
- POST /files/suggest-name renders the effective template (entity > root >
  global) with the same variables as organize: file name segment for files,
  work/season dir segment for directories; identity_uncertain when no entity
- planImport plan.naming.templateSource/templateVersion now come from
  effectiveNaming + the registered template version (fixes the leftover
  DEFAULT_TEMPLATES reference after the naming chain landed)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 15:26:34 +08:00

99 lines
4.3 KiB
TypeScript

import { extname } from "node:path";
import type { FilesSuggestNameRequest, FilesSuggestNameResult } from "@umt/contracts";
import { buildNamingVars, normalizeTitle, parseReleasePath, renderNamingPath } from "@umt/domain";
import { effectiveNaming, loadNamingEntity } from "../acquisition-organize/services/naming.ts";
import { FilesError, type FmDeps, resolveIn } from "./common.ts";
// G9 推荐名:按命名模板(作品 > 库根 > 全局)对选中文件/目录渲染名字(与整理用同一引擎与变量来源)。
// 作品:请求指定 > 已登记资产所属 > 解析标题在本地别名里唯一精确命中;都没有 → name=null, reason=identity_uncertain。
// 文件给文件名段;目录按解析结果给季目录段(只有季号)或作品目录段。
const empty = (reason: string, mediaEntityId: string | null = null): FilesSuggestNameResult => ({
name: null,
relativePath: null,
reason,
mediaEntityId,
templateSource: null,
segments: [],
});
function segmentsOf(rel: string) {
const parts = rel.split("/");
const file = parts.pop()!;
const parent = parts.pop();
const grandParent = parts.pop();
return { file, ...(parent ? { parent } : {}), ...(grandParent ? { grandParent } : {}) };
}
export async function suggestName(d: FmDeps, body: FilesSuggestNameRequest): Promise<FilesSuggestNameResult> {
const db = d.ctx.db;
const r = await resolveIn(d, body.root, body.path);
if (r.rel === "") throw new FilesError("bad_request", "不能给登记目录本身生成推荐名");
const isDir = !!r.lst?.isDirectory();
const parsed = parseReleasePath(segmentsOf(r.rel));
let entityId = body.mediaEntityId ?? null;
if (!entityId && r.def.libraryRootId) {
const row = await db
.selectFrom("asset_location as l")
.innerJoin("media_asset as a", "a.id", "l.media_asset_id")
.select(["a.media_entity_id"])
.where("l.library_root_id", "=", r.def.libraryRootId)
.where("l.status", "<>", "removed")
.where((eb) =>
isDir
? eb("l.relative_path", "like", `${r.rel.replace(/[\\%_]/g, (m) => `\\${m}`)}/%`)
: eb("l.relative_path", "=", r.rel),
)
.executeTakeFirst();
entityId = row?.media_entity_id ?? null;
}
if (!entityId && parsed.title) {
const n = normalizeTitle(parsed.title);
const hits = n
? await db
.selectFrom("title_alias as a")
.innerJoin("media_entity as e", "e.id", "a.media_entity_id")
.select(["e.id", "e.year"])
.distinct()
.where("a.normalized", "=", n)
.where("e.status", "not in", ["merged", "archived"])
.limit(5)
.execute()
: [];
const byYear = parsed.year ? hits.filter((h) => h.year === parsed.year) : hits;
if (byYear.length === 1) entityId = byYear[0]!.id;
}
if (!entityId) return empty("identity_uncertain");
const entity = await db.selectFrom("media_entity").selectAll().where("id", "=", entityId).executeTakeFirst();
if (!entity) throw new FilesError("not_found", "作品不存在");
const root = r.def.libraryRootId
? await db.selectFrom("library_root").selectAll().where("id", "=", r.def.libraryRootId).executeTakeFirst()
: undefined;
const namingSettings = await d.ctx.settings.get("naming").catch(() => undefined);
const eff = effectiveNaming(namingSettings, root ?? null, entity);
const namingEntity = await loadNamingEntity(db, entity, { libraryRoot: root?.name });
const kind = namingEntity.kind ?? "movie";
const ext = isDir ? "" : extname(r.rel);
const vars = buildNamingVars(namingEntity, parsed, isDir ? {} : { fileExt: ext });
const out = renderNamingPath(eff.templates[kind], vars, isDir ? { mode: "dirs" } : {});
const segments = out.segments.map((s) => ({ text: s.text, role: s.role }));
if (!out.ok)
return {
...empty(`naming_failed: ${out.errors.map((e) => e.message).join("; ")}`, entityId),
templateSource: eff.sources[kind],
segments,
};
const want = isDir ? (parsed.season !== undefined && !parsed.episodes ? "season" : "root") : "file";
const seg = out.segments.find((s) => s.role === want);
return {
name: seg?.text || null,
relativePath: out.relativePath,
reason: seg?.text ? null : `no_${want}_segment`,
mediaEntityId: entityId,
templateSource: eff.sources[kind],
segments,
};
}