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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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:/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user