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:
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: {
|
||||
|
||||
Reference in new issue
Block a user