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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 15:26:34 +08:00
1 parent 8651a35594
commit 99a3e17210
5 files changed
+129 -14

No files matched your search

@@ -572,9 +572,11 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
...(req.slotId ? { slotId: req.slotId } : {}),
naming: {
mode: keepNames ? "keep" : "template",
// 模板层级(作品/库根/全局)接入前只有全局默认模板;保留原文件名 = 本次覆写
templateSource: req.namingSource ?? (keepNames ? "override" : "global"),
templateVersion: DEFAULT_TEMPLATES.version,
// 模板来源按 作品 > 库根 > 全局 回落;保留原文件名 = 本次覆写
templateSource:
req.namingSource ??
(keepNames ? "override" : effectiveNaming(namingSettings, root, entity).sources[namingEntity.kind!]),
templateVersion: fileTemplates.version,
},
};
const drafts = organizePlanToFileResults(plan, { targetLibraryRootId: root.id });
@@ -1,10 +1,98 @@
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 推荐名:按命名模板(作品 > 库根 > 全局)对选中文件渲染文件名/目录段。
// 依赖 W9-C 的命名引擎(packages/domain/src/naming + POST /naming/preview);引擎提交前返回 501 not_implemented。
// 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> {
await resolveIn(d, body.root, body.path);
throw new FilesError("not_implemented", "命名引擎尚未就绪,暂不能按模板生成推荐名");
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,
};
}
+30 -5
View File
@@ -15,7 +15,7 @@ import {
} from "./harness.ts";
// W9-H 文件管理 v2 后端缺口:G1 TMDB 候选建作品 / G2 最小体积 / G3 目标已存在策略 / G4 命名方式与来源 /
// G5 整理记录提示 + 重新整理源 + 整理后刷新元数据 + 批量刮削 / G8 目录摘要 / G9 推荐名(引擎未就绪 501)。
// G5 整理记录提示 + 重新整理源 + 整理后刷新元数据 + 批量刮削 / G8 目录摘要 / G9 推荐名。
const skip = !(await pgAvailable());
@@ -315,14 +315,39 @@ describe.skipIf(skip)("files v2(W9-H)", () => {
expect(top.body.data.summary.registeredCount).toBeGreaterThanOrEqual(2);
});
it("G9:推荐名在命名引擎接入前返回 501 not_implemented", async () => {
const r = await h.api(
it("G9:推荐名按命名模板渲染(文件名 / 作品目录);识别不可信 → null + 原因", async () => {
const f = await h.api(
"POST",
"/files/suggest-name",
{ root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv" },
READ_KEY,
);
expect(r.status).toBe(501);
expect(r.body.error.message).toMatch(/^not_implemented:/);
expect(f.status).toBe(200);
expect(f.body.data).toMatchObject({
mediaEntityId: film,
templateSource: "global",
reason: null,
name: "Blue Film (2021) - 2160p.mkv",
relativePath: "Blue Film (2021)/Blue Film (2021) - 2160p.mkv",
});
expect(f.body.data.segments.map((s: Loose) => s.role)).toEqual(["root", "file"]);
await mkdir(join(h.dl, "Blue.Film.2021.Pack"), { recursive: true });
const dir = await h.api(
"POST",
"/files/suggest-name",
{ root: h.dlRoot, path: "Blue.Film.2021.Pack", mediaEntityId: film },
READ_KEY,
);
expect(dir.body.data.name).toBe("Blue Film (2021)");
await writeFile(join(h.dl, "Nobody.Knows.This.2011.mkv"), big);
const unknown = await h.api(
"POST",
"/files/suggest-name",
{ root: h.dlRoot, path: "Nobody.Knows.This.2011.mkv" },
READ_KEY,
);
expect(unknown.body.data).toMatchObject({ name: null, reason: "identity_uncertain" });
});
});
+1 -1
View File
@@ -47,7 +47,7 @@
| G4 | organize `naming: template\|keep`(缺省 template) | keep = 保留源文件名(目录仍按模板/绑定/targetDir;改名后撞名 → hold + duplicate_target)。`planImport` 新参数 `namingMode` / `namingSource`,冻结计划 `organization_record.plan.naming = {mode, templateSource: entity\|root\|global\|override, templateVersion}`(模板来源按 作品 > 库根 > 全局 回落;keep = override)。组响应同名 `naming` + `targetSegments[{text, role: root\|season\|dir, bound}]`(bound = 沿用已有路径绑定) |
| G5 | identify 项 `records{count,lastRecordId,lastStatus}`;`GET /files/record-sources?recordId=`;organize `refreshMetadata`;`POST /files/scrape-batch` | 整理记录按 `organization_file_result.source_path` 回查;record-sources 把记录主视频源(多个且同在源目录下时给目录)按登记目录 hostPath 换算成 FileRef,不在登记目录/已不存在的进 `unresolved`。`refreshMetadata=true`:执行成功的组按作品去重各排一次 `refreshMetadata`(组里 `refreshTaskId`)。scrape-batch:`items`(文件/目录 → 其下已登记资产的作品)∪ `mediaEntityIds` 去重,≤200 个作品;无 active 资产 `not_registered`、不存在 `not_found`、文件不属于任何作品 `no_entity`;带幂等键 |
| G8 | `GET /files/tree?root=&path=` | 目录摘要:`parent`(根为 null)、子目录 `hasChildren`(隐藏点目录与临时文件)、本目录递归视频数(上限 `dirSummaryMax`,超出 `partial`)与已登记位置数 |
| G9 | `POST /files/suggest-name {root, path, mediaEntityId?}` | 按命名模板渲染推荐名;命名引擎接入前返回 **501** `not_implemented:` |
| G9 | `POST /files/suggest-name {root, path, mediaEntityId?}` | 与整理同一命名引擎与变量来源(`effectiveNaming` 作品 > 库根 > 全局 + `loadNamingEntity`),按解析结果渲染整条模板:文件给文件名段,目录给作品目录段(只有季号时给季目录段),同时回 `relativePath`、`segments[{text, role}]`、`templateSource`。作品取 请求指定 > 已登记资产所属 > 解析标题在本地别名里唯一精确命中(同年);都没有 → `name=null, reason=identity_uncertain`;模板渲染失败 → `reason=naming_failed: …` |
## 授权矩阵(实现)
+1 -1
View File
@@ -1434,7 +1434,7 @@ export const Api = {
method: "post",
path: "/files/suggest-name",
scope: "organize.read",
summary: "按命名模板生成推荐名(命名引擎未就绪时 501)",
summary: "按命名模板(作品 > 库根 > 全局)生成推荐名;识别不可信时 name=null + 原因",
body: files.FilesSuggestNameRequest,
response: files.FilesSuggestNameResult,
}),