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: {
@@ -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,
};
}
@@ -0,0 +1,101 @@
import { type TmdbClient, TmdbError } from "@umt/adapters";
import type { MediaKind } from "@umt/contracts";
import type { DB, Kysely } from "@umt/db";
import type { ParsedRelease } from "@umt/domain";
import { type Actor, audit, type Exec } from "./common.ts";
import { createEntityFromTmdb, ensureUnits, entityByExternal, IdentityConflictError } from "./entities.ts";
// W9-H(文件管理整理向导 G1):选中"仅 TMDB 候选"时就地建本地作品 + 外部映射(accepted,证据等级 manual)
// + 季/集单元。幂等:同一 TMDB id 已有 accepted 映射直接复用(跟随合并跳转),并发创建冲突时重试一次。
export class CandidateError extends Error {
constructor(
readonly reason: "not_configured" | "not_found" | "upstream",
message: string,
) {
super(message);
}
}
export interface EntityFromCandidate {
mediaEntityId: string;
created: boolean;
title: string;
year: number | null;
kind: MediaKind;
/** 本次确保存在的季/集单元数 */
contentUnits: number;
}
/** 单季最多建的集单元数(ensureUnits 内部也限 200) */
const MAX_EPISODES_PER_SEASON = 200;
export async function createEntityFromTmdbCandidate(
db: Kysely<DB>,
tmdb: () => Promise<TmdbClient | null>,
ref: { externalId: string; kind: "movie" | "tv" },
actor: Actor,
): Promise<EntityFromCandidate> {
const extRef = { provider: "tmdb", entityType: ref.kind, externalId: ref.externalId };
const existing = await entityByExternal(db, extRef);
if (existing) return describe(db, existing, false, 0);
const client = await tmdb();
if (!client) throw new CandidateError("not_configured", "未配置 TMDB,无法按候选建作品");
let detail: Awaited<ReturnType<TmdbClient["details"]>>;
try {
detail = await client.details(ref.kind, ref.externalId);
} catch (e) {
if (e instanceof TmdbError && e.kind === "notfound")
throw new CandidateError("not_found", `TMDB 无此条目 ${ref.kind}/${ref.externalId}`);
if (e instanceof TmdbError) throw new CandidateError("upstream", `TMDB 查询失败:${e.kind}`);
throw e;
}
for (let attempt = 0; ; attempt++) {
try {
return await db.transaction().execute(async (trx) => {
const r = await createEntityFromTmdb(trx, detail, {
level: "manual",
evidence: { by: `${actor.type}:${actor.id ?? ""}`, via: "files.organize" },
actor,
});
let units = 0;
if (r.created && ref.kind === "tv") {
for (const s of detail.seasons ?? []) {
if (s.seasonNumber < 0) continue;
const eps = Math.min(s.episodeCount, MAX_EPISODES_PER_SEASON);
const parsed = {
season: s.seasonNumber,
...(eps > 0 ? { episodes: { start: 1, end: eps } } : {}),
} as ParsedRelease;
const u = await ensureUnits(trx, r.entityId, "series", parsed);
units += u.unitIds.length + (eps > 0 ? 1 : 0);
}
}
if (r.created)
await audit(
trx,
actor,
"identity.entity_created_from_candidate",
{ type: "media_entity", id: r.entityId },
{ provider: "tmdb", entityType: ref.kind, externalId: ref.externalId, contentUnits: units },
);
return describe(trx, r.entityId, r.created, units);
});
} catch (e) {
// 并发:另一事务刚接受了同一身份 → 本事务回滚,重试时直接复用
if (e instanceof IdentityConflictError && attempt === 0) continue;
throw e;
}
}
}
async function describe(exec: Exec, id: string, created: boolean, units: number): Promise<EntityFromCandidate> {
const e = await exec
.selectFrom("media_entity")
.select(["id", "title", "year", "kind"])
.where("id", "=", id)
.executeTakeFirstOrThrow();
return { mediaEntityId: e.id, created, title: e.title, year: e.year, kind: e.kind, contentUnits: units };
}
+328
View File
@@ -0,0 +1,328 @@
import { mkdir, writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { createTmdbClient } from "@umt/adapters";
import { createMockTmdb, type MockTmdb } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
createFmHarness,
EXEC_KEY,
type FmHarness,
key,
pgAvailable,
READ_KEY,
seedAsset,
seedEntity,
} from "./harness.ts";
// W9-H 文件管理 v2 后端缺口:G1 TMDB 候选建作品 / G2 最小体积 / G3 目标已存在策略 / G4 命名方式与来源 /
// G5 整理记录提示 + 重新整理源 + 整理后刷新元数据 + 批量刮削 / G8 目录摘要 / G9 推荐名(引擎未就绪 501)。
const skip = !(await pgAvailable());
// biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读响应 JSON
type Loose = any;
describe.skipIf(skip)("files v2(W9-H)", () => {
let h: FmHarness;
let tmdb: MockTmdb;
let film: string;
const big = "x".repeat(64);
beforeAll(async () => {
tmdb = await createMockTmdb();
h = await createFmHarness("v2", {
tmdb: () => createTmdbClient({ apiKey: "test-tmdb-key", baseUrl: `${tmdb.url}/3`, retries: 0 }),
});
film = await seedEntity(h.db, { kind: "movie", title: "Blue Film", year: 2021 });
await writeFile(join(h.dl, "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv"), big);
await writeFile(join(h.dl, "Blue.Film.2021.sample.tiny.mkv"), "tiny");
await mkdir(join(h.dl, "The.Bear.S01.1080p"), { recursive: true });
await writeFile(join(h.dl, "The.Bear.S01.1080p", "The.Bear.S01E01.1080p.WEB-DL-GRP.mkv"), big);
});
afterAll(async () => {
await h?.close();
await tmdb?.close();
});
const organize = (body: Record<string, unknown>, k = EXEC_KEY) =>
h.api("POST", "/files/organize", { idempotencyKey: key(), action: "copy", dryRun: true, ...body }, k);
it("G1:按 TMDB 候选建作品 + 外部映射(accepted/manual)+ 季集单元;同 TMDB id 幂等", async () => {
const r = await h.api(
"POST",
"/files/entity-from-candidate",
{ provider: "tmdb", externalId: "136315", kind: "tv" },
EXEC_KEY,
);
expect(r.status).toBe(200);
const d = r.body.data;
expect(d).toMatchObject({ created: true, kind: "series", year: 2022 });
expect(d.contentUnits).toBe(31); // 3 季 + 8/10/10 集
const ident = await h.db
.selectFrom("external_identity")
.select(["provider", "external_id", "status", "evidence_level", "media_entity_id"])
.where("media_entity_id", "=", d.mediaEntityId)
.where("provider", "=", "tmdb")
.executeTakeFirstOrThrow();
expect(ident).toMatchObject({ external_id: "136315", status: "accepted", evidence_level: "manual" });
const units = await h.db
.selectFrom("content_unit")
.select(["kind"])
.where("media_entity_id", "=", d.mediaEntityId)
.execute();
expect(units.filter((u) => u.kind === "season")).toHaveLength(3);
expect(units.filter((u) => u.kind === "episode")).toHaveLength(28);
const again = await h.api(
"POST",
"/files/entity-from-candidate",
{ provider: "tmdb", externalId: "136315", kind: "tv" },
EXEC_KEY,
);
expect(again.body.data).toMatchObject({ mediaEntityId: d.mediaEntityId, created: false });
const count = await h.db
.selectFrom("external_identity")
.select(["id"])
.where("provider", "=", "tmdb")
.where("external_id", "=", "136315")
.execute();
expect(count).toHaveLength(1);
// 只读 key 不能建
const ro = await h.api(
"POST",
"/files/entity-from-candidate",
{ provider: "tmdb", externalId: "1", kind: "movie" },
READ_KEY,
);
expect(ro.status).toBe(403);
// TMDB 无此条目 → 404
const nf = await h.api("POST", "/files/entity-from-candidate", {
provider: "tmdb",
externalId: "424242",
kind: "movie",
});
expect(nf.status).toBe(404);
});
it("G1:整理项带 external → 预览就地建(或复用)作品,组标 entity;与 mediaEntityId 二选一", async () => {
const r = await organize({
items: [
{
root: h.dlRoot,
path: "The.Bear.S01.1080p/The.Bear.S01E01.1080p.WEB-DL-GRP.mkv",
external: { provider: "tmdb", externalId: "136315", kind: "tv" },
},
],
});
expect(r.status).toBe(200);
const g = r.body.data.groups[0];
expect(g.error).toBeNull();
expect(g.entity).toMatchObject({ provider: "tmdb", externalId: "136315", created: false });
expect(g.files[0].target).toMatch(/Season 1/);
const movie = await organize({
items: [
{
root: h.dlRoot,
path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv",
external: { provider: "tmdb", externalId: "872585", kind: "movie" },
},
],
});
expect(movie.body.data.groups[0].entity.created).toBe(true);
const both = await organize({
items: [
{
root: h.dlRoot,
path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv",
mediaEntityId: film,
external: { provider: "tmdb", externalId: "872585", kind: "movie" },
},
],
});
expect(both.status).toBe(400);
const none = await organize({ items: [{ root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv" }] });
expect(none.status).toBe(400);
});
it("G2:整理按最小体积排除(below_min_size);识别按最小体积跳过并计数", async () => {
const items = [
{ root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv", mediaEntityId: film },
{ root: h.dlRoot, path: "Blue.Film.2021.sample.tiny.mkv", mediaEntityId: film },
];
const r = await organize({ items, minSizeBytes: 32 });
const g = r.body.data.groups[0];
expect(g.minSizeBytes).toBe(32);
expect(g.files.map((f: Loose) => basename(f.source))).toEqual(["Blue.Film.2021.2160p.BluRay.x265-GRP.mkv"]);
expect(g.excluded).toContainEqual(
expect.objectContaining({ source: join(h.dl, "Blue.Film.2021.sample.tiny.mkv"), reason: expect.any(String) }),
);
// 非 sample 命名的小文件:明确 below_min_size + 体积
await writeFile(join(h.dl, "Blue.Film.2021.1080p.small.mkv"), "0123456789ab");
const r2 = await organize({
items: [{ root: h.dlRoot, path: "Blue.Film.2021.1080p.small.mkv", mediaEntityId: film }],
minSizeBytes: 32,
});
expect(r2.body.data.groups[0].excluded).toEqual([
{ source: join(h.dl, "Blue.Film.2021.1080p.small.mkv"), kind: "video", size: 12, reason: "below_min_size" },
]);
const id = await h.api(
"POST",
"/files/identify",
{
items: [
{ root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv" },
{ root: h.dlRoot, path: "Blue.Film.2021.1080p.small.mkv" },
],
minSizeBytes: 32,
},
READ_KEY,
);
expect(id.status).toBe(200);
expect(id.body.data.excludedBySize).toBe(1);
const [a, b] = id.body.data.items;
expect(a).toMatchObject({ size: 64, skipped: null });
expect(b).toMatchObject({ size: 12, skipped: "below_min_size", candidates: [] });
});
it("G3:目标已存在策略 skip / conflict / replace_with_verify 逐文件标注", async () => {
const other = await seedEntity(h.db, { kind: "movie", title: "Clash Film", year: 2020 });
await writeFile(join(h.dl, "Clash.Film.2020.1080p.WEB-DL-GRP.mkv"), big);
const items = [{ root: h.dlRoot, path: "Clash.Film.2020.1080p.WEB-DL-GRP.mkv", mediaEntityId: other }];
const first = await organize({ items });
const target = first.body.data.groups[0].files[0].target as string;
expect(first.body.data.groups[0].files[0].existing).toBeNull();
expect(first.body.data.groups[0].onExisting).toBe("conflict");
await mkdir(join(target, ".."), { recursive: true });
await writeFile(target, "different-content-of-another-size");
const conflict = await organize({ items });
const gc = conflict.body.data.groups[0];
expect(gc.files[0]).toMatchObject({ existing: "conflict", action: "hold" });
expect(gc.error.code).toBe("plan_blocked");
const skipped = await organize({ items, onExisting: "skip" });
const gs = skipped.body.data.groups[0];
expect(gs.files[0]).toMatchObject({ existing: "skip", action: "skip", skipReason: "target_exists" });
expect(gs.error).toBeNull();
expect(gs.onExisting).toBe("skip");
const replace = await organize({ items, onExisting: "replace_with_verify" });
const gr = replace.body.data.groups[0];
expect(gr.files[0]).toMatchObject({ existing: "replace", action: "replace" });
expect(gr.error).toBeNull();
// 策略进请求指纹:skip 的 planId 不能拿去执行 replace
const exec = await organize({ items, onExisting: "replace_with_verify", dryRun: false, planIds: [gs.planId] });
expect(exec.body.data.groups[0].error.code).toBe("plan_stale");
});
it("G4:命名方式 keep 保留原文件名(来源 override);template 记录模板来源/版本;执行后冻结进整理记录", async () => {
const items = [{ root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv", mediaEntityId: film }];
const tpl = await organize({ items });
const gt = tpl.body.data.groups[0];
expect(gt.naming).toMatchObject({ mode: "template", templateSource: "global" });
expect(typeof gt.naming.templateVersion).toBe("number");
expect(gt.targetSegments).toEqual([{ text: "Blue Film (2021)", role: "root", bound: false }]);
const keep = await organize({ items, naming: "keep" });
const gk = keep.body.data.groups[0];
expect(gk.naming).toMatchObject({ mode: "keep", templateSource: "override" });
expect(basename(gk.files[0].target)).toBe("Blue.Film.2021.2160p.BluRay.x265-GRP.mkv");
expect(gk.files[0].target).toContain("Blue Film (2021)");
const exec = await organize({ items, naming: "keep", dryRun: false, planIds: [gk.planId], refreshMetadata: true });
expect(exec.status).toBe(200);
const g = exec.body.data.groups[0];
expect(g.error).toBeNull();
const rec = await h.db
.selectFrom("organization_record")
.select(["plan"])
.where("id", "=", g.recordId)
.executeTakeFirstOrThrow();
expect((rec.plan as Loose).naming).toMatchObject({ mode: "keep", templateSource: "override" });
// G5:整理后刷新元数据
expect(g.refreshTaskId).toBeTruthy();
expect(
h.engine.enqueued.some((e) => e.task === "refreshMetadata" && (e.input as Loose).mediaEntityId === film),
).toBe(true);
});
it("G5:识别标已有整理记录;整理记录 → 源 FileRef(重新整理)", async () => {
const id = await h.api(
"POST",
"/files/identify",
{ items: [{ root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv" }] },
READ_KEY,
);
const recs = id.body.data.items[0].records;
expect(recs.count).toBeGreaterThanOrEqual(1);
expect(recs.lastRecordId).toBeTruthy();
const src = await h.api("GET", `/files/record-sources?recordId=${recs.lastRecordId}`, undefined, READ_KEY);
expect(src.status).toBe(200);
expect(src.body.data.items).toEqual([{ root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv" }]);
expect(src.body.data.unresolved).toEqual([]);
expect(src.body.data.mediaEntityId).toBe(film);
});
it("G5:批量刷新元数据按作品去重,未登记作品跳过", async () => {
const reg = await seedEntity(h.db, { kind: "movie", title: "Registered Film", year: 2018 });
await mkdir(join(h.lib, "Registered Film (2018)"), { recursive: true });
await writeFile(join(h.lib, "Registered Film (2018)", "a.mkv"), big);
await writeFile(join(h.lib, "Registered Film (2018)", "b.mkv"), big);
await seedAsset(h.db, h, { entityId: reg, rel: "Registered Film (2018)/a.mkv" });
await seedAsset(h.db, h, { entityId: reg, rel: "Registered Film (2018)/b.mkv" });
const bare = await seedEntity(h.db, { kind: "movie", title: "Bare Film", year: 2017 });
const r = await h.api(
"POST",
"/files/scrape-batch",
{
idempotencyKey: key(),
items: [
{ root: h.libRoot, path: "Registered Film (2018)/a.mkv" },
{ root: h.libRoot, path: "Registered Film (2018)/b.mkv" },
],
mediaEntityIds: [reg, bare],
},
EXEC_KEY,
);
expect(r.status).toBe(200);
expect(r.body.data.submitted.map((s: Loose) => s.mediaEntityId)).toEqual([reg]);
expect(r.body.data.skipped).toEqual([{ ref: bare, reason: "not_registered" }]);
});
it("G8:目录摘要 — 上一级、子目录是否有下级、视频/已登记计数", async () => {
await mkdir(join(h.lib, "Tree", "A", "Deep"), { recursive: true });
await mkdir(join(h.lib, "Tree", "B"), { recursive: true });
await writeFile(join(h.lib, "Tree", "A", "x.mkv"), big);
const r = await h.api("GET", `/files/tree?root=${h.libRoot}&path=Tree`, undefined, READ_KEY);
expect(r.status).toBe(200);
expect(r.body.data).toMatchObject({
path: "Tree",
name: "Tree",
parent: "",
dirs: [
{ name: "A", path: "Tree/A", hasChildren: true },
{ name: "B", path: "Tree/B", hasChildren: false },
],
summary: { videoCount: 1, registeredCount: 0, partial: false },
});
const top = await h.api("GET", `/files/tree?root=${h.libRoot}&path=`, undefined, READ_KEY);
expect(top.body.data.parent).toBeNull();
expect(top.body.data.summary.registeredCount).toBeGreaterThanOrEqual(2);
});
it("G9:推荐名在命名引擎接入前返回 501 not_implemented", async () => {
const r = 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:/);
});
});