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 { 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, }; }