diff --git a/apps/server/src/modules/acquisition-organize/services/organize.ts b/apps/server/src/modules/acquisition-organize/services/organize.ts index c764795d..41bd3a01 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize.ts @@ -572,9 +572,11 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise 库根 > 全局 回落;保留原文件名 = 本次覆写 + templateSource: + req.namingSource ?? + (keepNames ? "override" : effectiveNaming(namingSettings, root, entity).sources[namingEntity.kind!]), + templateVersion: fileTemplates.version, }, }; const drafts = organizePlanToFileResults(plan, { targetLibraryRootId: root.id }); diff --git a/apps/server/src/modules/file-manager/suggest-name.ts b/apps/server/src/modules/file-manager/suggest-name.ts index 9db9d35e..545594c4 100644 --- a/apps/server/src/modules/file-manager/suggest-name.ts +++ b/apps/server/src/modules/file-manager/suggest-name.ts @@ -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 { - 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, + }; } diff --git a/apps/server/test/file-manager/v2.test.ts b/apps/server/test/file-manager/v2.test.ts index aae39977..d4e97c80 100644 --- a/apps/server/test/file-manager/v2.test.ts +++ b/apps/server/test/file-manager/v2.test.ts @@ -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" }); }); }); diff --git a/docs/runbook/files.md b/docs/runbook/files.md index 82804ac6..33800660 100644 --- a/docs/runbook/files.md +++ b/docs/runbook/files.md @@ -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: …` | ## 授权矩阵(实现) diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index 9704f730..29f4c27c 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -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, }),