diff --git a/apps/server/src/modules/acquisition-organize/__tests__/records-filter.test.ts b/apps/server/src/modules/acquisition-organize/__tests__/records-filter.test.ts new file mode 100644 index 00000000..92c0c207 --- /dev/null +++ b/apps/server/src/modules/acquisition-organize/__tests__/records-filter.test.ts @@ -0,0 +1,121 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createHarness, type Harness, pgAvailable, seedEntity } from "./harness.ts"; + +// HIS-1 整理记录列表筛选与统计:类型(电影 / 剧集 / 未识别)、时间、关键字(标题 / 路径 / 文件名 / 种子 hash,支持 * ?)、 +// 只看整理类、状态计数与列表同口径;列表带海报、大小、下载器与 hash。 + +const skip = !(await pgAvailable()); + +describe.skipIf(skip)("整理记录筛选与统计", () => { + let h: Harness; + const ids: Record = {}; + beforeAll(async () => { + h = await createHarness("recfilter"); + const film = await seedEntity(h.db, { kind: "movie", title: "Arrival", year: 2016 }); + await h.db + .updateTable("media_entity") + .set({ metadata: JSON.stringify({ posterPath: { value: "/arrival.jpg" } }) }) + .where("id", "=", film) + .execute(); + const show = await seedEntity(h.db, { kind: "series", title: "Severance", year: 2022 }); + const inst = await h.db + .insertInto("downloader_instance") + .values({ name: "家里的 qB", base_url: "http://127.0.0.1:1" }) + .returning("id") + .executeTakeFirstOrThrow(); + const task = await h.db + .insertInto("download_task") + .values({ instance_id: inst.id, torrent_hash: "abcdef0123456789abcdef0123456789abcdef01", name: "Arrival.2016" }) + .returning("id") + .executeTakeFirstOrThrow(); + const old = new Date(Date.now() - 40 * 86_400_000); + const rows = [ + { k: "film", entity: film, status: "succeeded", action: "copy", src: "/dl/Arrival.2016.mkv", task: task.id }, + { k: "show", entity: show, status: "failed", action: "move", src: "/dl/Severance.S01E01.mkv", at: old }, + { k: "unknown", entity: null, status: "failed", action: "copy", src: "/dl/random.mp4" }, + { k: "rename", entity: film, status: "succeeded", action: "rename", src: "/lib/Arrival/a.mkv" }, + ] as const; + for (const r of rows) { + const rec = await h.db + .insertInto("organization_record") + .values({ + trigger: "auto", + actor_type: "system", + media_entity_id: r.entity, + download_task_id: "task" in r ? r.task : null, + source_path: r.src, + action: r.action, + status: r.status, + ...("at" in r ? { created_at: r.at } : {}), + }) + .returning("id") + .executeTakeFirstOrThrow(); + ids[r.k] = rec.id; + await h.db + .insertInto("organization_file_result") + .values({ + organization_record_id: rec.id, + mode: r.action, + source_path: `${r.src}`, + source_size_bytes: 1000, + status: r.status === "failed" ? "failed" : "succeeded", + }) + .execute(); + } + }); + afterAll(async () => h?.close()); + + const list = async (q: string) => { + const r = await h.api("GET", `/organization-records?${q}`); + expect(r.status).toBe(200); + return r.body.data.items as { id: string }[]; + }; + const idsOf = (items: { id: string }[]) => items.map((x) => x.id).sort(); + + it("类型:电影 / 剧集 / 未识别", async () => { + expect(idsOf(await list("kind=movie"))).toEqual([ids.film, ids.rename].sort()); + expect(idsOf(await list("kind=series"))).toEqual([ids.show]); + expect(idsOf(await list("kind=none"))).toEqual([ids.unknown]); + }); + + it("时间:since 之后创建的", async () => { + const since = new Date(Date.now() - 7 * 86_400_000).toISOString(); + const items = await list(`since=${encodeURIComponent(since)}`); + expect(items.map((x) => x.id)).not.toContain(ids.show); + expect(items).toHaveLength(3); + }); + + it("关键字:标题、文件名、种子 hash、通配", async () => { + expect(idsOf(await list("q=Severance"))).toEqual([ids.show]); + expect(idsOf(await list(`q=${encodeURIComponent("abcdef0123")}`))).toEqual([ids.film]); + expect(idsOf(await list(`q=${encodeURIComponent("*.mp4")}`))).toEqual([ids.unknown]); + expect(idsOf(await list(`q=${encodeURIComponent("/dl/?rrival*")}`))).toEqual([ids.film]); + // 通配里的 % 与 _ 按字面匹配 + expect(await list(`q=${encodeURIComponent("100%")}`)).toEqual([]); + }); + + it("只看整理类:不含改名 / 删除 / 还原", async () => { + const items = await list("organizeOnly=true"); + expect(items.map((x) => x.id)).not.toContain(ids.rename); + expect(items).toHaveLength(3); + }); + + it("统计与列表同口径,不受状态筛选影响", async () => { + const all = await h.api("GET", "/organization-records/stats"); + expect(all.status).toBe(200); + expect(all.body.data).toEqual({ total: 4, succeeded: 2, partial: 0, failed: 2, running: 0, cancelled: 0 }); + const movies = await h.api("GET", "/organization-records/stats?kind=movie&organizeOnly=true"); + expect(movies.body.data).toMatchObject({ total: 1, succeeded: 1, failed: 0 }); + }); + + it("列表项带海报、大小、下载器名称与 hash", async () => { + const [film] = await list("q=Arrival.2016"); + expect(film).toMatchObject({ + media: { title: "Arrival", posterPath: "/arrival.jpg" }, + sizeBytes: 1000, + download: { name: "Arrival.2016", hash: "abcdef0123456789abcdef0123456789abcdef01", downloaderName: "家里的 qB" }, + }); + const [unknown] = await list("kind=none"); + expect(unknown).toMatchObject({ media: null, download: null, sizeBytes: 1000 }); + }); +}); diff --git a/apps/server/src/modules/acquisition-organize/routes/organization.ts b/apps/server/src/modules/acquisition-organize/routes/organization.ts index e1269b01..6a471ee7 100644 --- a/apps/server/src/modules/acquisition-organize/routes/organization.ts +++ b/apps/server/src/modules/acquisition-organize/routes/organization.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; -import { Api, type OrganizationChangesResponse } from "@umt/contracts"; -import { sql } from "@umt/db"; +import { Api, type OrganizationChangesResponse, type OrganizationStatsQuery } from "@umt/contracts"; +import { type DB, sql } from "@umt/db"; +import type { ExpressionBuilder, SelectQueryBuilder } from "kysely"; import { ApiException } from "../../../http/errors.ts"; import type { ModuleApp } from "../../types.ts"; import { type AoDeps, ensureTaskRow, markTask } from "../services/common.ts"; @@ -28,27 +29,39 @@ export function registerOrganizationRoutes(app: ModuleApp, d: AoDeps) { app, Api.organization.list, async (_c, { query }) => { - let q = db().selectFrom("organization_record").selectAll(); + let q = recordFilters(db().selectFrom("organization_record").selectAll(), query); if (query.status) q = q.where("status", "=", query.status); - if (query.trigger) q = q.where("trigger", "=", query.trigger); - if (query.mediaEntityId) q = q.where("media_entity_id", "=", query.mediaEntityId); - if (query.downloadTaskId) q = q.where("download_task_id", "=", query.downloadTaskId); - if (query.q) { - const like = `%${String(query.q).replace(/[\\%_]/g, (m) => `\\${m}`)}%`; - q = q.where((eb) => - eb.or([ - eb("source_path", "ilike", like), - eb("target_path", "ilike", like), - eb("media_entity_id", "in", eb.selectFrom("media_entity").select("id").where("title", "ilike", like)), - ]), - ); - } const p = await paginate(q, query); return { items: await loadRecordsPublic(db(), p.rows, false), nextCursor: p.nextCursor }; }, { tag: TAG }, ); + // HIS-1:按状态计数,筛选与列表一致(不含状态本身);必须先于 {id} 注册 + mount( + app, + Api.organization.stats, + async (_c, { query }) => { + const rows = await recordFilters( + db() + .selectFrom("organization_record") + .select(["status", (eb) => eb.fn.countAll().as("n")]) + .groupBy("status"), + query, + ).execute(); + const n = (s: string) => Number(rows.find((r) => r.status === s)?.n ?? 0); + return { + total: rows.reduce((a, r) => a + Number(r.n), 0), + succeeded: n("succeeded"), + partial: n("partial"), + failed: n("failed"), + running: n("running") + n("queued"), + cancelled: n("cancelled"), + }; + }, + { tag: TAG }, + ); + // 增量读取:change_xid 早于当前快照最小活跃事务,避免"序号小但提交晚"被跳过 mount( app, @@ -239,6 +252,54 @@ export function registerOrganizationRoutes(app: ModuleApp, d: AoDeps) { ); } +/** 通配(* ?)转 ILIKE 模式;不带通配时两侧补 % */ +export function likePattern(input: string): string { + const esc = input.replace(/[\\%_]/g, (m) => `\\${m}`); + return /[*?]/.test(input) ? esc.replace(/\*/g, "%").replace(/\?/g, "_") : `%${esc}%`; +} + +/** 列表 / 统计共用筛选(不含状态) */ +// biome-ignore lint/suspicious/noExplicitAny: 同一张表的 select 与 groupBy 查询共用 +function recordFilters>( + q0: Q, + query: OrganizationStatsQuery, +): Q { + // biome-ignore lint/suspicious/noExplicitAny: kysely 泛型在链式 where 后无法保持 Q + let q = q0 as any; + if (query.trigger) q = q.where("trigger", "=", query.trigger); + if (query.mediaEntityId) q = q.where("media_entity_id", "=", query.mediaEntityId); + if (query.downloadTaskId) q = q.where("download_task_id", "=", query.downloadTaskId); + if (query.since) q = q.where("created_at", ">=", new Date(query.since)); + if (query.organizeOnly) q = q.where("action", "in", ["copy", "move", "hardlink"]); + if (query.kind === "none") q = q.where("media_entity_id", "is", null); + else if (query.kind) + q = q.where("media_entity_id", "in", (eb: ExpressionBuilder) => + eb + .selectFrom("media_entity") + .select("id") + .where("kind", query.kind === "movie" ? "=" : "<>", "movie"), + ); + if (query.q) { + const like = likePattern(String(query.q).trim()); + q = q.where((eb: ExpressionBuilder) => + eb.or([ + eb("source_path", "ilike", like), + eb("target_path", "ilike", like), + eb("media_entity_id", "in", eb.selectFrom("media_entity").select("id").where("title", "ilike", like)), + eb("download_task_id", "in", eb.selectFrom("download_task").select("id").where("torrent_hash", "ilike", like)), + eb.exists( + eb + .selectFrom("organization_file_result as f") + .select("f.id") + .whereRef("f.organization_record_id", "=", "organization_record.id") + .where((e) => e.or([e("f.source_path", "ilike", like), e("f.target_path", "ilike", like)])), + ), + ]), + ); + } + return q as Q; +} + async function audit(d: AoDeps, actor: string, action: string, detail: Record) { await d.ctx.db .insertInto("audit_event") diff --git a/apps/server/src/modules/acquisition-organize/services/organize.ts b/apps/server/src/modules/acquisition-organize/services/organize.ts index bb49b356..e0e88d56 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize.ts @@ -1821,7 +1821,8 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[ if (rows.length === 0) return []; const ids = rows.map((r) => r.id); const entityIds = [...new Set(rows.map((r) => r.media_entity_id).filter((x): x is string => !!x))]; - const [files, ops, entities, idents, units] = await Promise.all([ + const dlIds = [...new Set(rows.map((r) => r.download_task_id).filter((x): x is string => !!x))]; + const [files, ops, entities, idents, units, sizes, dls] = await Promise.all([ includeFiles ? exec .selectFrom("organization_file_result") @@ -1854,8 +1855,27 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[ "00000000-0000-0000-0000-000000000000", ]) .execute(), + // HIS-1:列表也要大小,不带逐文件明细时单独汇总 + exec + .selectFrom("organization_file_result") + .select(["organization_record_id", (eb) => eb.fn.sum("source_size_bytes").as("bytes")]) + .where("organization_record_id", "in", ids) + .groupBy("organization_record_id") + .execute(), + dlIds.length + ? exec + .selectFrom("download_task as t") + .leftJoin("downloader_instance as i", "i.id", "t.instance_id") + .select(["t.id", "t.name", "t.torrent_hash", "i.name as downloader_name"]) + .where("t.id", "in", dlIds) + .execute() + : Promise.resolve([]), ]); const iso = (v: Date | null) => (v ? v.toISOString() : null); + const posterOf = (metadata: unknown) => { + const v = (metadata as { posterPath?: { value?: unknown } } | null)?.posterPath?.value; + return typeof v === "string" && v ? v : null; + }; // blocked:* 的说明在 error.conflicts[].detail(中文),有就用它,不把内部串直接给人看 const conflictText = (err: unknown) => ((err as { conflicts?: { detail?: string }[] } | null)?.conflicts ?? []) @@ -1897,6 +1917,7 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[ externalIds: idents .filter((i) => i.media_entity_id === e.id) .map((i) => ({ provider: i.provider, entityType: i.entity_type, externalId: i.external_id })), + posterPath: posterOf(e.metadata), } : null, trigger: r.trigger, @@ -1913,6 +1934,14 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[ target: targetRef, reason: reason(r.reason, r.error && r.status === "failed" ? r.error : null), counts: { total: r.file_total, succeeded: r.file_succeeded, failed: r.file_failed, skipped: r.file_skipped }, + sizeBytes: (() => { + const b = sizes.find((x) => x.organization_record_id === r.id)?.bytes; + return b == null ? null : Number(b); + })(), + download: (() => { + const t = dls.find((x) => x.id === r.download_task_id); + return t ? { taskId: t.id, name: t.name, hash: t.torrent_hash, downloaderName: t.downloader_name } : null; + })(), files: includeFiles ? files .filter((f) => f.organization_record_id === r.id) diff --git a/apps/server/src/modules/file-manager/record-delete.ts b/apps/server/src/modules/file-manager/record-delete.ts new file mode 100644 index 00000000..35b03724 --- /dev/null +++ b/apps/server/src/modules/file-manager/record-delete.ts @@ -0,0 +1,278 @@ +import { randomUUID } from "node:crypto"; +import { lstat } from "node:fs/promises"; +import { basename, isAbsolute, join } from "node:path"; +import type { + FileRef, + FilesDeleteResult, + OrganizationDeleteBlock, + OrganizationDeleteRequest, + OrganizationDeleteResult, +} from "@umt/contracts"; +import type { Principal } from "../../http/auth.ts"; +import { FilesError, type FmDeps, isAdmin, loadRootDefs, type RootDef, within } from "./common.ts"; +import { audit } from "./ledger.ts"; +import { deleteFiles } from "./trash.ts"; + +// HIS-1 删除整理记录(对照 MP DELETE /history/transfer?deletesrc&deletedest): +// 可选把源文件 / 媒体库里的文件一并移入回收站(走文件管理的删除:做种义务、已入库文件、绑定目录的检查都照旧), +// 下载器里的任务不动。整条记录的文件有任何一个不能删,这条记录就不删(不留半截)。 +// 只有复制 / 移动 / 硬链接这三类整理记录有"源文件 / 媒体库文件";改名、删除、还原类记录只删记录本身。 + +const FILE_ACTIONS = new Set(["copy", "move", "hardlink"]); + +const BLOCK_TEXT: Record = { + running: "还在整理,等结束后再删", + not_found: "记录不存在", + download_active: "下载还没完成或做种时间不够", + needs_unbind: "是作品的绑定目录", + is_root: "在只读的媒体库目录里", + root_unavailable: "目录不可访问或未挂载", + escape: "路径指向登记目录之外", + outside_roots: "文件不在登记目录里", + failed: "删除失败", +}; + +type Ref = FileRef & { abs: string }; +type Plan = { + id: string; + blocked: OrganizationDeleteBlock | null; + message: string | null; + sources: string[]; + targets: string[]; + downloadTaskId: string | null; +}; + +const exists = (abs: string) => + lstat(abs).then( + () => true, + () => false, + ); + +async function planRecords( + d: FmDeps, + body: OrganizationDeleteRequest, +): Promise<{ plans: Plan[]; refs: Map }> { + const db = d.ctx.db; + const ids = [...new Set(body.ids)]; + const recs = await db + .selectFrom("organization_record") + .select(["id", "status", "action", "source_path_view", "download_task_id"]) + .where("id", "in", ids) + .execute(); + const files = + body.deleteSource || body.deleteTarget + ? await db + .selectFrom("organization_file_result as f") + .leftJoin("library_root as lr", "lr.id", "f.target_library_root_id") + .select([ + "f.organization_record_id", + "f.status", + "f.source_path", + "f.target_path", + "lr.local_path as root_path", + ]) + .where("f.organization_record_id", "in", ids) + .orderBy("f.ordinal") + .execute() + : []; + const defs: RootDef[] = (await loadRootDefs(db)).sort((a, b) => b.hostPath.length - a.hostPath.length); + const refs = new Map(); + const toRef = (abs: string): Ref | null => { + const def = defs.find((x) => within(abs, x.hostPath)); + return def ? { root: def.id, path: abs === def.hostPath ? "" : abs.slice(def.hostPath.length + 1), abs } : null; + }; + + const plans: Plan[] = []; + for (const id of ids) { + const r = recs.find((x) => x.id === id); + const plan: Plan = { + id, + blocked: null, + message: null, + sources: [], + targets: [], + downloadTaskId: r?.download_task_id ?? null, + }; + plans.push(plan); + if (!r) { + plan.blocked = "not_found"; + continue; + } + if (r.status === "queued" || r.status === "running") { + plan.blocked = "running"; + continue; + } + if (!FILE_ACTIONS.has(r.action)) continue; + const mine = files.filter((f) => f.organization_record_id === id); + const want: { abs: string; side: "sources" | "targets" }[] = []; + if (body.deleteSource && r.source_path_view === "local") + for (const f of mine) want.push({ abs: f.source_path, side: "sources" }); + if (body.deleteTarget) + for (const f of mine) { + if (f.status !== "succeeded" || !f.target_path) continue; + const abs = isAbsolute(f.target_path) ? f.target_path : f.root_path ? join(f.root_path, f.target_path) : null; + if (abs) want.push({ abs, side: "targets" }); + } + for (const w of want) { + // 已经不在的(移动过、之前删过)不算,也不拦 + if (plan[w.side].includes(w.abs) || !(await exists(w.abs))) continue; + const ref = toRef(w.abs); + if (!ref) { + plan.blocked ??= "outside_roots"; + plan.message ??= `${basename(w.abs)}:${BLOCK_TEXT.outside_roots}`; + continue; + } + refs.set(w.abs, ref); + plan[w.side].push(w.abs); + } + } + return { plans, refs }; +} + +/** 文件管理删除的核对(dryRun),按绝对路径回填每条记录的阻断原因 */ +async function checkFiles( + d: FmDeps, + principal: Principal | undefined, + plans: Plan[], + refs: Map, +): Promise> { + const out = new Map(); + const paths = [...new Set(plans.filter((p) => !p.blocked).flatMap((p) => [...p.sources, ...p.targets]))]; + if (paths.length === 0) return out; + const dry = await deleteFiles(d, principal, { + idempotencyKey: randomUUID(), + items: paths.map((abs) => ({ root: refs.get(abs)!.root, path: refs.get(abs)!.path })), + mode: "trash", + dryRun: true, + retireAssets: true, + unbind: false, + }); + paths.forEach((abs, i) => { + const c = dry.checks[i]; + if (c) out.set(abs, c); + }); + for (const p of plans) { + if (p.blocked) continue; + for (const abs of [...p.sources, ...p.targets]) { + const b = out.get(abs)?.blocked; + if (!b || b === "not_found" || b === "needs_retire") continue; + p.blocked = b === "escape" ? "escape" : b; + p.message = `${basename(abs)}:${BLOCK_TEXT[p.blocked]}`; + break; + } + } + return out; +} + +export async function deleteRecords( + d: FmDeps, + principal: Principal | undefined, + body: OrganizationDeleteRequest, +): Promise { + const withFiles = body.deleteSource || body.deleteTarget; + if (withFiles && !isAdmin(principal)) + throw new FilesError( + "forbidden", + "API Key 不能在这里删文件。先用 /files/delete 删文件(需要一次性审批),再删记录", + ); + const db = d.ctx.db; + const { plans, refs } = await planRecords(d, body); + const checks = await checkFiles(d, principal, plans, refs); + const sizeOf = (abs: string) => checks.get(abs)?.size ?? 0; + + // 删记录后没有别的整理记录、下载已完成但没入库的任务,会被自动整理再处理一次 + const okIds = plans.filter((p) => !p.blocked).map((p) => p.id); + const taskIds = [...new Set(plans.filter((p) => !p.blocked && p.downloadTaskId).map((p) => p.downloadTaskId!))]; + const retry = taskIds.length + ? await db + .selectFrom("download_task as t") + .select("t.id") + .where("t.id", "in", taskIds) + .where("t.status", "=", "completed") + .where("t.managed", "=", true) + .where("t.removed_at", "is", null) + .where((eb) => + eb.not( + eb.exists( + eb + .selectFrom("organization_record as r") + .select("r.id") + .whereRef("r.download_task_id", "=", "t.id") + .where("r.id", "not in", okIds), + ), + ), + ) + .execute() + : []; + + const summary = (removed: string[], trashRecordId: string | null): OrganizationDeleteResult => { + const live = plans.filter((p) => (body.dryRun ? !p.blocked : removed.includes(p.id))); + const src = [...new Set(live.flatMap((p) => p.sources))]; + const dst = [...new Set(live.flatMap((p) => p.targets))]; + return { + dryRun: body.dryRun, + items: plans.map((p) => ({ + id: p.id, + sourceFiles: p.sources.length, + targetFiles: p.targets.length, + bytes: [...p.sources, ...p.targets].reduce((a, x) => a + sizeOf(x), 0), + blocked: p.blocked, + message: p.message ?? (p.blocked ? BLOCK_TEXT[p.blocked] : null), + })), + removed, + sourceFiles: src.length, + targetFiles: dst.length, + bytes: [...src, ...dst].reduce((a, x) => a + sizeOf(x), 0), + assets: dst.reduce((a, x) => a + (checks.get(x)?.assets.length ?? 0), 0), + autoRetry: retry.length, + trashRecordId, + }; + }; + if (body.dryRun) return summary([], null); + + // 执行:先把文件移入回收站,失败的文件所属记录保留 + let trashRecordId: string | null = null; + const failedPaths = new Set(); + const paths = [...new Set(plans.filter((p) => !p.blocked).flatMap((p) => [...p.sources, ...p.targets]))].filter( + (abs) => checks.get(abs)?.blocked !== "not_found", + ); + if (paths.length > 0) { + const items = paths.map((abs) => ({ root: refs.get(abs)!.root, path: refs.get(abs)!.path })); + const base = { mode: "trash" as const, retireAssets: true, unbind: false }; + const dry = await deleteFiles(d, principal, { idempotencyKey: randomUUID(), items, dryRun: true, ...base }); + const res = await deleteFiles(d, principal, { + idempotencyKey: randomUUID(), + items, + dryRun: false, + planId: dry.planId ?? undefined, + ...base, + }); + trashRecordId = res.recordId; + for (const f of res.failed) { + const hit = paths.find((abs, i) => items[i]!.path === f.path || abs.endsWith(`/${f.path}`)); + if (hit) failedPaths.add(hit); + } + } + for (const p of plans) { + if (p.blocked) continue; + const bad = [...p.sources, ...p.targets].find((abs) => failedPaths.has(abs)); + if (bad) { + p.blocked = "failed"; + p.message = `${basename(bad)}:${BLOCK_TEXT.failed}`; + } + } + const removable = plans.filter((p) => !p.blocked).map((p) => p.id); + const removed = removable.length + ? (await db.deleteFrom("organization_record").where("id", "in", removable).returning("id").execute()).map( + (r) => r.id, + ) + : []; + await audit(d, principal, "organize_records_delete", { + removed, + deleteSource: body.deleteSource, + deleteTarget: body.deleteTarget, + trashRecordId, + kept: plans.filter((p) => p.blocked).map((p) => ({ id: p.id, blocked: p.blocked })), + }); + return summary(removed, trashRecordId); +} diff --git a/apps/server/src/modules/file-manager/routes.ts b/apps/server/src/modules/file-manager/routes.ts index de89ccfe..5ae1a891 100644 --- a/apps/server/src/modules/file-manager/routes.ts +++ b/apps/server/src/modules/file-manager/routes.ts @@ -16,6 +16,7 @@ import { mkdirIn, renameEntry } from "./mutate.ts"; import { organizeFiles } from "./organize.ts"; import { previewFile } from "./preview.ts"; import { probeFiles, scrape, scrapeBatch } from "./probe.ts"; +import { deleteRecords } from "./record-delete.ts"; import { findRecords } from "./records.ts"; import { suggestName } from "./suggest-name.ts"; import { transfer } from "./transfer.ts"; @@ -123,4 +124,6 @@ export function registerFileRoutes(app: ModuleApp, d: FmDeps) { 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)); + // HIS-1:删除整理记录(可选源 / 媒体库文件进回收站);路径在 /organization-records 下,实现复用文件管理的删除 + mount(app, Api.organization.remove, async (c, { body }) => deleteRecords(d, c.get("principal"), body)); } diff --git a/apps/server/test/file-manager/record-delete.test.ts b/apps/server/test/file-manager/record-delete.test.ts new file mode 100644 index 00000000..7f619e64 --- /dev/null +++ b/apps/server/test/file-manager/record-delete.test.ts @@ -0,0 +1,239 @@ +import { access, mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + createFmHarness, + EXEC_KEY, + type FmHarness, + pgAvailable, + seedAsset, + seedDownload, + seedEntity, +} from "./harness.ts"; + +// HIS-1 删除整理记录(对照 MP deletesrc / deletedest):只删记录;连同源文件 / 媒体库文件移入回收站(已入库文件同时退役); +// 做种未满足的源文件整条拦下;还在整理的记录不删;API Key 不能顺带删文件;删掉没入库的下载任务的记录会被自动整理再处理。 + +const skip = !(await pgAvailable()); +const exists = (p: string) => + access(p).then( + () => true, + () => false, + ); + +describe.skipIf(skip)("删除整理记录", () => { + let h: FmHarness; + let entity: string; + beforeAll(async () => { + h = await createFmHarness("recdel"); + entity = await seedEntity(h.db, { kind: "movie", title: "Heat", year: 1995 }); + }); + afterAll(async () => h?.close()); + + /** 一条复制整理记录:源在下载根,目标在库根(相对路径) */ + async function seedRecord( + name: string, + o: { status?: "succeeded" | "failed" | "running"; downloadTaskId?: string | null; action?: "copy" | "trash" } = {}, + ) { + const src = join(h.dl, `${name}.mkv`); + const rel = `${name} (1995)/${name}.mkv`; + await writeFile(src, "video"); + if (o.status !== "failed") { + await mkdir(join(h.lib, `${name} (1995)`), { recursive: true }); + await writeFile(join(h.lib, rel), "video"); + } + const rec = await h.db + .insertInto("organization_record") + .values({ + trigger: "auto", + actor_type: "system", + media_entity_id: entity, + download_task_id: o.downloadTaskId ?? null, + source_path: src, + target_library_root_id: h.libraryRootId, + target_path: `${name} (1995)`, + action: o.action ?? "copy", + status: o.status ?? "succeeded", + file_total: 1, + file_succeeded: o.status === "failed" ? 0 : 1, + file_failed: o.status === "failed" ? 1 : 0, + }) + .returning("id") + .executeTakeFirstOrThrow(); + await h.db + .insertInto("organization_file_result") + .values({ + organization_record_id: rec.id, + mode: o.action ?? "copy", + source_path: src, + source_size_bytes: 5, + target_library_root_id: h.libraryRootId, + target_path: o.status === "failed" ? null : rel, + status: o.status === "failed" ? "failed" : "succeeded", + }) + .execute(); + return { id: rec.id, src, dst: join(h.lib, rel), rel }; + } + + const del = (body: Record, k?: string) => h.api("POST", "/organization-records/delete", body, k); + const recordExists = async (id: string) => + !!(await h.db.selectFrom("organization_record").select("id").where("id", "=", id).executeTakeFirst()); + + it("只删记录:文件都不动", async () => { + const r = await seedRecord("OnlyRecord"); + const res = await del({ ids: [r.id] }); + expect(res.status).toBe(200); + expect(res.body.data).toMatchObject({ removed: [r.id], sourceFiles: 0, targetFiles: 0, trashRecordId: null }); + expect(await recordExists(r.id)).toBe(false); + expect(await exists(r.src)).toBe(true); + expect(await exists(r.dst)).toBe(true); + }); + + it("dryRun 列出会删的文件与大小,不动文件和记录", async () => { + const r = await seedRecord("DryRun"); + const res = await del({ ids: [r.id], deleteSource: true, deleteTarget: true, dryRun: true }); + expect(res.status).toBe(200); + expect(res.body.data).toMatchObject({ dryRun: true, removed: [], sourceFiles: 1, targetFiles: 1, bytes: 10 }); + expect(res.body.data.items[0]).toMatchObject({ id: r.id, blocked: null, sourceFiles: 1, targetFiles: 1 }); + expect(await recordExists(r.id)).toBe(true); + expect(await exists(r.src)).toBe(true); + }); + + it("连同源文件和媒体库文件:移入回收站,已入库文件退役,写一条「移入回收站」记录", async () => { + const r = await seedRecord("Both"); + const a = await seedAsset(h.db, h, { entityId: entity, rel: r.rel }); + const res = await del({ ids: [r.id], deleteSource: true, deleteTarget: true }); + expect(res.status).toBe(200); + expect(res.body.data).toMatchObject({ removed: [r.id], sourceFiles: 1, targetFiles: 1, assets: 1 }); + expect(await exists(r.src)).toBe(false); + expect(await exists(r.dst)).toBe(false); + const loc = await h.db + .selectFrom("asset_location") + .select("status") + .where("id", "=", a.locationId) + .executeTakeFirstOrThrow(); + expect(loc.status).toBe("removed"); + const trash = await h.db + .selectFrom("organization_record") + .select(["action", "status"]) + .where("id", "=", res.body.data.trashRecordId) + .executeTakeFirstOrThrow(); + expect(trash).toMatchObject({ action: "trash", status: "succeeded" }); + expect(await h.db.selectFrom("trash_item").select("id").execute()).toHaveLength(2); + }); + + it("只删媒体库文件:源文件留着", async () => { + const r = await seedRecord("TargetOnly"); + const res = await del({ ids: [r.id], deleteTarget: true }); + expect(res.body.data).toMatchObject({ removed: [r.id], sourceFiles: 0, targetFiles: 1 }); + expect(await exists(r.src)).toBe(true); + expect(await exists(r.dst)).toBe(false); + }); + + it("批量:做种未满足的源文件整条拦下,其余照删;还在整理的不删", async () => { + const t = await seedDownload(h.db, h, { name: "Seeding.mkv", status: "completed" }); + const blocked = await seedRecord("Seeding", { downloadTaskId: t }); + const ok = await seedRecord("Batch1"); + const running = await seedRecord("Running", { status: "running" }); + const res = await del({ ids: [blocked.id, ok.id, running.id], deleteSource: true, deleteTarget: true }); + expect(res.status).toBe(200); + expect(res.body.data.removed).toEqual([ok.id]); + const byId = Object.fromEntries(res.body.data.items.map((i: { id: string }) => [i.id, i])); + expect(byId[blocked.id].blocked).toBe("download_active"); + expect(byId[blocked.id].message).toContain("做种"); + expect(byId[running.id].blocked).toBe("running"); + // 被拦的记录:文件和记录都在(不留半截) + expect(await exists(blocked.src)).toBe(true); + expect(await exists(blocked.dst)).toBe(true); + expect(await recordExists(blocked.id)).toBe(true); + expect(await recordExists(running.id)).toBe(true); + expect(await exists(ok.src)).toBe(false); + }); + + it("失败记录的下载任务没有别的记录了:提示会被自动整理再处理", async () => { + const t = await seedDownload(h.db, h, { name: "Retry.mkv", status: "completed" }); + const r = await seedRecord("Retry", { status: "failed", downloadTaskId: t }); + const dry = await del({ ids: [r.id], dryRun: true }); + expect(dry.body.data.autoRetry).toBe(1); + // 已入库(imported)的不会重来 + await h.db.updateTable("download_task").set({ status: "imported" }).where("id", "=", t).execute(); + const dry2 = await del({ ids: [r.id], dryRun: true }); + expect(dry2.body.data.autoRetry).toBe(0); + }); + + it("文件管理的删除记录只删记录本身", async () => { + const r = await seedRecord("TrashRec", { action: "trash" }); + const res = await del({ ids: [r.id], deleteSource: true, deleteTarget: true }); + expect(res.body.data).toMatchObject({ removed: [r.id], sourceFiles: 0, targetFiles: 0 }); + expect(await exists(r.src)).toBe(true); + }); + + it("批量重新整理:几条记录的源文件换算成登记目录路径,一起走整理(每个作品一条新记录)", async () => { + const other = await seedEntity(h.db, { kind: "movie", title: "Ronin", year: 1998 }); + const mk = async (file: string, entityId: string) => { + const src = join(h.dl, file); + await writeFile(src, "x".repeat(100)); + const rec = await h.db + .insertInto("organization_record") + .values({ + trigger: "auto", + actor_type: "system", + media_entity_id: null, + source_path: src, + action: "copy", + status: "failed", + reason: "identity_unknown", + }) + .returning("id") + .executeTakeFirstOrThrow(); + await h.db + .insertInto("organization_file_result") + .values({ organization_record_id: rec.id, mode: "copy", source_path: src, status: "failed", role: "main" }) + .execute(); + return { id: rec.id, entityId }; + }; + const recs = [await mk("Heat.1995.1080p.mkv", entity), await mk("Ronin.1998.1080p.mkv", other)]; + const items: { root: string; path: string; mediaEntityId: string }[] = []; + for (const r of recs) { + const s = await h.api("GET", `/files/record-sources?recordId=${r.id}`); + expect(s.status).toBe(200); + expect(s.body.data.unresolved).toEqual([]); + for (const it of s.body.data.items) items.push({ ...it, mediaEntityId: r.entityId }); + } + expect(items.map((i) => i.path).sort()).toEqual(["Heat.1995.1080p.mkv", "Ronin.1998.1080p.mkv"]); + const dry = await h.api("POST", "/files/organize", { + idempotencyKey: crypto.randomUUID(), + items, + action: "copy", + dryRun: true, + }); + expect(dry.status).toBe(200); + const planIds = dry.body.data.groups.map((g: { planId: string }) => g.planId); + expect(planIds).toHaveLength(2); + const exec = await h.api("POST", "/files/organize", { + idempotencyKey: crypto.randomUUID(), + items, + action: "copy", + dryRun: false, + planIds, + }); + expect(exec.status).toBe(200); + expect(exec.body.data.recordIds).toHaveLength(2); + const made = await h.db + .selectFrom("organization_record") + .select(["media_entity_id", "trigger"]) + .where("id", "in", exec.body.data.recordIds) + .execute(); + expect(made.map((m) => m.media_entity_id).sort()).toEqual([entity, other].sort()); + }); + + it("API Key:只删记录可以,顺带删文件 403", async () => { + const r = await seedRecord("ApiKey"); + const no = await del({ ids: [r.id], deleteSource: true }, EXEC_KEY); + expect(no.status).toBe(403); + expect(await exists(r.src)).toBe(true); + const ok = await del({ ids: [r.id] }, EXEC_KEY); + expect(ok.status).toBe(200); + expect(ok.body.data.removed).toEqual([r.id]); + }); +}); diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index f8ac6664..9929c018 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -1431,6 +1431,22 @@ export const Api = { query: organization.OrganizationChangesQuery, response: organization.OrganizationChangesResponse, }), + stats: ep({ + method: "get", + path: "/organization-records/stats", + scope: "organize.read", + summary: "整理记录按状态计数(筛选同列表)", + query: organization.OrganizationStatsQuery, + response: organization.OrganizationStats, + }), + remove: ep({ + method: "post", + path: "/organization-records/delete", + scope: "organize.execute", + summary: "删除整理记录,可选把源文件 / 媒体库文件移入回收站(dryRun 只核对)", + body: organization.OrganizationDeleteRequest, + response: organization.OrganizationDeleteResult, + }), create: ep({ method: "post", path: "/organization-records", diff --git a/packages/contracts/src/organization.ts b/packages/contracts/src/organization.ts index b46ea126..462143b4 100644 --- a/packages/contracts/src/organization.ts +++ b/packages/contracts/src/organization.ts @@ -228,6 +228,8 @@ export const PublicOrganizationRecord = z.object({ episodeNumber: z.number().int().nullable(), /** 已确认的外部身份,如 tmdb:movie:693134 */ externalIds: z.array(z.object({ provider: z.string(), entityType: z.string(), externalId: z.string() })), + /** HIS-1:作品海报(TMDB poster_path,如 /abc.jpg);没有为 null */ + posterPath: z.string().nullable().optional(), }) .nullable(), trigger: OrganizationTrigger, @@ -240,6 +242,13 @@ export const PublicOrganizationRecord = z.object({ target: PublicPathRef, reason: PublicReason.nullable(), counts: z.object({ total: NonNegInt, succeeded: NonNegInt, failed: NonNegInt, skipped: NonNegInt }), + /** HIS-1:逐文件源大小之和(字节);没有文件明细为 null */ + sizeBytes: NonNegInt.nullable().optional(), + /** HIS-1:来源下载任务摘要(下载器名称、种子 hash);手动整理本地路径时为 null */ + download: z + .object({ taskId: Id, name: z.string(), hash: z.string(), downloaderName: z.string().nullable() }) + .nullable() + .optional(), /** includeFiles=false 时为 null */ files: z.array(PublicOrganizationFile).nullable(), createdAt: Timestamp, @@ -280,10 +289,87 @@ export const OrganizationListQuery = z.object({ trigger: OrganizationTrigger.optional(), mediaEntityId: Id.optional(), downloadTaskId: Id.optional(), + /** 作品标题、源/目标路径、种子 hash;支持 * 与 ? 通配 */ q: z.string().max(200).optional(), + /** HIS-1:作品类型;none = 没关联作品(未识别) */ + kind: z.enum(["movie", "series", "none"]).optional(), + /** HIS-1:只看这之后创建的记录 */ + since: Timestamp.optional(), + /** HIS-1:只看整理类记录(复制/移动/硬链接),不含文件管理的改名、删除、还原 */ + organizeOnly: z.union([z.boolean(), z.enum(["true", "false"]).transform((v) => v === "true")]).optional(), }); export type OrganizationListQuery = z.infer; +/** HIS-1:统计与列表同一套筛选(不含状态,状态按计数分开给) */ +export const OrganizationStatsQuery = OrganizationListQuery.omit({ cursor: true, limit: true, status: true }); +export type OrganizationStatsQuery = z.infer; +export const OrganizationStats = z.object({ + total: NonNegInt, + succeeded: NonNegInt, + partial: NonNegInt, + failed: NonNegInt, + /** 排队 + 执行中 */ + running: NonNegInt, + cancelled: NonNegInt, +}); +export type OrganizationStats = z.infer; + +/** + * HIS-1:删除整理记录,可选同时删源文件 / 媒体库里的文件(对照 MP deletesrc / deletedest)。 + * 文件一律移入回收站(同文件管理删除,可在回收站还原);下载器里的任务不动。 + * 删文件只允许管理员(控制台会话或 DEV key);API Key 删文件请走 /files/delete(一次性审批)。 + */ +export const OrganizationDeleteRequest = z.object({ + ids: z.array(Id).min(1).max(200), + deleteSource: z.boolean().default(false), + deleteTarget: z.boolean().default(false), + /** true 只核对会删什么,不动文件与记录 */ + dryRun: z.boolean().default(false), +}); +export type OrganizationDeleteRequest = z.infer; + +/** 单条记录不能删的原因:running 还在整理 / not_found 记录不存在 / 其余同文件管理删除的 blocked 码 */ +export const OrganizationDeleteBlock = z.enum([ + "running", + "not_found", + "download_active", + "needs_unbind", + "is_root", + "root_unavailable", + "escape", + "outside_roots", + "failed", +]); +export type OrganizationDeleteBlock = z.infer; + +export const OrganizationDeleteResult = z.object({ + dryRun: z.boolean(), + items: z.array( + z.object({ + id: Id, + /** 会移入回收站的源文件 / 媒体库文件数(已不存在的不算) */ + sourceFiles: NonNegInt, + targetFiles: NonNegInt, + bytes: NonNegInt, + blocked: OrganizationDeleteBlock.nullable(), + /** blocked 时的一句说明(路径 + 原因) */ + message: z.string().nullable(), + }), + ), + /** 执行后真正删掉的记录 */ + removed: z.array(Id), + sourceFiles: NonNegInt, + targetFiles: NonNegInt, + bytes: NonNegInt, + /** 删掉的媒体库文件里已入库的文件数(同时从媒体库移除) */ + assets: NonNegInt, + /** 删记录后会被自动整理重新处理的下载任务数(该下载任务没有别的整理记录了) */ + autoRetry: NonNegInt, + /** 文件移入回收站时写的「移入回收站」整理记录 */ + trashRecordId: Id.nullable(), +}); +export type OrganizationDeleteResult = z.infer; + /** 手动发起整理(organize.execute):返回 record/task ID,由任务页追踪 */ export const OrganizeRequest = z .object({