From 51e82f1cd9c40d082b9c916c0854e5cd9715a510 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 30 Sep 2026 23:57:19 +0800 Subject: [PATCH 1/3] feat(organize): record filters, stats and delete with files (HIS-1) List query gains kind/since/organizeOnly and wildcard/hash search; new GET /organization-records/stats and POST /organization-records/delete (optional source/library files to trash via files delete). Public record adds posterPath, sizeBytes and download summary. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/records-filter.test.ts | 121 ++++++++ .../routes/organization.ts | 93 +++++- .../acquisition-organize/services/organize.ts | 31 +- .../src/modules/file-manager/record-delete.ts | 278 ++++++++++++++++++ .../server/src/modules/file-manager/routes.ts | 3 + .../test/file-manager/record-delete.test.ts | 239 +++++++++++++++ packages/contracts/src/api.ts | 16 + packages/contracts/src/organization.ts | 86 ++++++ 8 files changed, 850 insertions(+), 17 deletions(-) create mode 100644 apps/server/src/modules/acquisition-organize/__tests__/records-filter.test.ts create mode 100644 apps/server/src/modules/file-manager/record-delete.ts create mode 100644 apps/server/test/file-manager/record-delete.test.ts 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({ From 03c651085f12168f774927317545019d9d2640aa Mon Sep 17 00:00:00 2001 From: root Date: Wed, 30 Sep 2026 23:57:19 +0800 Subject: [PATCH 2/3] feat(web): organize records page parity with MP history (HIS-1) Poster, source -> target, size, downloader; type/time filters; status counts; group by work; row menu; batch select, delete and reorganize; delete confirm dialog with dry-run summary; detail delete and download info. Co-Authored-By: Claude Opus 5.5 --- apps/web/app/api/files.ts | 31 +- apps/web/app/api/records.ts | 45 ++ .../organize-wizard/organize-wizard.tsx | 42 +- .../organize/delete-records-dialog.tsx | 160 +++++ .../components/organize/record-parts.test.ts | 54 ++ .../app/components/organize/record-parts.ts | 35 ++ apps/web/app/routes/console/organize.$id.tsx | 58 +- apps/web/app/routes/console/organize.tsx | 583 +++++++++++++++--- 8 files changed, 898 insertions(+), 110 deletions(-) create mode 100644 apps/web/app/components/organize/delete-records-dialog.tsx create mode 100644 apps/web/app/components/organize/record-parts.test.ts create mode 100644 apps/web/app/components/organize/record-parts.ts diff --git a/apps/web/app/api/files.ts b/apps/web/app/api/files.ts index b0b4f5b5..9614d37e 100644 --- a/apps/web/app/api/files.ts +++ b/apps/web/app/api/files.ts @@ -1,4 +1,11 @@ -import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + keepPreviousData, + useInfiniteQuery, + useMutation, + useQueries, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { cursorPage, type FileEntry, @@ -281,6 +288,28 @@ export function useRecordSources(recordId: string | null) { }); } +/** HIS-1 批量重新整理:多条记录的源路径一并换算(合并 items / unresolved) */ +export function useRecordSourcesMany(recordIds: string[]) { + return useQueries({ + queries: recordIds.map((id) => ({ + queryKey: ["files", "record-sources", id], + retry: false, + queryFn: ({ signal }: { signal: AbortSignal }) => + call(FilesRecordSourcesResult, "/files/record-sources", { params: { recordId: id }, signal }), + })), + combine: combineRecordSources, + }); +} + +// 模块级函数:引用稳定,结果不变时 useQueries 不重算(避免下游 useMemo 每次渲染都变) +function combineRecordSources(rs: { isPending: boolean; error: unknown; data?: FilesRecordSourcesResult }[]) { + return { + isPending: rs.some((r) => r.isPending), + error: rs.find((r) => r.error)?.error ?? null, + data: rs.every((r) => r.data) ? rs.map((r) => r.data!) : undefined, + }; +} + /** 作品选择器里的本地作品 */ export type LocalEntityHit = { id: string; title: string; year: number | null; kind: string; alias: string | null }; diff --git a/apps/web/app/api/records.ts b/apps/web/app/api/records.ts index 7df956d0..046368ce 100644 --- a/apps/web/app/api/records.ts +++ b/apps/web/app/api/records.ts @@ -4,6 +4,9 @@ import { cursorPage, LibraryItem, LibraryRoot, + type OrganizationDeleteRequest, + OrganizationDeleteResult, + OrganizationStats, type OrganizationStatus, type OrganizationTrigger, OrganizeAccepted, @@ -23,6 +26,10 @@ export type RecordFilters = { trigger?: OrganizationTrigger; mediaEntityId?: string; q?: string; + kind?: "movie" | "series" | "none"; + /** ISO 时间:只看之后创建的 */ + since?: string; + organizeOnly?: boolean; }; export const recordKeys = { @@ -30,8 +37,34 @@ export const recordKeys = { list: (f: RecordFilters) => ["records", "list", f] as const, one: (id: string) => ["records", "one", id] as const, entity: (id: string) => ["records", "entity", id] as const, + stats: (f: Omit) => ["records", "stats", f] as const, }; +/** HIS-1:按状态计数(筛选同列表,不含状态) */ +export function useRecordStats(f: Omit) { + return useQuery({ + queryKey: recordKeys.stats(f), + placeholderData: keepPreviousData, + queryFn: ({ signal }) => + apiParsed(OrganizationStats, "/api/v1/organization-records/stats", { params: cleanParams(f), signal }), + }); +} + +/** HIS-1:删除整理记录(dryRun 只核对会删什么) */ +export function useDeleteRecords() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: OrganizationDeleteRequest) => + apiMutate("/api/v1/organization-records/delete", { body }).then((d) => OrganizationDeleteResult.parse(d)), + onSuccess: (r) => { + if (!r.dryRun) { + void qc.invalidateQueries({ queryKey: recordKeys.all }); + if (r.trashRecordId) void qc.invalidateQueries({ queryKey: ["files"] }); + } + }, + }); +} + export function useRecords(f: RecordFilters) { return useInfiniteQuery({ queryKey: recordKeys.list(f), @@ -109,6 +142,18 @@ export const RECORD_STATUS_TONE: Record = { auto: "自动", manual: "手动", agent: "智能体" }; +/** 整理方式 / 文件操作(记录的 action) */ +export const ACTION_LABEL = { + copy: "复制", + move: "移动", + rename: "重命名", + relocate: "目录调整", + hardlink: "硬链接", + trash: "移入回收站", + delete: "永久删除", + restore: "从回收站还原", +} as const; + /** dryRun 生成的整理计划冻结为 artifact(kind=plan),GET /artifacts/{id} 读取逐文件预览 */ export function useArtifact(id: string | null | undefined) { return useQuery({ diff --git a/apps/web/app/components/organize-wizard/organize-wizard.tsx b/apps/web/app/components/organize-wizard/organize-wizard.tsx index fb390d35..f2714b7d 100644 --- a/apps/web/app/components/organize-wizard/organize-wizard.tsx +++ b/apps/web/app/components/organize-wizard/organize-wizard.tsx @@ -16,6 +16,7 @@ import { useIdentify, useOrganizeFiles, useRecordSources, + useRecordSourcesMany, } from "~/api/files.ts"; import { useRecord } from "~/api/records.ts"; import { useSettings } from "~/api/settings.ts"; @@ -64,7 +65,9 @@ export type OrganizeSource = /** 做种中且 H&R 未满足:只能复制 */ hnrBlocked?: boolean; } - | { kind: "record"; recordId: string }; + | { kind: "record"; recordId: string } + /** HIS-1 批量重新整理:几条记录的源文件一起进向导;preset = 这几条同属一个作品时预选 */ + | { kind: "records"; recordIds: string[]; preset?: WizardEntity | null }; const STEPS = ["文件与作品", "选项", "预览并执行"] as const; @@ -90,6 +93,7 @@ function useResolvedSource(source: OrganizeSource, roots: FileRoot[] | undefined }); const recSrc = useRecordSources(isRec ? source.recordId : null); const rec = useRecord(isRec ? source.recordId : undefined); + const many = useRecordSourcesMany(source.kind === "records" ? source.recordIds : []); return useMemo((): Resolved => { const base = { unresolved: [], preset: null, pending: false, error: null }; if (source.kind === "files") @@ -114,6 +118,28 @@ function useResolvedSource(source: OrganizeSource, roots: FileRoot[] | undefined label: `下载任务 · ${source.name}`, }; } + if (source.kind === "records") { + const label = `重新整理 ${source.recordIds.length} 条记录`; + if (many.isPending) return { ...base, items: [], hnrPaths: [], label, pending: true }; + if (many.error) return { ...base, items: [], hnrPaths: [], label, error: many.error }; + const seen = new Set(); + const items = (many.data ?? []) + .flatMap((r) => r.items) + .filter((it) => { + const k = `${it.root}\u0000${it.path}`; + if (seen.has(k)) return false; + seen.add(k); + return true; + }); + return { + ...base, + items, + hnrPaths: [], + unresolved: [...new Set((many.data ?? []).flatMap((r) => r.unresolved))], + preset: source.preset ?? null, + label, + }; + } if (recSrc.isPending) return { ...base, items: [], hnrPaths: [], label: "整理记录", pending: true }; if (recSrc.error) return { ...base, items: [], hnrPaths: [], label: "整理记录", error: recSrc.error }; const m = rec.data?.media; @@ -128,7 +154,19 @@ function useResolvedSource(source: OrganizeSource, roots: FileRoot[] | undefined : null, label: `重新整理 · ${m ? entityLabel(m) : "整理记录"}`, }; - }, [source, roots, maps.isLoading, maps.data, recSrc.isPending, recSrc.error, recSrc.data, rec.data]); + }, [ + source, + roots, + maps.isLoading, + maps.data, + recSrc.isPending, + recSrc.error, + recSrc.data, + rec.data, + many.isPending, + many.error, + many.data, + ]); } /** diff --git a/apps/web/app/components/organize/delete-records-dialog.tsx b/apps/web/app/components/organize/delete-records-dialog.tsx new file mode 100644 index 00000000..0bfb6f6a --- /dev/null +++ b/apps/web/app/components/organize/delete-records-dialog.tsx @@ -0,0 +1,160 @@ +import type { OrganizationDeleteResult, PublicOrganizationRecord } from "@umt/contracts"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { useDeleteRecords } from "~/api/records.ts"; +import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { formatBytes } from "~/lib/utils.ts"; +import { hasFiles, recordTitle } from "./record-parts.ts"; + +// HIS-1 删除整理记录确认框(对照 MP 的四个删除选项:只删记录 / 连同源文件 / 连同媒体库文件 / 全部)。 +// 勾选变化时先 dryRun 核对,把会删什么、哪些删不了、会不会被自动整理再处理写清楚。 + +type Rec = Pick; + +export function DeleteRecordsDialog({ + records, + open, + onOpenChange, + onDone, +}: { + records: Rec[]; + open: boolean; + onOpenChange: (open: boolean) => void; + /** 删除完成(传回真正删掉的 id) */ + onDone?: (removed: string[]) => void; +}) { + const [src, setSrc] = useState(false); + const [dst, setDst] = useState(false); + const dry = useDeleteRecords(); + const exec = useDeleteRecords(); + const ids = records.map((r) => r.id); + const idKey = ids.join(","); + const anyFiles = records.some(hasFiles); + const { mutate: check, reset } = dry; + + useEffect(() => { + if (!open) { + setSrc(false); + setDst(false); + reset(); + return; + } + if (!idKey) return; + check({ ids: idKey.split(","), deleteSource: src, deleteTarget: dst, dryRun: true }); + }, [open, idKey, src, dst, check, reset]); + + const plan = dry.data; + const byId = new Map(records.map((r) => [r.id, r])); + const blocked = plan?.items.filter((i) => i.blocked) ?? []; + const deletable = plan ? plan.items.length - blocked.length : 0; + const withFiles = src || dst; + const single = records.length === 1 ? records[0] : undefined; + + return ( + + exec + .mutateAsync({ ids, deleteSource: src, deleteTarget: dst, dryRun: false }) + .then((r) => { + report(r); + onDone?.(r.removed); + }) + .catch((e) => { + toast.error(errorMessage(e, "删除失败")); + throw e; + }) + } + > +
+ {anyFiles ? ( +
+ + +
+ ) : null} + + {blocked.length ? ( +
+

{blocked.length === records.length ? "这些记录都不能删:" : `${blocked.length} 条不能删,其余照删:`}

+
    + {blocked.slice(0, 5).map((b) => { + const r = byId.get(b.id); + return ( +
  • + {r ? `${recordTitle(r)}:` : ""} + {b.message} +
  • + ); + })} + {blocked.length > 5 ?
  • 还有 {blocked.length - 5} 条
  • : null} +
+
+ ) : null} +
+
+ ); +} + +function Summary({ + plan, + pending, + error, + src, + withFiles, +}: { + plan: OrganizationDeleteResult | undefined; + pending: boolean; + error: unknown; + src: boolean; + withFiles: boolean; +}) { + if (error) return

{errorMessage(error, "核对失败")}

; + if (!plan) return

{pending ? "正在核对要删的文件…" : null}

; + const files = [ + plan.sourceFiles ? `${plan.sourceFiles} 个源文件` : null, + plan.targetFiles ? `${plan.targetFiles} 个媒体库文件` : null, + ].filter(Boolean); + return ( +
+ {files.length ? ( +

+ {files.join("、")}({formatBytes(plan.bytes, 1)})会移入回收站,可以在回收站还原。 + {plan.assets ? `其中 ${plan.assets} 个是已入库文件,会从媒体库移除。` : ""} +

+ ) : ( +

{withFiles ? "没有找到还在的文件,只删记录。" : "只删记录,文件不动。"}

+ )} + {src && plan.sourceFiles ?

下载器里的任务不会删除。

: null} + {plan.autoRetry ? ( +

{plan.autoRetry} 个下载任务没有别的整理记录了,几分钟后会重新自动整理。

+ ) : null} +
+ ); +} + +function report(r: OrganizationDeleteResult) { + const kept = r.items.filter((i) => i.blocked); + const files = r.sourceFiles + r.targetFiles; + const head = `已删除 ${r.removed.length} 条记录${files ? `,${files} 个文件移入回收站` : ""}`; + if (kept.length) toast.warning(head, { description: `${kept.length} 条没有删除:${kept[0]?.message ?? ""}` }); + else toast.success(head); +} diff --git a/apps/web/app/components/organize/record-parts.test.ts b/apps/web/app/components/organize/record-parts.test.ts new file mode 100644 index 00000000..d1a9ffbb --- /dev/null +++ b/apps/web/app/components/organize/record-parts.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { canReorganize, episodeTag, hasFiles, isRunning, recordTitle } from "./record-parts.ts"; + +// HIS-1 整理记录展示小件:标题 / 季集、能否重新整理、能否连文件删除 + +const media = ( + o: Partial<{ seasonNumber: number | null; episodeNumber: number | null; year: number | null }> = {}, +) => ({ + entityId: "00000000-0000-4000-8000-000000000001", + kind: "series" as const, + title: "Severance", + year: 2022, + contentUnitId: null, + seasonNumber: null, + episodeNumber: null, + externalIds: [], + ...o, +}); +const path = (p: string | null) => ({ + path: p, + view: "local" as const, + downloaderInstanceId: null, + libraryRootId: null, +}); + +describe("整理记录展示", () => { + it("季集标记", () => { + expect(episodeTag(null)).toBe(""); + expect(episodeTag(media())).toBe(""); + expect(episodeTag(media({ seasonNumber: 1 }))).toBe("S01"); + expect(episodeTag(media({ seasonNumber: 1, episodeNumber: 2 }))).toBe("S01E02"); + }); + + it("标题:作品 (年) 季集;没关联作品时取源文件名", () => { + expect( + recordTitle({ media: media({ seasonNumber: 2, episodeNumber: 10 }), source: path(null), target: path(null) }), + ).toBe("Severance (2022) S02E10"); + expect(recordTitle({ media: media({ year: null }), source: path(null), target: path(null) })).toBe("Severance"); + expect(recordTitle({ media: null, source: path("/dl/Some.Movie.2020/"), target: path(null) })).toBe( + "Some.Movie.2020", + ); + expect(recordTitle({ media: null, source: path(null), target: path(null) })).toBe("未识别"); + }); + + it("重新整理只对已结束的复制 / 移动 / 硬链接", () => { + expect(canReorganize({ action: "copy", status: "failed" })).toBe(true); + expect(canReorganize({ action: "hardlink", status: "succeeded" })).toBe(true); + expect(canReorganize({ action: "move", status: "running" })).toBe(false); + expect(canReorganize({ action: "trash", status: "succeeded" })).toBe(false); + expect(isRunning({ status: "queued" })).toBe(true); + expect(hasFiles({ action: "rename" })).toBe(false); + expect(hasFiles({ action: "move" })).toBe(true); + }); +}); diff --git a/apps/web/app/components/organize/record-parts.ts b/apps/web/app/components/organize/record-parts.ts new file mode 100644 index 00000000..7892d66e --- /dev/null +++ b/apps/web/app/components/organize/record-parts.ts @@ -0,0 +1,35 @@ +import type { PublicOrganizationRecord } from "@umt/contracts"; + +// 整理记录列表 / 详情 / 删除确认共用的展示小件(HIS-1)。 + +type Rec = Pick; + +/** S01E02 / S01;不是剧集返回空串 */ +export function episodeTag(m: PublicOrganizationRecord["media"]): string { + if (!m || m.seasonNumber == null) return ""; + const s = `S${String(m.seasonNumber).padStart(2, "0")}`; + return m.episodeNumber != null ? `${s}E${String(m.episodeNumber).padStart(2, "0")}` : s; +} + +/** 列表标题:作品 (年) S01E02;没关联作品时用源文件名 */ +export function recordTitle(r: Rec): string { + if (r.media) { + const ep = episodeTag(r.media); + return `${r.media.title}${r.media.year ? ` (${r.media.year})` : ""}${ep ? ` ${ep}` : ""}`; + } + const p = r.source.path ?? r.target.path ?? ""; + return p.split("/").filter(Boolean).pop() ?? "未识别"; +} + +/** 能重新整理的记录:复制 / 移动 / 硬链接,且已结束 */ +export const canReorganize = (r: Pick) => + (r.action === "copy" || r.action === "move" || r.action === "hardlink") && + r.status !== "queued" && + r.status !== "running"; + +/** 有源文件 / 媒体库文件可删的记录(改名、删除、还原类只删记录本身) */ +export const hasFiles = (r: Pick) => + r.action === "copy" || r.action === "move" || r.action === "hardlink"; + +export const isRunning = (r: Pick) => + r.status === "queued" || r.status === "running"; diff --git a/apps/web/app/routes/console/organize.$id.tsx b/apps/web/app/routes/console/organize.$id.tsx index 59aafd47..f4683d95 100644 --- a/apps/web/app/routes/console/organize.$id.tsx +++ b/apps/web/app/routes/console/organize.$id.tsx @@ -1,30 +1,23 @@ import type { PublicOrganizationFile, PublicPathRef } from "@umt/contracts"; -import { ArrowLeft, ArrowRight, Bot, ChevronRight, Wand2 } from "lucide-react"; +import { ArrowLeft, ArrowRight, Bot, ChevronRight, Trash2, Wand2 } from "lucide-react"; import { useState } from "react"; -import { Link, useParams } from "react-router"; -import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE, TRIGGER_LABEL, useRecord } from "~/api/records.ts"; +import { Link, useNavigate, useParams } from "react-router"; +import { ACTION_LABEL, RECORD_STATUS_LABEL, RECORD_STATUS_TONE, TRIGGER_LABEL, useRecord } from "~/api/records.ts"; import { openAgentDrawer } from "~/components/agent/agent-store.ts"; import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { KvList } from "~/components/console/kv-list.tsx"; import { PageHeader } from "~/components/console/page-header.tsx"; import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; import { TechInfo } from "~/components/console/tech-info.tsx"; +import { CoverPlaceholder } from "~/components/library/parts.tsx"; +import { DeleteRecordsDialog } from "~/components/organize/delete-records-dialog.tsx"; +import { canReorganize, isRunning } from "~/components/organize/record-parts.ts"; import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx"; import { Button } from "~/components/ui/button.tsx"; import { actorTypeLabel } from "~/lib/labels.ts"; import { reasonCode, reasonLine } from "~/lib/reasons.ts"; import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts"; -const ACTION_LABEL = { - copy: "复制", - move: "移动", - rename: "重命名", - relocate: "目录调整", - hardlink: "硬链接", - trash: "移入回收站", - delete: "永久删除", - restore: "从回收站还原", -} as const; const FILE_STATUS: Record = { queued: { label: "排队", tone: "neutral" }, running: { label: "执行中", tone: "neutral" }, @@ -124,6 +117,8 @@ export default function OrganizeRecordPage() { const rec = useRecord(id); const r = rec.data; const [reorganize, setReorganize] = useState(false); + const [removing, setRemoving] = useState(false); + const navigate = useNavigate(); return ( <> @@ -140,7 +135,7 @@ export default function OrganizeRecordPage() { 全部记录 - {r && (r.action === "copy" || r.action === "move") && r.status !== "queued" && r.status !== "running" ? ( + {r && canReorganize(r) ? ( ) : null} + {r && !isRunning(r) ? ( + + ) : null} } /> @@ -176,10 +177,20 @@ export default function OrganizeRecordPage() {
+ {r.media ? ( + + ) : null} {RECORD_STATUS_LABEL[r.status]} {[ `${ACTION_LABEL[r.action]} · ${TRIGGER_LABEL[r.trigger]}`, + r.sizeBytes ? formatBytes(r.sizeBytes, 1) : null, r.counts.total ? `${r.counts.succeeded}/${r.counts.total} 个文件成功` : null, r.counts.failed ? `${r.counts.failed} 个失败` : null, r.counts.skipped ? `${r.counts.skipped} 个跳过` : null, @@ -210,6 +221,14 @@ export default function OrganizeRecordPage() { { label: "开始", value: formatDateTime(r.startedAt) }, { label: "完成", value: formatDateTime(r.finishedAt) }, { label: "操作者", value: actorTypeLabel(r.actor.type) }, + ...(r.download + ? [ + { + label: "下载任务", + value: `${r.download.name}${r.download.downloaderName ? ` · ${r.download.downloaderName}` : ""}`, + }, + ] + : []), ]} /> {r.media ? ( @@ -253,6 +273,16 @@ export default function OrganizeRecordPage() {
)} + {r ? ( + { + if (removed.includes(r.id)) navigate("/console/organize"); + }} + /> + ) : null} {reorganize && r ? ( setReorganize(false)} /> ) : null} diff --git a/apps/web/app/routes/console/organize.tsx b/apps/web/app/routes/console/organize.tsx index 5417395b..ee4ed43d 100644 --- a/apps/web/app/routes/console/organize.tsx +++ b/apps/web/app/routes/console/organize.tsx @@ -1,95 +1,259 @@ import { useQueryClient } from "@tanstack/react-query"; import type { OrganizationStatus, OrganizationTrigger, PublicOrganizationRecord } from "@umt/contracts"; import { EventType } from "@umt/contracts"; -import { FileStack, FolderInput, X } from "lucide-react"; -import { useEffect, useState } from "react"; +import { + Bot, + Eye, + FileStack, + FolderInput, + ListTree, + MoreHorizontal, + SquareCheck, + Trash2, + Wand2, + X, +} from "lucide-react"; +import { type ReactNode, useEffect, useMemo, useState } from "react"; import { useNavigate, useSearchParams } from "react-router"; import { useFileRecords } from "~/api/files.ts"; -import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE, TRIGGER_LABEL, useRecords } from "~/api/records.ts"; +import { + ACTION_LABEL, + RECORD_STATUS_LABEL, + RECORD_STATUS_TONE, + type RecordFilters, + TRIGGER_LABEL, + useRecordStats, + useRecords, +} from "~/api/records.ts"; +import { openAgentDrawer } from "~/components/agent/agent-store.ts"; import { type DataColumn, DataTable } from "~/components/console/data-table.tsx"; import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx"; import { PageHeader } from "~/components/console/page-header.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { useQueryState } from "~/components/console/use-query-state.ts"; +import { Checkbox } from "~/components/files/file-list.tsx"; +import { CoverPlaceholder, relTime } from "~/components/library/parts.tsx"; +import { DeleteRecordsDialog } from "~/components/organize/delete-records-dialog.tsx"; +import { canReorganize, isRunning, recordTitle } from "~/components/organize/record-parts.ts"; +import { type OrganizeSource, OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx"; import { Button } from "~/components/ui/button.tsx"; -import { relTime } from "~/components/workbench/release-fields.ts"; +import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx"; import { reasonLine } from "~/lib/reasons.ts"; import { useSse } from "~/lib/sse.ts"; +import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts"; -const ACTION_LABEL = { - copy: "复制", - move: "移动", - rename: "重命名", - relocate: "目录调整", - hardlink: "硬链接", - trash: "移入回收站", - delete: "永久删除", - restore: "从回收站还原", -} as const; +// 整理记录(HIS-1 对照 MP 历史记录页):海报 + 作品 / 季集、源 → 目标路径、整理方式与大小、状态与原因、来源(下载器)、时间; +// 按状态 / 类型 / 来源 / 时间 / 关键字(支持 * ? 通配、种子 hash)筛选;按状态计数;按作品分组; +// 单条与批量的重新整理(走整理向导)和删除(可选源文件 / 媒体库文件进回收站)。 -function Counts({ r }: { r: PublicOrganizationRecord }) { - const c = r.counts; +type Rec = PublicOrganizationRecord; + +const KIND_LABEL = { movie: "电影", series: "剧集", none: "未识别" } as const; +const TIME_LABEL = { today: "今天", "7d": "最近 7 天", "30d": "最近 30 天" } as const; + +function sinceOf(t: string): string | undefined { + if (t === "today") { + const d = new Date(); + d.setHours(0, 0, 0, 0); + return d.toISOString(); + } + const days = t === "7d" ? 7 : t === "30d" ? 30 : 0; + return days ? new Date(Date.now() - days * 86_400_000).toISOString() : undefined; +} + +const when = (r: Rec) => r.finishedAt ?? r.startedAt ?? r.createdAt; + +/** 状态:成功不加徽章,其余徽章 + 一句原因 */ +function StatusCell({ r }: { r: Rec }) { + if (r.status === "succeeded" && !r.reason) return 成功; + const line = reasonLine(r.reason); return ( - - - {c.succeeded}/{c.total} - - {c.failed ? · 失败 {c.failed} : null} - {c.skipped ? · 跳过 {c.skipped} : null} - +
+ {RECORD_STATUS_LABEL[r.status]} + {line ? ( + + {line} + + ) : null} +
); } -const COLUMNS: DataColumn[] = [ - { - key: "media", - header: "作品 / 目标", - cell: (r) => ( -
-

- {r.media ? `${r.media.title}${r.media.year ? ` (${r.media.year})` : ""}` : "未关联作品"} - {r.media?.seasonNumber != null - ? ` S${String(r.media.seasonNumber).padStart(2, "0")}${r.media.episodeNumber != null ? `E${String(r.media.episodeNumber).padStart(2, "0")}` : ""}` - : ""} -

-

- {r.target.path ?? r.source.path ?? "—"} -

-
- ), - }, - { - key: "status", - header: "状态", - cell: (r) => ( -
- {RECORD_STATUS_LABEL[r.status]} - {r.reason ? ( - {reasonLine(r.reason)} +/** 长路径只留最后两段(文件名完整),前面用 … 代替;完整路径放 title */ +function shortPath(p: string | null): string | null { + if (!p) return p; + const parts = p.split("/").filter(Boolean); + return parts.length > 2 ? `…/${parts.slice(-2).join("/")}` : p; +} + +function PathLines({ r }: { r: Rec }) { + return ( +
+

+ {shortPath(r.source.path) ?? "—"} +

+

+ → {shortPath(r.target.path) ?? (r.status === "failed" ? "未生成目标路径" : "—")} +

+
+ ); +} + +function MenuItem({ + icon, + children, + onClick, + danger = false, +}: { + icon: ReactNode; + children: ReactNode; + onClick: () => void; + danger?: boolean; +}) { + return ( + + ); +} + +function RowMenu({ + r, + onOpen, + onReorganize, + onDelete, + onOnlyEntity, +}: { + r: Rec; + onOpen: () => void; + onReorganize: () => void; + onDelete: () => void; + onOnlyEntity: () => void; +}) { + const [open, setOpen] = useState(false); + const run = (fn: () => void) => () => { + setOpen(false); + fn(); + }; + const failed = r.status === "failed" || r.status === "partial"; + return ( + + + + + e.stopPropagation()}> + } onClick={run(onOpen)}> + 查看详情 + + {canReorganize(r) ? ( + } onClick={run(onReorganize)}> + 重新整理 + ) : null} -
- ), - }, - { key: "action", header: "动作", hideBelow: "sm", cell: (r) => ACTION_LABEL[r.action] }, - { key: "files", header: "文件", hideBelow: "md", cell: (r) => }, - { key: "trigger", header: "来源", hideBelow: "lg", cell: (r) => TRIGGER_LABEL[r.trigger] }, - { - key: "at", - header: "时间", - hideBelow: "sm", - cell: (r) => ( - - {relTime(r.finishedAt ?? r.startedAt ?? r.createdAt)} - - ), - }, -]; + {failed ? ( + } + onClick={run(() => + openAgentDrawer({ + label: `整理记录:${recordTitle(r)}`, + recordId: r.id, + entityId: r.media?.entityId, + goal: "排查这次整理为什么没有全部成功,给出恢复办法", + }), + )} + > + 让智能体排查 + + ) : null} + {r.media ? ( + } onClick={run(onOnlyEntity)}> + 只看这个作品 + + ) : null} + {!isRunning(r) ? ( + } onClick={run(onDelete)} danger> + 删除… + + ) : null} + + + ); +} + +/** 按状态计数:只列非零项,点一下按该状态筛选 */ +function StatsLine({ + filters, + status, + onStatus, +}: { + filters: Omit; + status: string; + onStatus: (s: string) => void; +}) { + const stats = useRecordStats(filters); + const s = stats.data; + if (!s || s.total === 0) return null; + const parts: { key: OrganizationStatus | "running"; label: string; n: number; tone?: string }[] = [ + { key: "succeeded", label: "成功", n: s.succeeded }, + { key: "failed", label: "失败", n: s.failed, tone: "text-destructive" }, + { key: "partial", label: "部分成功", n: s.partial, tone: "text-warning" }, + { key: "running", label: "进行中", n: s.running }, + { key: "cancelled", label: "已取消", n: s.cancelled }, + ]; + return ( +
+ + {parts + .filter((p) => p.n > 0) + .map((p) => ( + + ))} +
+ ); +} export default function OrganizePage() { const [params, setParams] = useSearchParams(); - const [status, setStatus] = useState(""); - const [trigger, setTrigger] = useState(""); - const [qInput, setQInput] = useState(""); - const [q, setQ] = useState(""); + const f = useQueryState(["status", "trigger", "kind", "time", "q", "group"] as const); + const { set: setFilter, values: v } = f; + const [qInput, setQInput] = useState(v.q); const entityId = params.get("mediaEntityId") ?? undefined; // 整理向导执行多组后跳来:?ids=<记录 id,…> 只看这几条(执行中每 3 秒刷新) const ids = (params.get("ids") ?? "") @@ -100,59 +264,232 @@ export default function OrganizePage() { const navigate = useNavigate(); const qc = useQueryClient(); useEffect(() => { - const t = setTimeout(() => setQ(qInput.trim()), 400); + const t = setTimeout(() => setFilter("q", qInput.trim()), 400); return () => clearTimeout(t); - }, [qInput]); - const records = useRecords({ - status: (status || undefined) as OrganizationStatus | undefined, - trigger: (trigger || undefined) as OrganizationTrigger | undefined, + }, [qInput, setFilter]); + // 时间筛选换成具体时刻只在切换时算一次,避免每次渲染查询键都变 + const since = useMemo(() => sinceOf(v.time), [v.time]); + const base: Omit = { + trigger: (v.trigger || undefined) as OrganizationTrigger | undefined, + kind: (v.kind || undefined) as RecordFilters["kind"], + since, mediaEntityId: entityId, - q: q || undefined, - }); + q: v.q || undefined, + }; + // 统计里的"进行中"= 排队 + 执行中;点它时列表按执行中筛 + const status = (v.status || undefined) as OrganizationStatus | undefined; + const records = useRecords({ ...base, status }); useSse([EventType.organizationChanged], () => { void qc.invalidateQueries({ queryKey: ["records"] }); if (ids.length) void qc.invalidateQueries({ queryKey: ["files", "records"] }); }); const pinned = ids.length > 0; const rows = pinned ? byIds.data?.items : records.data?.pages.flatMap((p) => p.items); - const filtered = !!(status || trigger || q || entityId || pinned); + const filtered = !!(v.status || v.trigger || v.kind || v.time || v.q) || !!entityId || pinned; + + const [selecting, setSelecting] = useState(false); + const [sel, setSel] = useState>(new Set()); + const [wizard, setWizard] = useState(null); + const [toDelete, setToDelete] = useState(null); + const grouped = v.group === "entity"; + + const selRows = (rows ?? []).filter((r) => sel.has(r.id)); + const allPicked = !!rows?.length && selRows.length === rows.length; + const pickAll = () => setSel(allPicked ? new Set() : new Set(rows?.map((r) => r.id))); + const toggle = (id: string) => + setSel((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + const exitSelect = () => { + setSelecting(false); + setSel(new Set()); + }; + const reorganize = (list: Rec[]) => { + const ok = list.filter(canReorganize); + const first = ok[0]; + if (!first) return; + if (ok.length === 1) { + setWizard({ kind: "record", recordId: first.id }); + return; + } + const ents = new Set(ok.map((r) => r.media?.entityId ?? "")); + const m = first.media; + setWizard({ + kind: "records", + recordIds: ok.map((r) => r.id), + preset: + ents.size === 1 && m ? { id: m.entityId, external: null, title: m.title, year: m.year, kind: m.kind } : null, + }); + }; + + const pickColumn: DataColumn = { + key: "pick", + header: 0 && !allPicked} label="全选" onToggle={pickAll} />, + className: "w-10", + cell: (r) => toggle(r.id)} />, + }; + const columns: DataColumn[] = [ + ...(selecting ? [pickColumn] : []), + { + key: "media", + header: "作品", + cell: (r) => ( +
+ +
+

+ {r.media ? recordTitle(r) : 未识别 · {recordTitle(r)}} +

+

+ {[ACTION_LABEL[r.action], r.sizeBytes ? formatBytes(r.sizeBytes, 1) : null, relTime(when(r))] + .filter(Boolean) + .join(" · ")} +

+ {r.status !== "succeeded" || r.reason ? ( +
+ +
+ ) : null} +
+
+ ), + }, + { key: "path", header: "源 → 目标", hideBelow: "md", cell: (r) => }, + { + key: "action", + header: "方式", + hideBelow: "sm", + cell: (r) => ( +
+

{ACTION_LABEL[r.action]}

+ {r.sizeBytes ? ( +

{formatBytes(r.sizeBytes, 1)}

+ ) : null} +
+ ), + }, + { key: "status", header: "状态", hideBelow: "sm", cell: (r) => }, + { + key: "trigger", + header: "来源", + hideBelow: "lg", + cell: (r) => ( +
+

{TRIGGER_LABEL[r.trigger]}

+ {r.download?.downloaderName ? ( +

+ {r.download.downloaderName} +

+ ) : null} +
+ ), + }, + { + key: "at", + header: "时间", + hideBelow: "sm", + cell: (r) => ( + + {relTime(when(r))} + + ), + }, + { + key: "menu", + header: 操作, + className: "w-10 pl-0", + cell: (r) => ( + navigate(`/console/organize/${r.id}`)} + onReorganize={() => reorganize([r])} + onDelete={() => setToDelete([r])} + onOnlyEntity={() => r.media && setParams({ mediaEntityId: r.media.entityId })} + /> + ), + }, + ]; + + const selReorg = selRows.filter(canReorganize).length; + const selDeletable = selRows.filter((r) => !isRunning(r)); return ( <> navigate("/console/files")}> - - 手动整理 - + <> + + + } /> { - setStatus(""); - setTrigger(""); setQInput(""); - setParams({}); + setParams(v.group ? { group: v.group } : {}); } : undefined } + actions={ + + } > setFilter("status", x)} options={Object.entries(RECORD_STATUS_LABEL).map(([value, label]) => ({ value, label }))} /> + setFilter("kind", x)} + options={Object.entries(KIND_LABEL).map(([value, label]) => ({ value, label }))} + /> setFilter("trigger", x)} options={Object.entries(TRIGGER_LABEL).map(([value, label]) => ({ value, label }))} /> + setFilter("time", x)} + options={Object.entries(TIME_LABEL).map(([value, label]) => ({ value, label }))} + /> {entityId ? ( ) : null} + {pinned ? null : setFilter("status", s)} />} + {selecting ? ( +
+ 已选 {selRows.length} + + + + + +
+ ) : null} r.id} loading={pinned ? byIds.isPending : records.isPending} error={pinned ? byIds.error : records.error} onRetry={() => void (pinned ? byIds.refetch() : records.refetch())} - onRowClick={(r) => navigate(`/console/organize/${r.id}`)} + onRowClick={(r) => (selecting ? toggle(r.id) : navigate(`/console/organize/${r.id}`))} + selectedKey={null} hasMore={!pinned && records.hasNextPage} onLoadMore={() => void records.fetchNextPage()} loadingMore={records.isFetchingNextPage} + groupBy={ + grouped + ? { + key: (r) => r.media?.entityId ?? "", + label: (k, g) => { + const m = g[0]?.media; + return k && m ? `${m.title}${m.year ? ` (${m.year})` : ""}` : "未识别"; + }, + } + : undefined + } empty={{ icon: FileStack, title: filtered ? "没有符合筛选的整理记录" : "还没有整理记录", - description: "下载完成后会自动整理,也可以在文件管理里选中文件整理。", + description: filtered ? undefined : "下载完成后会自动整理,也可以在文件管理里选中文件整理。", }} /> + {wizard ? ( + { + setWizard(null); + exitSelect(); + }} + /> + ) : null} + { + if (!o) setToDelete(null); + }} + onDone={(removed) => + setSel((prev) => { + const next = new Set(prev); + for (const id of removed) next.delete(id); + return next; + }) + } + /> ); } From 76433dd43fd2822cbf029bcdfd5290f095d1843e Mon Sep 17 00:00:00 2001 From: root Date: Wed, 30 Sep 2026 23:57:19 +0800 Subject: [PATCH 3/3] docs(organize): MP history comparison and changelog (HIS-1) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 ++++++ docs/runbook/organize.md | 65 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 docs/runbook/organize.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 29b5f7e4..14668737 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ ## [Unreleased] +### 新增(HIS-1 整理记录对齐 MP 历史记录页) +- 列表加海报、源 → 目标路径、大小、下载器名称;成功不再加徽章,失败 / 部分成功带一句原因。 +- 筛选新增类型(电影 / 剧集 / 未识别)与时间(今天 / 7 天 / 30 天);关键字支持 `*` `?` 通配,并能搜文件名与种子 hash;筛选写进地址栏。 +- 列表上方按状态计数(只列非零项,点一下筛选);「按作品分组」。 +- 删除记录:可选同时删除源文件、媒体库里的文件(移入回收站,可还原;已入库文件同时从媒体库移除;下载器任务不动)。确认框先核对,写清删几个文件、多大、哪些删不了、哪些下载会被重新自动整理。 +- 批量:选择 / 全选、批量删除、批量重新整理(选中记录的源文件一起进整理向导)。行菜单:查看详情、重新整理、让智能体排查、只看这个作品、删除。 +- 详情加海报、大小、下载任务与删除;种子 hash 在「技术信息」。 +- 接口:`GET /organization-records/stats`、`POST /organization-records/delete`;列表查询新增 `kind`、`since`、`organizeOnly`;公开记录新增 `media.posterPath`、`sizeBytes`、`download`(只加字段)。MP 功能对照表见 `docs/runbook/organize.md`。 + ## [0.3.6] - 2026-09-30 ### 新增(SEARCH-4 资源筛选对齐 MP) diff --git a/docs/runbook/organize.md b/docs/runbook/organize.md new file mode 100644 index 00000000..32585504 --- /dev/null +++ b/docs/runbook/organize.md @@ -0,0 +1,65 @@ +# 整理记录(`/console/organize`,HIS-1) + +整理记录页列出每次整理(复制 / 移动 / 硬链接)以及文件管理的改名、删除、还原。HIS-1 按 MoviePilot 历史记录页逐项补齐。 + +实现: +- 列表 / 统计 / 详情:`apps/server/src/modules/acquisition-organize/routes/organization.ts`(筛选 `recordFilters`,通配 `likePattern`);公开字段在 `services/organize.ts` 的 `loadRecordsPublic`。 +- 删除:`apps/server/src/modules/file-manager/record-delete.ts`(复用文件管理删除 `deleteFiles`)。 +- 契约:`packages/contracts/src/organization.ts`(`OrganizationListQuery`、`OrganizationStats*`、`OrganizationDelete*`),端点 `Api.organization.stats / remove`。 +- 前端:`apps/web/app/routes/console/organize.tsx`、`organize.$id.tsx`,`components/organize/`(删除确认框、标题 / 季集小件),批量重新整理走整理向导的 `{ kind: "records" }` 来源。 + +## 与 MP 的对照 + +MP 对照源:`MoviePilot-Frontend/src/views/reorganize/TransferHistoryView.vue`、`components/dialog/TransferHistoryDeleteDialog.vue`、`app/api/endpoints/history.py`、`app/db/models/transferhistory.py`。 + +| MP 功能 | 我们 | 说明 | +| --- | --- | --- | +| 海报 | 新增 | 作品海报(`media.posterPath`),没有时显示首字占位 | +| 标题 / 年份 / 季集 | 已有 | `作品 (年) S01E02`;未识别的显示「未识别 · 源文件名」 | +| 二级分类(华语电影等) | 不做 | 我们没有二级分类;按类型(电影 / 剧集)筛选代替 | +| 源路径 → 目标路径 | 新增 | 列表加「源 → 目标」列(只显示末两段,悬停看全路径);详情「整理前 / 整理后」已有 | +| 存储标签(本地 / SMB…) | 不做 | 只有本地存储,标签不提供信息 | +| 整理方式 复制 / 移动 / 硬链接 | 已有 | 列表「方式」列;文件管理的改名、移入回收站、还原也在这里 | +| 软链接、Rclone 复制 / 移动 | 不做 | 整理服务不支持这几种方式 | +| 大小 | 新增 | 逐文件源大小之和(`sizeBytes`) | +| 状态 成功 / 失败 + 失败原因 | 已有,改 | 加「部分成功」「执行中」;成功不加徽章;失败原因走 `reasons.ts` 一句话,原码进详情「技术信息」 | +| 时间 | 已有,改 | 相对时间,悬停看完整时间 | +| 来源 下载器 / 手动 / 自动 | 新增下载器 | 来源列加下载器名称;详情加「下载任务」一行,种子 hash 进「技术信息」 | +| 关键字搜索(标题、源、目标;`*` `?` 通配) | 已有,补 | 补通配、逐文件的源 / 目标路径、种子 hash | +| 搜「成功」「失败」切状态 | 不做 | 有状态筛选和计数按钮 | +| 状态筛选 | 已有 | | +| 类型筛选 | 新增 | 电影 / 剧集 / 未识别(`kind=movie\|series\|none`) | +| 时间筛选 | 新增 | 今天 / 最近 7 天 / 最近 30 天(`since`)。MP 没有,任务单要求 | +| 统计(总数 / 成功 / 失败) | 新增 | 列表上方一行计数,只列非零项,点一下按该状态筛选(`GET /organization-records/stats`) | +| 分页、每页条数 | 已有(不同) | 游标「加载更多」,MP 手机端同样是触底加载;不做页码与每页条数 | +| 列排序 | 不做 | 按时间倒序;分组模式下按作品聚合 | +| 分组模式(按标题) | 新增 | 「按作品分组」,组头可折叠 | +| 筛选写进地址栏 | 新增 | 状态 / 类型 / 来源 / 时间 / 关键字 / 分组都在 URL,刷新不丢 | +| 单条:重新整理 | 已有,补 | 详情已有;列表行菜单新增。走整理向导(识别 → 选项 → 预览执行) | +| 单条:智能助手整理 | 已有(不同) | 「让智能体排查」:失败 / 部分成功的记录在行菜单和详情里 | +| 单条:删除(只删记录 / 连源文件 / 连媒体库文件 / 全部) | 新增 | 确认框里两个勾选项,先核对再执行,写清删几个文件、多大、哪些删不了、会不会被自动整理再处理 | +| 查看详情 | 已有 | 详情加海报、大小、下载任务、删除按钮 | +| 批量选择 / 全选 | 新增 | 「批量」进入选择模式,表头全选 | +| 批量删除 | 新增 | 同单条删除确认框;删不了的逐条列出,其余照删 | +| 批量重新整理 | 新增 | 选中记录的源文件一起进整理向导;同属一个作品时预选作品 | +| 清空整理记录 | 不做 | 整理记录同时是文件变更的追溯记录;清理按保留期由计划页负责 | + +## 删除 + +`POST /organization-records/delete {ids[≤200], deleteSource, deleteTarget, dryRun}`,scope `organize.execute`。 + +- 文件一律移入回收站,走文件管理删除(`deleteFiles`,mode=trash,`retireAssets=true`):已入库文件同时从媒体库移除(位置 removed、资产 retired、满足的收录版本转需关注 + 待处理)。回收站里能还原。 +- 源文件 = 该记录逐文件结果的 `source_path`(只认执行进程本地视图);媒体库文件 = 成功文件的 `target_path`(相对库根时按库根拼)。已经不在的文件跳过、不拦。 +- 只有复制 / 移动 / 硬链接记录有文件可删;改名、移入回收站、还原类记录只删记录本身。 +- 一条记录的任一文件不能删(做种未满足或下载中 `download_active`、绑定目录 `needs_unbind`、不在登记目录 `outside_roots`、目录离线等),这条记录连同它的文件都不动;还在整理的记录(`running`)不删。其余照删。 +- 文件移入回收站会写一条「移入回收站」整理记录(同文件管理删除),`trashRecordId` 返回它。 +- 下载器里的任务不删(同文件管理删除原则)。 +- 删文件只允许管理员(控制台会话 / DEV key);API Key 只能删记录,删文件 403,需先走 `/files/delete` 的一次性审批。 +- `autoRetry`:下载已完成但没入库(`download_task.status=completed`、本工具管理、没从下载器删除)且删完没有别的整理记录的下载任务数。自动整理扫描(`index.ts` 的补漏)会在几分钟后重新整理它们——与 MP 删失败记录后重新整理一致;已入库(`imported`)的不会。 +- 删除不写增量读取的墓碑(`/organization-records/changes` 不会出现被删的记录);文件变化通过「移入回收站」记录可见。审计 `files.organize_records_delete`。 + +## 已知缺口 + +- 删媒体库文件只删整理记录里的文件,目录里的 NFO、图片与空目录保留(MP 在没有剩余视频时会连目录删掉)。 +- 分组只对已加载的记录生效(同 MP 分组模式)。 +- 统计的「进行中」= 排队 + 执行中;点它时列表只筛执行中。