diff --git a/apps/server/src/modules/acquisition-organize/services/organize.ts b/apps/server/src/modules/acquisition-organize/services/organize.ts index f008c39f..1396bfd3 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize.ts @@ -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 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 c.blocking); @@ -646,6 +673,28 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise join(dirname(f.dst), basename(f.src)); + +/** W9-H 保留原文件名:改写计划目标;改写后撞名的项转 hold + duplicate_target(阻塞) */ +function keepOriginalNames(plan: OrganizePlan): void { + const renamed = new Map(); + 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(); + 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: { diff --git a/apps/server/src/modules/file-manager/candidate.ts b/apps/server/src/modules/file-manager/candidate.ts new file mode 100644 index 00000000..536d686d --- /dev/null +++ b/apps/server/src/modules/file-manager/candidate.ts @@ -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 { + 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}`; diff --git a/apps/server/src/modules/file-manager/common.ts b/apps/server/src/modules/file-manager/common.ts index 52e0b0bf..bc5a2448 100644 --- a/apps/server/src/modules/file-manager/common.ts +++ b/apps/server/src/modules/file-manager/common.ts @@ -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; } 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 以 `: ` 开头便于前端分支 */ diff --git a/apps/server/src/modules/file-manager/identify.ts b/apps/server/src/modules/file-manager/identify.ts index 11576698..124c450b 100644 --- a/apps/server/src/modules/file-manager/identify.ts +++ b/apps/server/src/modules/file-manager/identify.ts @@ -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 = { 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 { 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(); const remoteCache = new Map(); const entityKinds = new Map(); @@ -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, + }; } diff --git a/apps/server/src/modules/file-manager/index.ts b/apps/server/src/modules/file-manager/index.ts index 44d0c637..45d619ed 100644 --- a/apps/server/src/modules/file-manager/index.ts +++ b/apps/server/src/modules/file-manager/index.ts @@ -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; + /** 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; diff --git a/apps/server/src/modules/file-manager/organize.ts b/apps/server/src/modules/file-manager/organize.ts index c8d3d598..3be54c33 100644 --- a/apps/server/src/modules/file-manager/organize.ts +++ b/apps/server/src/modules/file-manager/organize.ts @@ -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>) => (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 { + 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 { + 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(); + 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(); + 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 { if (planIds.length === 0) throw new FilesError("bad_request", "执行需要 dryRun 返回的 planIds"); diff --git a/apps/server/src/modules/file-manager/probe.ts b/apps/server/src/modules/file-manager/probe.ts index 856ff7dc..c29c051c 100644 --- a/apps/server/src/modules/file-manager/probe.ts +++ b/apps/server/src/modules/file-manager/probe.ts @@ -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 { + 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 }; +} diff --git a/apps/server/src/modules/file-manager/routes.ts b/apps/server/src/modules/file-manager/routes.ts index b943184b..c96d2227 100644 --- a/apps/server/src/modules/file-manager/routes.ts +++ b/apps/server/src/modules/file-manager/routes.ts @@ -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 以 `: ` 开头)。 @@ -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)); } diff --git a/apps/server/src/modules/file-manager/suggest-name.ts b/apps/server/src/modules/file-manager/suggest-name.ts new file mode 100644 index 00000000..9db9d35e --- /dev/null +++ b/apps/server/src/modules/file-manager/suggest-name.ts @@ -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 { + await resolveIn(d, body.root, body.path); + throw new FilesError("not_implemented", "命名引擎尚未就绪,暂不能按模板生成推荐名"); +} diff --git a/apps/server/src/modules/file-manager/tree.ts b/apps/server/src/modules/file-manager/tree.ts new file mode 100644 index 00000000..d11dd7fa --- /dev/null +++ b/apps/server/src/modules/file-manager/tree.ts @@ -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 { + 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 { + 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, + }; +} diff --git a/apps/server/src/modules/sources-identity/service/entity-from-candidate.ts b/apps/server/src/modules/sources-identity/service/entity-from-candidate.ts new file mode 100644 index 00000000..13b1b8f1 --- /dev/null +++ b/apps/server/src/modules/sources-identity/service/entity-from-candidate.ts @@ -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, + tmdb: () => Promise, + ref: { externalId: string; kind: "movie" | "tv" }, + actor: Actor, +): Promise { + 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>; + 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 { + 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 }; +} diff --git a/apps/server/test/file-manager/v2.test.ts b/apps/server/test/file-manager/v2.test.ts new file mode 100644 index 00000000..aae39977 --- /dev/null +++ b/apps/server/test/file-manager/v2.test.ts @@ -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, 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:/); + }); +});