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:
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,
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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: …` |
|
||||
|
||||
## 授权矩阵(实现)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user