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

@@ -0,0 +1,34 @@
import type { FilesEntityFromCandidateResult, FilesExternalCandidate } from "@umt/contracts";
import type { Principal } from "../../http/auth.ts";
import { CandidateError, createEntityFromTmdbCandidate } from "../sources-identity/service/entity-from-candidate.ts";
import { actorOfP, FilesError, type FmDeps } from "./common.ts";
import { audit } from "./ledger.ts";
// G1:整理向导选中"仅 TMDB 候选"作品 → 就地建本地作品 + 外部映射(accepted/manual)+ 季集单元,返回 entityId。
// 幂等(同 TMDB id 已有映射即复用);/files/organize 的 external 项与独立端点共用。
const actorOf = (p: Principal | undefined) => ({
type: p?.kind === "api_key" ? ("api_key" as const) : ("user" as const),
id: actorOfP(p),
});
export async function entityFromCandidate(
d: FmDeps,
principal: Principal | undefined,
ref: FilesExternalCandidate,
): Promise<FilesEntityFromCandidateResult> {
try {
const r = await createEntityFromTmdbCandidate(d.ctx.db, d.tmdb, ref, actorOf(principal));
if (r.created) await audit(d, principal, "entity_from_candidate", { ...ref, mediaEntityId: r.mediaEntityId });
return r;
} catch (e) {
if (e instanceof CandidateError)
throw new FilesError(
e.reason === "not_found" ? "not_found" : e.reason === "upstream" ? "upstream" : "bad_request",
e.message,
);
throw e;
}
}
export const externalKey = (x: FilesExternalCandidate) => `${x.provider}:${x.kind}:${x.externalId}`;
@@ -1,7 +1,7 @@
import { createHash } from "node:crypto";
import { lstat, realpath, stat } from "node:fs/promises";
import { basename, dirname, join, sep } from "node:path";
import { checkRoot, DEFAULT_ROOT_SENTINEL } from "@umt/adapters";
import { checkRoot, DEFAULT_ROOT_SENTINEL, type TmdbClient } from "@umt/adapters";
import type { ApiErrorCode, FileRootId, FilesErrorReason } from "@umt/contracts";
import type { LibraryRootRow } from "@umt/db";
import type { Principal } from "../../http/auth.ts";
@@ -43,6 +43,8 @@ export interface FmDeps {
/** acquisition-organize 的服务依赖(planImport / 守卫 / 授权 / 端口复用) */
ao: AoDeps;
options: FmOptions;
/** W9-H(G1):TMDB 客户端(按 TMDB 候选建作品);未配置返回 null */
tmdb: () => Promise<TmdbClient | null>;
}
const envNum = (k: string, dflt: number) => {
@@ -71,7 +73,7 @@ export function defaultOptions(ctx: ServerContext): FmOptions {
// ── 错误 ──
const REASON_STATUS: Record<
FilesErrorReason | "bad_request" | "forbidden" | "too_large",
FilesErrorReason | "bad_request" | "forbidden" | "too_large" | "upstream",
{ status: number; code: ApiErrorCode }
> = {
root_unavailable: { status: 503, code: "upstream_unavailable" },
@@ -88,6 +90,8 @@ const REASON_STATUS: Record<
permanent_disabled: { status: 403, code: "forbidden" },
approval_required: { status: 403, code: "forbidden" },
too_large: { status: 413, code: "bad_request" },
not_implemented: { status: 501, code: "precondition_failed" },
upstream: { status: 502, code: "upstream_unavailable" },
};
/** 业务错误:路由统一转成信封,message 以 `<reason>: ` 开头便于前端分支 */
@@ -1,11 +1,12 @@
import { randomUUID } from "node:crypto";
import { readdir } from "node:fs/promises";
import { readdir, stat } from "node:fs/promises";
import { join } from "node:path";
import type {
FileIdentifyCandidate,
FileIdentifyItem,
FilesIdentifyRequest,
FilesIdentifyResult,
OrganizationStatus,
} from "@umt/contracts";
import { sql } from "@umt/db";
import { type IdentityCandidate, type ParsedRelease, parseReleasePath, queryTitles, rankCandidates } from "@umt/domain";
@@ -31,6 +32,8 @@ const LEVEL: Record<string, FileIdentifyCandidate["level"]> = {
interface Video {
root: string;
rel: string;
/** 本机绝对路径(整理记录回查 organization_file_result.source_path) */
abs: string;
}
async function collectVideos(d: FmDeps, body: FilesIdentifyRequest): Promise<{ videos: Video[]; truncated: boolean }> {
@@ -41,7 +44,7 @@ async function collectVideos(d: FmDeps, body: FilesIdentifyRequest): Promise<{ v
const r = await resolveIn(d, it.root, it.path);
if (!r.lst?.isDirectory() || r.isSymlink) {
if (videos.length >= max) truncated = true;
else videos.push({ root: it.root, rel: r.rel });
else videos.push({ root: it.root, rel: r.rel, abs: r.abs });
continue;
}
const stack = [r.rel];
@@ -59,7 +62,7 @@ async function collectVideos(d: FmDeps, body: FilesIdentifyRequest): Promise<{ v
truncated = true;
break;
}
videos.push({ root: it.root, rel: child });
videos.push({ root: it.root, rel: child, abs: join(r.rootReal, child) });
}
}
if (truncated) break;
@@ -83,6 +86,21 @@ export async function identify(
): Promise<FilesIdentifyResult> {
const db = d.ctx.db;
const { videos, truncated } = await collectVideos(d, body);
// G2:最小体积(请求 > settings.organize.minSizeBytes > 不过滤)
const orgSettings = (await d.ctx.settings.get("organize").catch(() => null)) as { minSizeBytes?: unknown } | null;
const minSize = body.minSizeBytes ?? (typeof orgSettings?.minSizeBytes === "number" ? orgSettings.minSizeBytes : 0);
let excludedBySize = 0;
// G5:已有整理记录(按源文件本机路径)
const recordRows = videos.length
? await sql<{ source_path: string; n: number; last_id: string; last_status: string }>`
select distinct on (f.source_path) f.source_path,
count(*) over (partition by f.source_path)::int as n,
r.id as last_id, r.status as last_status
from organization_file_result f join organization_record r on r.id = f.organization_record_id
where f.source_path = any(${videos.map((v) => v.abs)}::text[])
order by f.source_path, r.created_at desc`.execute(db)
: { rows: [] };
const recordsBy = new Map(recordRows.rows.map((r) => [r.source_path, r]));
const localCache = new Map<string, IdentityCandidate[]>();
const remoteCache = new Map<string, { list: IdentityCandidate[]; warning: string | null }>();
const entityKinds = new Map<string, string>();
@@ -201,12 +219,38 @@ export async function identify(
const items: FileIdentifyItem[] = [];
for (const v of videos) {
const size = await stat(v.abs)
.then((st) => Number(st.size))
.catch(() => 0);
const rec = recordsBy.get(v.abs);
const records = {
count: rec?.n ?? 0,
lastRecordId: rec?.last_id ?? null,
lastStatus: (rec?.last_status ?? null) as OrganizationStatus | null,
};
const parsed0 = parseReleasePath(segments(v.rel));
const parsed: ParsedRelease = {
...parsed0,
...(parsed0.year === undefined && body.hints?.year ? { year: body.hints.year } : {}),
};
const warnings: string[] = [];
if (minSize > 0 && size < minSize) {
// 低于最小体积:不查候选(省远端查询),只回解析结果
excludedBySize++;
items.push({
root: v.root as FileIdentifyItem["root"],
path: v.rel,
parsed: parsedOut(parsed),
candidates: [],
suggestion: { mediaEntityId: null, contentUnitId: null, contentUnit: null },
existing: { assetId: null },
warnings: [`低于最小体积 ${minSize} 字节,已跳过`],
size,
skipped: "below_min_size",
records,
});
continue;
}
const local = parsed.title || parsed.explicitIds.length || body.hints?.tmdbId ? await localFor(parsed) : [];
const remote = parsed.title ? await remoteFor(parsed) : { list: [], warning: null };
if (remote.warning) warnings.push(remote.warning);
@@ -265,29 +309,37 @@ export async function identify(
items.push({
root: v.root as FileIdentifyItem["root"],
path: v.rel,
parsed: {
title: parsed.title ?? null,
year: parsed.year ?? null,
season: season ?? null,
episodes: parsed.episodes
? Array.from(
{ length: Math.min(200, parsed.episodes.end - parsed.episodes.start + 1) },
(_, i) => parsed.episodes!.start + i,
)
: [],
resolution: parsed.resolution ?? null,
source: parsed.source ?? null,
releaseGroup: parsed.releaseGroup?.name ?? null,
mediaKind:
parsed.episodes || parsed.season !== undefined ? "series" : parsed.mediaTypeHint === "tv" ? "series" : null,
},
parsed: parsedOut(parsed),
candidates,
suggestion: { mediaEntityId: best?.mediaEntityId ?? null, contentUnitId, contentUnit },
existing: { assetId: existing?.media_asset_id ?? null },
warnings,
size,
skipped: null,
records,
});
}
if (videos.length === 0 && body.items.length > 0 && !truncated)
throw new FilesError("bad_request", "所选项中没有视频文件");
return { items, truncated };
return { items, truncated, excludedBySize };
}
function parsedOut(parsed: ParsedRelease): FileIdentifyItem["parsed"] {
const season = parsed.season ?? (parsed.episodes ? 1 : undefined);
return {
title: parsed.title ?? null,
year: parsed.year ?? null,
season: season ?? null,
episodes: parsed.episodes
? Array.from(
{ length: Math.min(200, parsed.episodes.end - parsed.episodes.start + 1) },
(_, i) => parsed.episodes!.start + i,
)
: [],
resolution: parsed.resolution ?? null,
source: parsed.source ?? null,
releaseGroup: parsed.releaseGroup?.name ?? null,
mediaKind:
parsed.episodes || parsed.season !== undefined ? "series" : parsed.mediaTypeHint === "tv" ? "series" : null,
};
}
@@ -1,9 +1,11 @@
import { randomUUID } from "node:crypto";
import type { TmdbClient } from "@umt/adapters";
import { defineTask } from "@umt/tasks";
import { z } from "zod";
import type { AoOverrides } from "../acquisition-organize/index.ts";
import { createPorts } from "../acquisition-organize/ports.ts";
import { registerScheduledJob } from "../schedules/registry.ts";
import { SourcesRuntime } from "../sources-identity/runtime.ts";
import type { ServerContext, ServerModule } from "../types.ts";
import { defaultOptions, type FmDeps, type FmOptions } from "./common.ts";
import { registerFileRoutes } from "./routes.ts";
@@ -18,10 +20,13 @@ import { purgeTrash } from "./trash.ts";
export interface FmOverrides {
ao?: AoOverrides;
options?: Partial<FmOptions>;
/** W9-H:TMDB 客户端工厂(测试注入);缺省按设置 metadata.tmdb* 取(与 sources-identity 同一实现) */
tmdb?: (() => TmdbClient | null) | undefined;
}
export function createFileManagerModule(overrides: FmOverrides = {}): ServerModule & { deps(): FmDeps } {
let deps: FmDeps | null = null;
let sources: SourcesRuntime | null = null;
const lazy = (ctx: ServerContext): FmDeps => {
if (!deps) {
const { options: aoOptions, ...aoRest } = overrides.ao ?? {};
@@ -29,6 +34,10 @@ export function createFileManagerModule(overrides: FmOverrides = {}): ServerModu
ctx,
ao: { ctx, ports: createPorts(ctx, { ...aoRest, ...(aoOptions ? { options: aoOptions } : {}) }) },
options: { ...defaultOptions(ctx), ...overrides.options },
tmdb: () => {
sources ??= new SourcesRuntime(ctx, overrides.tmdb ? { tmdb: overrides.tmdb } : {});
return sources.tmdb();
},
};
}
return deps;
+146 -10
View File
@@ -1,9 +1,10 @@
import { randomUUID } from "node:crypto";
import { stat } from "node:fs/promises";
import { join } from "node:path";
import { join, relative } from "node:path";
import type { FilesOrganizeGroup, FilesOrganizeRequest, FilesOrganizeResult } from "@umt/contracts";
import type { Principal } from "../../http/auth.ts";
import { ensureTaskRow, markTask } from "../acquisition-organize/services/common.ts";
import { type FileOverrideInput, planImport } from "../acquisition-organize/services/organize.ts";
import { type FileOverrideInput, type FrozenPlan, planImport } from "../acquisition-organize/services/organize.ts";
import {
authorizeMoveEntities,
hnrBlockForMove,
@@ -12,6 +13,7 @@ import {
requestKey,
sourceGuard,
} from "../acquisition-organize/services/organize-guard.ts";
import { entityFromCandidate, externalKey } from "./candidate.ts";
import { actorOfP, errMessage, FilesError, type FmDeps, hashJson, isAdmin, resolveIn } from "./common.ts";
import { audit, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts";
import { isWritableTarget } from "./list.ts";
@@ -57,6 +59,71 @@ interface Group {
const pairsOf = (plan: Awaited<ReturnType<typeof planImport>>) =>
(plan.plan?.organize.files ?? []).map((f) => ({ src: f.src, dst: f.dst ?? null, kind: f.kind, size: f.size }));
type GroupFile = FilesOrganizeGroup["files"][number];
/** 计划文件 → 响应行:动作 + 目标已存在时的处理(G3 逐文件标注) */
function filesOf(plan: FrozenPlan | undefined): GroupFile[] {
if (!plan) return [];
const heldByExisting = new Set(
plan.organize.conflicts.filter((c) => c.kind === "target_exists" && c.blocking).map((c) => c.src),
);
return plan.organize.files.map((f) => {
const existing: GroupFile["existing"] =
f.action === "replace"
? "replace"
: f.action === "skip" && f.skipReason === "already_in_library"
? "same"
: f.action === "skip" && f.skipReason === "target_exists"
? "skip"
: f.action === "hold" && heldByExisting.has(f.src)
? "conflict"
: null;
return {
source: f.src,
target: f.dst ?? null,
kind: f.kind,
size: f.size,
action: f.action,
existing,
skipReason: f.skipReason ?? null,
};
});
}
/** 被排除的文件(G2:too_small → below_min_size) */
function excludedOf(plan: FrozenPlan | undefined): NonNullable<FilesOrganizeGroup["excluded"]> {
return (plan?.organize.excluded ?? []).map((x) => ({
source: x.src,
kind: x.kind,
size: x.reason === "too_small" && x.detail && /^\d+$/.test(x.detail) ? Number(x.detail) : null,
reason: x.reason === "too_small" ? "below_min_size" : x.reason,
}));
}
/** 目标目录分段(相对库根;G4):剧集末段为季目录;bound = 沿用已有路径绑定(本次不新建) */
function segmentsOf(plan: FrozenPlan | undefined, overrideDir: boolean): FilesOrganizeGroup["targetSegments"] {
const dir = plan?.targetDirs[0];
if (!plan || !dir) return [];
const parts = relative(plan.rootPath, dir).split("/").filter(Boolean);
if (overrideDir) return parts.map((text) => ({ text, role: "dir" as const, bound: false }));
const newEntity = plan.newBindings.some((b) => b.scope === "entity");
const newSeason = plan.newBindings.some((b) => b.scope === "season");
const rootIdx = plan.mediaType === "tv" ? parts.length - 2 : parts.length - 1;
return parts.map((text, i) =>
i === rootIdx
? { text, role: "root" as const, bound: !newEntity }
: plan.mediaType === "tv" && i === parts.length - 1
? { text, role: "season" as const, bound: !newSeason }
: { text, role: "dir" as const, bound: !newEntity },
);
}
/** 设置里的最小体积(settings.organize.minSizeBytes;契约未加该字段前恒为 undefined → planImport 用端口默认) */
async function settingsMinSize(d: FmDeps): Promise<number | undefined> {
const org = (await d.ctx.settings.get("organize").catch(() => null)) as { minSizeBytes?: unknown } | null;
return typeof org?.minSizeBytes === "number" && org.minSizeBytes >= 0 ? org.minSizeBytes : undefined;
}
async function factsOf(paths: string[]) {
return Promise.all(
paths.map(async (p) => {
@@ -85,7 +152,30 @@ export async function organizeFiles(
const actorType = principal?.kind === "api_key" ? "api_key" : "user";
const actor = actorOfP(principal);
const entityIds = [...new Set(body.items.map((i) => i.mediaEntityId))];
// G1:外部候选 → 本地作品(就地建,幂等);每项解析出 mediaEntityId
const created = new Map<string, { entityId: string; created: boolean; title: string; externalId: string }>();
for (const it of body.items) {
if (!!it.mediaEntityId === !!it.external)
throw new FilesError("bad_request", `整理项 ${it.path || "/"} 需要且只能指定 mediaEntityId 或 external 之一`);
if (!it.external) continue;
const k = externalKey(it.external);
if (created.has(k)) continue;
const r = await entityFromCandidate(d, principal, it.external);
created.set(k, {
entityId: r.mediaEntityId,
created: r.created,
title: r.title,
externalId: it.external.externalId,
});
}
const entityOf = (it: (typeof body.items)[number]) =>
it.mediaEntityId ?? created.get(externalKey(it.external!))!.entityId;
const fromExternal = new Map([...created.values()].map((c) => [c.entityId, c]));
const minSizeBytes = body.minSizeBytes ?? (await settingsMinSize(d));
const onExisting = body.onExisting ?? "conflict";
const namingMode = body.naming ?? "template";
const entityIds = [...new Set(body.items.map(entityOf))];
const entities = new Map(
(await db.selectFrom("media_entity").select(["id", "kind"]).where("id", "in", entityIds).execute()).map((e) => [
e.id,
@@ -129,7 +219,8 @@ export async function organizeFiles(
throw new FilesError("not_writable", `源根 ${r.def.name} 只读,不能移动`);
roots.add(it.root);
const isDir = !!r.lst?.isDirectory();
const kind = entities.get(it.mediaEntityId);
const entityId = entityOf(it);
const kind = entities.get(entityId);
const isTv = kind !== undefined && kind !== "movie";
const warnings: string[] = [];
const ov: FileOverrideInput = {};
@@ -163,7 +254,7 @@ export async function organizeFiles(
let unit = it.contentUnitId ?? null;
if (unit) {
const u = units.get(unit);
if (!u || u.media_entity_id !== it.mediaEntityId) {
if (!u || u.media_entity_id !== entityId) {
warnings.push(`${r.rel}: 内容单元 ${unit} 不属于该作品,未应用`);
unit = null;
} else if (u.kind === "episode" || u.kind === "part") ov.contentUnitIds = [u.id];
@@ -172,7 +263,7 @@ export async function organizeFiles(
let slotId = it.slotId ?? null;
if (slotId) {
const s = slots.get(slotId);
if (!s || s.media_entity_id !== it.mediaEntityId) {
if (!s || s.media_entity_id !== entityId) {
warnings.push(`${r.rel}: 版本目标 ${slotId} 不属于该作品,未应用`);
slotId = null;
} else if (BUSY_SLOT.has(s.status)) {
@@ -181,11 +272,11 @@ export async function organizeFiles(
}
}
const key = [it.mediaEntityId, unit ?? "", action, targetDir ?? "", targetLib ?? "", slotId ?? ""].join("|");
const key = [entityId, unit ?? "", action, targetDir ?? "", targetLib ?? "", slotId ?? ""].join("|");
const g =
groups.get(key) ??
({
entity: it.mediaEntityId,
entity: entityId,
unit,
action,
targetDir,
@@ -251,7 +342,12 @@ export async function organizeFiles(
...(hasOverrides ? { overrides: g.overrides } : {}),
...(g.targetDir ? { targetDir: g.targetDir } : {}),
...(g.slotId ? { slotId: g.slotId } : {}),
// W9-H:新选项进请求指纹(缺省值不进,老计划的指纹不变)
...(minSizeBytes !== undefined ? { minSizeBytes } : {}),
...(onExisting !== "conflict" ? { onExisting } : {}),
...(namingMode !== "template" ? { naming: namingMode } : {}),
});
const ext = fromExternal.get(g.entity);
const base = {
mediaEntityId: g.entity,
contentUnitId: g.unit,
@@ -259,12 +355,22 @@ export async function organizeFiles(
action: g.action,
targetDir: g.targetDir,
slotId: g.slotId,
entity: ext
? { provider: "tmdb" as const, externalId: ext.externalId, created: ext.created, title: ext.title }
: null,
onExisting,
...(minSizeBytes !== undefined ? { minSizeBytes } : {}),
};
const importReq = (dryRun: boolean, taskId?: string) => ({
...req,
...(hasOverrides ? { fileOverrides: g.overrides } : {}),
...(g.targetDir ? { targetDir: g.targetDir } : {}),
...(g.slotId ? { slotId: g.slotId } : {}),
...(minSizeBytes !== undefined ? { minSizeBytes } : {}),
onExisting,
// 向导里显式选择"替换"即为人工授权;替换走 replace_with_verify(新文件核验通过后旧文件进回收站)
...(onExisting === "replace_with_verify" ? { grantReplace: true } : {}),
namingMode,
trigger: "manual" as const,
actorType: actorType as "api_key" | "user",
actorId: actor,
@@ -294,8 +400,9 @@ export async function organizeFiles(
);
out.push({
...base,
...planExtras(plan.plan, !!g.targetDir),
planId: saved.id,
files: pairs.map((p) => ({ source: p.src, target: p.dst, kind: p.kind, size: p.size })),
files: filesOf(plan.plan),
truncated: plan.truncated === true,
recordId: null,
taskId: null,
@@ -357,9 +464,10 @@ export async function organizeFiles(
recordIds.push(plan.recordId);
out.push({
...base,
...planExtras(again.plan, !!g.targetDir),
planId,
// 执行时 planImport 不回传计划体;冻结计划与刚重算的 dryRun 一致(上面已校验),直接回显
files: pairsOf(again).map((p) => ({ source: p.src, target: p.dst, kind: p.kind, size: p.size })),
files: filesOf(again.plan),
truncated: false,
recordId: plan.recordId,
taskId,
@@ -390,6 +498,25 @@ export async function organizeFiles(
});
}
}
// G5:执行成功的组按作品去重各排一次元数据刷新(字段级,锁定字段与路径不变)
if (!body.dryRun && body.refreshMetadata) {
const runs = new Map<string, string>();
for (const g of out) {
if (!g.recordId || g.error) continue;
if (!runs.has(g.mediaEntityId)) {
const { runId } = await d.ctx.engine.enqueue(
"refreshMetadata",
{ mediaEntityId: g.mediaEntityId },
{
idempotencyKey: `refreshMetadata:files-organize:${body.idempotencyKey}:${g.mediaEntityId}`,
traceId: randomUUID(),
},
);
runs.set(g.mediaEntityId, runId);
}
g.refreshTaskId = runs.get(g.mediaEntityId) ?? null;
}
}
if (!body.dryRun) {
await audit(d, principal, "organize", {
action: body.action,
@@ -405,6 +532,15 @@ export async function organizeFiles(
return { dryRun: body.dryRun, groups: out, recordIds };
}
/** 组的计划附加信息:排除项 / 命名记录 / 目标目录分段 */
function planExtras(plan: FrozenPlan | undefined, overrideDir: boolean) {
return {
excluded: excludedOf(plan),
...(plan?.naming ? { naming: plan.naming } : {}),
targetSegments: segmentsOf(plan, overrideDir),
};
}
/** 执行请求携带全部组的 planId:找与本组请求一致的那个 */
async function pickPlan(d: FmDeps, planIds: string[], reqHash: string): Promise<string> {
if (planIds.length === 0) throw new FilesError("bad_request", "执行需要 dryRun 返回的 planIds");
@@ -6,6 +6,8 @@ import type {
FileProbeItem,
FilesProbeRequest,
FilesProbeResult,
FilesScrapeBatchRequest,
FilesScrapeBatchResult,
FilesScrapeRequest,
FilesScrapeResult,
} from "@umt/contracts";
@@ -14,6 +16,7 @@ import type { Principal } from "../../http/auth.ts";
import { FilesError, type FmDeps, jsonb, resolveIn } from "./common.ts";
import { audit } from "./ledger.ts";
import { kindOfName, mediaSummary } from "./list.ts";
import { entitiesUnder } from "./records.ts";
// probe:ffprobe 摘要;已登记资产写回 media_asset.media_info.probe,未登记只缓存 24h(DATA_DIR/cache/probe)。
// scrape:只对已登记作品,等价 POST /media-entities/{id}/refresh(入队 refreshMetadata,字段锁优先、不动路径);
@@ -141,3 +144,77 @@ export async function scrape(
await audit(d, principal, "scrape", { ...result });
return result;
}
/**
* G5 批量刷新元数据:文件/目录归到其下已登记资产的作品,与显式作品合并去重,逐个入队 refreshMetadata。
* 只对有 active 资产的作品提交;其余进 skipped(前端汇总"已提交 n 个作品,锁定字段不变")。
*/
export async function scrapeBatch(
d: FmDeps,
principal: Principal | undefined,
body: FilesScrapeBatchRequest,
): Promise<FilesScrapeBatchResult> {
const db = d.ctx.db;
const skipped: FilesScrapeBatchResult["skipped"] = [];
const ids = new Set(body.mediaEntityIds ?? []);
for (const it of body.items ?? []) {
const r = await resolveIn(d, it.root, it.path);
const found = r.def.libraryRootId
? await entitiesUnder(db, r.def.libraryRootId, r.rel, !!r.lst?.isDirectory())
: [];
if (found.length === 0) skipped.push({ ref: `${it.root}:${it.path}`, reason: "no_entity" });
for (const id of found) ids.add(id);
}
if (ids.size === 0 && skipped.length === 0) throw new FilesError("bad_request", "需要 items 或 mediaEntityIds");
if (ids.size > 200) throw new FilesError("bad_request", `一次最多刷新 200 个作品(本次 ${ids.size})`);
const list = [...ids];
const ents = list.length
? await db.selectFrom("media_entity").select(["id", "title", "field_locks"]).where("id", "in", list).execute()
: [];
const active = new Set(
list.length
? (
await db
.selectFrom("media_asset")
.select(["media_entity_id"])
.where("media_entity_id", "in", list)
.where("status", "=", "active")
.execute()
).map((a) => a.media_entity_id)
: [],
);
const locks = list.length
? await db
.selectFrom("field_override")
.select(["media_entity_id", "field"])
.where("media_entity_id", "in", list)
.where("locked", "=", true)
.execute()
: [];
const submitted: FilesScrapeBatchResult["submitted"] = [];
for (const id of list) {
const e = ents.find((x) => x.id === id);
if (!e) {
skipped.push({ ref: id, reason: "not_found" });
continue;
}
if (!active.has(id)) {
skipped.push({ ref: id, reason: "not_registered" });
continue;
}
const { runId } = await d.ctx.engine.enqueue(
"refreshMetadata",
{ mediaEntityId: id },
{ idempotencyKey: `refreshMetadata:files-batch:${body.idempotencyKey}:${id}`, traceId: randomUUID() },
);
const lockedFields = [
...new Set([...(e.field_locks ?? []), ...locks.filter((l) => l.media_entity_id === id).map((l) => l.field)]),
].sort();
submitted.push({ mediaEntityId: id, title: e.title, taskId: runId, lockedFields });
}
await audit(d, principal, "scrape_batch", {
submitted: submitted.map((s) => s.mediaEntityId),
skipped,
});
return { submitted, skipped };
}
+12 -1
View File
@@ -6,6 +6,7 @@ import { type AuthEnv, requireAdminSession, requireScope } from "../../http/auth
import { errorBody } from "../../http/errors.ts";
import { PathGuardError } from "../acquisition-organize/services/organize-guard.ts";
import type { ModuleApp } from "../types.ts";
import { entityFromCandidate } from "./candidate.ts";
import { FilesError, type FmDeps, fsErrorToFiles } from "./common.ts";
import { downloadFile } from "./download.ts";
import { identify } from "./identify.ts";
@@ -13,10 +14,12 @@ import { idempotent } from "./ledger.ts";
import { listDir, listRoots, statEntry } from "./list.ts";
import { mkdirIn, renameEntry } from "./mutate.ts";
import { organizeFiles } from "./organize.ts";
import { probeFiles, scrape } from "./probe.ts";
import { probeFiles, scrape, scrapeBatch } from "./probe.ts";
import { findRecords } from "./records.ts";
import { suggestName } from "./suggest-name.ts";
import { transfer } from "./transfer.ts";
import { deleteFiles, listTrash, purgeTrash, restoreTrash } from "./trash.ts";
import { recordSources, treeOf } from "./tree.ts";
// /files 路由:scope 由 contracts 端点定义(读 organize.read / 写 organize.execute),处理器内再按授权矩阵细分。
// FilesError → 信封 + 对应状态(根离线 503,message 以 `<reason>: ` 开头)。
@@ -107,4 +110,12 @@ export function registerFileRoutes(app: ModuleApp, d: FmDeps) {
mount(app, F.probe, async (_c, { body }) => probeFiles(d, body));
mount(app, F.records, async (_c, { query }) => findRecords(d, query));
mount(app, F.download, async (c, { query }) => downloadFile(d, c.get("principal"), query), true);
// W9-H 文件管理 v2
mount(app, F.entityFromCandidate, async (c, { body }) => entityFromCandidate(d, c.get("principal"), body));
mount(app, F.scrapeBatch, async (c, { body }) =>
idem(d, c, "scrape_batch", body, () => scrapeBatch(d, c.get("principal"), body)),
);
mount(app, F.tree, async (_c, { query }) => treeOf(d, query));
mount(app, F.recordSources, async (_c, { query }) => recordSources(d, query.recordId));
mount(app, F.suggestName, async (_c, { body }) => suggestName(d, body));
}
@@ -0,0 +1,10 @@
import type { FilesSuggestNameRequest, FilesSuggestNameResult } from "@umt/contracts";
import { FilesError, type FmDeps, resolveIn } from "./common.ts";
// G9 推荐名:按命名模板(作品 > 库根 > 全局)对选中文件渲染文件名/目录段。
// 依赖 W9-C 的命名引擎(packages/domain/src/naming + POST /naming/preview);引擎提交前返回 501 not_implemented。
export async function suggestName(d: FmDeps, body: FilesSuggestNameRequest): Promise<FilesSuggestNameResult> {
await resolveIn(d, body.root, body.path);
throw new FilesError("not_implemented", "命名引擎尚未就绪,暂不能按模板生成推荐名");
}
@@ -0,0 +1,130 @@
import { lstat, readdir } from "node:fs/promises";
import { join } from "node:path";
import type {
FileRootId,
FilesRecordSourcesResult,
FilesTreeQuery,
FilesTreeResult,
OrganizationStatus,
} from "@umt/contracts";
import { sql } from "@umt/db";
import { FilesError, type FmDeps, isTempName, loadRootDefs, resolveIn, within } from "./common.ts";
import { kindOfName } from "./list.ts";
// G8 目录摘要(树折叠 / 上一级):只列子目录(是否还有下级)+ 本目录递归视频数与已登记数;
// G5 整理记录 → 源 FileRef(整理记录"重新整理"按登记目录 hostPath 换算)。
const likePrefix = (rel: string) => `${rel.replace(/[\\%_]/g, (m) => `\\${m}`)}/%`;
const hiddenName = (n: string) => n.startsWith(".") || isTempName(n);
export async function treeOf(d: FmDeps, q: FilesTreeQuery): Promise<FilesTreeResult> {
const r = await resolveIn(d, q.root, q.path);
if (!r.lst?.isDirectory() || r.isSymlink) throw new FilesError("bad_request", `不是目录: ${r.rel || "/"}`);
const names = await readdir(r.abs, { withFileTypes: true });
const subdirs = names
.filter((n) => n.isDirectory() && !hiddenName(n.name))
.map((n) => n.name)
.sort((a, b) => a.localeCompare(b));
const max = d.options.listMaxEntries;
const truncated = subdirs.length > max;
const dirs = await Promise.all(
subdirs.slice(0, max).map(async (name) => {
const path = r.rel ? `${r.rel}/${name}` : name;
const kids = await readdir(join(r.abs, name), { withFileTypes: true }).catch(() => []);
return { name, path, hasChildren: kids.some((k) => k.isDirectory() && !hiddenName(k.name)) };
}),
);
// 递归数视频(上限 dirSummaryMax 项,同列表目录摘要口径)
let seen = 0;
let videoCount = 0;
let partial = false;
const stack = [r.abs];
while (stack.length > 0 && !partial) {
const dir = stack.pop()!;
for (const n of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
if (++seen > d.options.dirSummaryMax) {
partial = true;
break;
}
if (n.name.startsWith(".")) continue;
if (n.isDirectory()) stack.push(join(dir, n.name));
else if (n.isFile() && kindOfName(n.name) === "video") videoCount++;
}
}
let registeredCount = 0;
if (r.def.libraryRootId) {
const row = await sql<{ n: number }>`
select count(*)::int as n from asset_location
where library_root_id = ${r.def.libraryRootId} and status = 'available'
and (${r.rel} = '' or relative_path like ${likePrefix(r.rel)})`.execute(d.ctx.db);
registeredCount = row.rows[0]?.n ?? 0;
}
const slash = r.rel.lastIndexOf("/");
return {
root: q.root,
path: r.rel,
name: r.rel ? r.rel.slice(slash + 1) : r.def.name,
parent: r.rel === "" ? null : slash < 0 ? "" : r.rel.slice(0, slash),
dirs,
truncated,
summary: { videoCount, registeredCount, partial },
};
}
export async function recordSources(d: FmDeps, recordId: string): Promise<FilesRecordSourcesResult> {
const db = d.ctx.db;
const rec = await db
.selectFrom("organization_record")
.select(["id", "media_entity_id", "action", "status", "source_path", "source_path_view"])
.where("id", "=", recordId)
.executeTakeFirst();
if (!rec) throw new FilesError("not_found", "整理记录不存在");
const mains = await db
.selectFrom("organization_file_result")
.select(["source_path"])
.where("organization_record_id", "=", rec.id)
.where("role", "=", "main")
.orderBy("ordinal")
.limit(500)
.execute();
const defs = (await loadRootDefs(db)).sort((a, b) => b.hostPath.length - a.hostPath.length);
const toRef = (abs: string) => {
const def = defs.find((x) => within(abs, x.hostPath));
return def
? { root: def.id as FileRootId, path: abs === def.hostPath ? "" : abs.slice(def.hostPath.length + 1) }
: null;
};
// 多个主视频都在记录的源目录下、且源目录不是登记目录本身 → 给目录(向导按目录展开);否则逐个主视频
let sources = mains.map((m) => m.source_path);
const dir = rec.source_path;
if (
dir &&
mains.length > 1 &&
mains.every((m) => within(m.source_path, dir)) &&
!defs.some((x) => x.hostPath === dir.replace(/\/+$/, ""))
)
sources = [dir];
if (sources.length === 0 && dir) sources = [dir];
const items: FilesRecordSourcesResult["items"] = [];
const unresolved: string[] = [];
for (const abs of [...new Set(sources)]) {
const ref = rec.source_path_view === "local" ? toRef(abs) : null;
const exists = ref
? await lstat(abs).then(
() => true,
() => false,
)
: false;
if (ref && exists) items.push(ref);
else unresolved.push(abs);
}
return {
recordId: rec.id,
mediaEntityId: rec.media_entity_id,
action: rec.action,
status: rec.status as OrganizationStatus,
items,
unresolved,
};
}