feat(file-manager): v2 backend gaps — TMDB candidate entities, min size, on-existing, naming source (W9-H)

- G1 createEntityFromTmdbCandidate (sources-identity service): entity + accepted
  manual external identity + season/episode units, idempotent per TMDB id;
  POST /files/entity-from-candidate and organize items with `external`
- G2 minSizeBytes for organize (excluded below_min_size) and identify
  (skipped + excludedBySize)
- G3 onExisting passed to planImport; dryRun marks each file
  (same/skip/replace/conflict); replace = explicit grant
- G4 naming template|keep; planImport namingMode/namingSource, frozen
  plan.naming {mode, templateSource, templateVersion}; targetSegments
- G5 identify prior-record hints, GET /files/record-sources, organize
  refreshMetadata, POST /files/scrape-batch (dedupe by entity)
- G8 GET /files/tree (parent, subdirs hasChildren, video/registered counts)
- G9 POST /files/suggest-name returns 501 until the naming engine lands

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 15:08:17 +08:00
1 parent 88bbed409c
commit 13132e7ad8
12 files changed
+977 -36

No files matched your search

@@ -80,6 +80,21 @@ export interface ImportRequest {
slotId?: string | undefined;
/** W5-A2 原样搬运(文件管理 移动/复制):保持文件名与目录结构,不识别作品、不登记新资产,已登记位置跟随 */
transfer?: TransferSpec | undefined;
/** W9-H 主视频最小体积(字节),低于的排除为 too_small;缺省取端口选项 minVideoBytes */
minSizeBytes?: number | undefined;
/** W9-H 命名方式:template(默认)按模板;keep 保留原文件名(目录仍按模板/绑定/targetDir) */
namingMode?: NamingMode | undefined;
/** W9-H 调用方声明的命名模板来源(本次覆写等);缺省按模板层级推断,冻结进 plan.naming */
namingSource?: NamingSourceTag | undefined;
}
export type NamingMode = "template" | "keep";
export type NamingSourceTag = "entity" | "root" | "global" | "override";
/** W9-H:冻结计划里的命名记录(organization_record.plan.naming) */
export interface FrozenNaming {
mode: NamingMode;
templateSource: NamingSourceTag;
templateVersion: number | null;
}
export interface FileOverrideInput {
@@ -117,6 +132,8 @@ export interface FrozenPlan {
slotId?: string;
/** W5-A2:原样搬运计划(finalize 走 finalizeTransfer) */
transfer?: FrozenTransfer;
/** W9-H:命名方式与模板来源/版本 */
naming?: FrozenNaming;
}
export interface PlanResult {
@@ -504,7 +521,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
},
target: { ...target, ...(health.kind === "online" ? { device: health.dev } : {}) },
retention: { hnrSatisfied: hnr },
minVideoBytes: d.ports.options.minVideoBytes,
minVideoBytes: req.minSizeBytes ?? d.ports.options.minVideoBytes,
...(req.fileOverrides ? { overrides: req.fileOverrides } : {}),
...(d.ctx.config?.ORGANIZE_DEFAULT_SUBTITLE_LANG
? { defaultSubtitleLang: d.ctx.config.ORGANIZE_DEFAULT_SUBTITLE_LANG }
@@ -513,10 +530,13 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
onExisting: req.onExisting ?? d.ctx.config?.ORGANIZE_ON_EXISTING ?? "conflict",
...(req.grantReplace ? { grants: { replace: true } } : {}),
} as const;
const keepNames = req.namingMode === "keep";
const draft = planOrganize({ ...baseInput, existingTargets: [] });
const existing: ExistingTarget[] = [];
for (const f of draft.files) {
const st = await statSafe(f.dst);
// keep:按保留原名后的真实目标观察存在性,仍以模板目标为键交给域层判定策略,之后统一改名
const probe = keepNames ? keptDst(f) : f.dst;
const st = await statSafe(probe);
if (st.kind === "missing") continue;
if (st.kind !== "ok") {
existing.push({ path: f.dst, size: -1 });
@@ -525,13 +545,14 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
const e: ExistingTarget = { path: f.dst, size: st.facts.size };
if (st.facts.size === f.size) {
// 同尺寸已存在的目标:全量 sha256 判定是否同一文件(子进程,见 adapters sha256Full)
e.sha256 = (await sha256Full(f.dst)).digest;
e.sha256 = (await sha256Full(probe)).digest;
const src = files.find((x) => x.path === f.src);
if (src) src.sha256 = (await sha256Full(src.path)).digest;
}
existing.push(e);
}
const plan = planOrganize({ ...baseInput, files, existingTargets: existing });
if (keepNames) keepOriginalNames(plan);
const frozen: FrozenPlan = {
version: 1,
organize: plan,
@@ -546,6 +567,12 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
facts,
...(downgradedFromMove ? { downgradedFromMove } : {}),
...(req.slotId ? { slotId: req.slotId } : {}),
naming: {
mode: keepNames ? "keep" : "template",
// 模板层级(作品/库根/全局)接入前只有全局默认模板;保留原文件名 = 本次覆写
templateSource: req.namingSource ?? (keepNames ? "override" : "global"),
templateVersion: DEFAULT_TEMPLATES.version,
},
};
const drafts = organizePlanToFileResults(plan, { targetLibraryRootId: root.id });
const blocking = plan.conflicts.filter((c) => c.blocking);
@@ -646,6 +673,28 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
return result;
}
/** W9-H 保留原文件名:目标目录不变,文件名取源文件名 */
const keptDst = (f: { src: string; dst: string }) => join(dirname(f.dst), basename(f.src));
/** W9-H 保留原文件名:改写计划目标;改写后撞名的项转 hold + duplicate_target(阻塞) */
function keepOriginalNames(plan: OrganizePlan): void {
const renamed = new Map<string, string>();
for (const f of plan.files) {
const kept = keptDst(f);
renamed.set(f.dst, kept);
f.dst = kept;
}
for (const c of plan.conflicts) if (c.dst && renamed.has(c.dst)) c.dst = renamed.get(c.dst)!;
const byDst = new Map<string, OrganizePlan["files"]>();
for (const f of plan.files) byDst.set(f.dst, [...(byDst.get(f.dst) ?? []), f]);
for (const [dst, fs] of byDst) {
if (fs.length < 2 || plan.conflicts.some((c) => c.kind === "duplicate_target" && c.dst === dst)) continue;
for (const f of fs) f.action = "hold";
plan.conflicts.push({ kind: "duplicate_target", blocking: true, dst, detail: fs.map((f) => f.src).join(" | ") });
}
plan.requiresApproval = plan.conflicts.some((c) => c.blocking);
}
async function writeFailedRecord(
d: AoDeps,
r: {