- 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]>
99 lines
4.3 KiB
TypeScript
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,
|
|
};
|
|
}
|