Merge branch 'his1'
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
commit
e7e202fdc2
18 files changed
+1822
-127
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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<string>().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<Q extends SelectQueryBuilder<DB, "organization_record", any>>(
|
||||
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<DB, "organization_record">) =>
|
||||
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<DB, "organization_record">) =>
|
||||
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<string, unknown>) {
|
||||
await d.ctx.db
|
||||
.insertInto("audit_event")
|
||||
|
||||
@@ -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<string | null>("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)
|
||||
|
||||
@@ -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<OrganizationDeleteBlock, string> = {
|
||||
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<string, Ref> }> {
|
||||
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<string, Ref>();
|
||||
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<string, Ref>,
|
||||
): Promise<Map<string, FilesDeleteResult["checks"][number]>> {
|
||||
const out = new Map<string, FilesDeleteResult["checks"][number]>();
|
||||
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<OrganizationDeleteResult> {
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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<string, unknown>, 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]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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<RecordFilters, "status">) => ["records", "stats", f] as const,
|
||||
};
|
||||
|
||||
/** HIS-1:按状态计数(筛选同列表,不含状态) */
|
||||
export function useRecordStats(f: Omit<RecordFilters, "status">) {
|
||||
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<OrganizationStatus, "success" | "warning
|
||||
|
||||
export const TRIGGER_LABEL: Record<OrganizationTrigger, string> = { 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({
|
||||
|
||||
@@ -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<string>();
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<PublicOrganizationRecord, "id" | "media" | "source" | "target" | "action">;
|
||||
|
||||
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 (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={single ? `删除「${recordTitle(single)}」的整理记录?` : `删除 ${records.length} 条整理记录?`}
|
||||
confirmLabel={withFiles ? "删除记录和文件" : "删除记录"}
|
||||
destructive
|
||||
confirmDisabled={!plan || dry.isPending || deletable === 0}
|
||||
onConfirm={() =>
|
||||
exec
|
||||
.mutateAsync({ ids, deleteSource: src, deleteTarget: dst, dryRun: false })
|
||||
.then((r) => {
|
||||
report(r);
|
||||
onDone?.(r.removed);
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error(errorMessage(e, "删除失败"));
|
||||
throw e;
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 text-caption">
|
||||
{anyFiles ? (
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-start gap-2.5">
|
||||
<input type="checkbox" className="mt-1" checked={src} onChange={(e) => setSrc(e.target.checked)} />
|
||||
<span>
|
||||
同时删除源文件
|
||||
<span className="block text-meta text-muted-foreground">下载目录里整理前的文件。</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2.5">
|
||||
<input type="checkbox" className="mt-1" checked={dst} onChange={(e) => setDst(e.target.checked)} />
|
||||
<span>
|
||||
同时删除媒体库里的文件
|
||||
<span className="block text-meta text-muted-foreground">整理后放进媒体库的文件,会从媒体库移除。</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
<Summary plan={plan} pending={dry.isPending} error={dry.error} src={src} withFiles={withFiles} />
|
||||
{blocked.length ? (
|
||||
<div className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-warning">
|
||||
<p>{blocked.length === records.length ? "这些记录都不能删:" : `${blocked.length} 条不能删,其余照删:`}</p>
|
||||
<ul className="mt-1 space-y-0.5 text-meta">
|
||||
{blocked.slice(0, 5).map((b) => {
|
||||
const r = byId.get(b.id);
|
||||
return (
|
||||
<li key={b.id} className="break-all">
|
||||
{r ? `${recordTitle(r)}:` : ""}
|
||||
{b.message}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{blocked.length > 5 ? <li>还有 {blocked.length - 5} 条</li> : null}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Summary({
|
||||
plan,
|
||||
pending,
|
||||
error,
|
||||
src,
|
||||
withFiles,
|
||||
}: {
|
||||
plan: OrganizationDeleteResult | undefined;
|
||||
pending: boolean;
|
||||
error: unknown;
|
||||
src: boolean;
|
||||
withFiles: boolean;
|
||||
}) {
|
||||
if (error) return <p className="text-destructive">{errorMessage(error, "核对失败")}</p>;
|
||||
if (!plan) return <p className="text-muted-foreground">{pending ? "正在核对要删的文件…" : null}</p>;
|
||||
const files = [
|
||||
plan.sourceFiles ? `${plan.sourceFiles} 个源文件` : null,
|
||||
plan.targetFiles ? `${plan.targetFiles} 个媒体库文件` : null,
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<div className="space-y-1 text-muted-foreground">
|
||||
{files.length ? (
|
||||
<p>
|
||||
{files.join("、")}({formatBytes(plan.bytes, 1)})会移入回收站,可以在回收站还原。
|
||||
{plan.assets ? `其中 ${plan.assets} 个是已入库文件,会从媒体库移除。` : ""}
|
||||
</p>
|
||||
) : (
|
||||
<p>{withFiles ? "没有找到还在的文件,只删记录。" : "只删记录,文件不动。"}</p>
|
||||
)}
|
||||
{src && plan.sourceFiles ? <p>下载器里的任务不会删除。</p> : null}
|
||||
{plan.autoRetry ? (
|
||||
<p className="text-foreground">{plan.autoRetry} 个下载任务没有别的整理记录了,几分钟后会重新自动整理。</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { PublicOrganizationRecord } from "@umt/contracts";
|
||||
|
||||
// 整理记录列表 / 详情 / 删除确认共用的展示小件(HIS-1)。
|
||||
|
||||
type Rec = Pick<PublicOrganizationRecord, "media" | "source" | "target">;
|
||||
|
||||
/** 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<PublicOrganizationRecord, "action" | "status">) =>
|
||||
(r.action === "copy" || r.action === "move" || r.action === "hardlink") &&
|
||||
r.status !== "queued" &&
|
||||
r.status !== "running";
|
||||
|
||||
/** 有源文件 / 媒体库文件可删的记录(改名、删除、还原类只删记录本身) */
|
||||
export const hasFiles = (r: Pick<PublicOrganizationRecord, "action">) =>
|
||||
r.action === "copy" || r.action === "move" || r.action === "hardlink";
|
||||
|
||||
export const isRunning = (r: Pick<PublicOrganizationRecord, "status">) =>
|
||||
r.status === "queued" || r.status === "running";
|
||||
@@ -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<PublicOrganizationFile["status"], { label: string; tone: StatusTone }> = {
|
||||
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() {
|
||||
全部记录
|
||||
</Link>
|
||||
</Button>
|
||||
{r && (r.action === "copy" || r.action === "move") && r.status !== "queued" && r.status !== "running" ? (
|
||||
{r && canReorganize(r) ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setReorganize(true)}>
|
||||
<Wand2 />
|
||||
重新整理
|
||||
@@ -163,6 +158,12 @@ export default function OrganizeRecordPage() {
|
||||
让智能体排查
|
||||
</Button>
|
||||
) : null}
|
||||
{r && !isRunning(r) ? (
|
||||
<Button variant="outline" size="sm" className="text-destructive" onClick={() => setRemoving(true)}>
|
||||
<Trash2 />
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -176,10 +177,20 @@ export default function OrganizeRecordPage() {
|
||||
<div className="space-y-4">
|
||||
<section className="pg-slab space-y-4 rounded-xl p-4 md:p-5">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{r.media ? (
|
||||
<CoverPlaceholder
|
||||
small
|
||||
title={r.media.title}
|
||||
kind={r.media.kind}
|
||||
posterPath={r.media.posterPath}
|
||||
className="mr-1 h-12 w-8 rounded-md"
|
||||
/>
|
||||
) : null}
|
||||
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
|
||||
<span className="text-meta text-muted-foreground tabular-nums">
|
||||
{[
|
||||
`${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}` : ""}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
<TechInfo
|
||||
@@ -224,6 +243,7 @@ export default function OrganizeRecordPage() {
|
||||
{ label: "变更序号", value: r.changeSeq },
|
||||
{ label: "记录 ID", value: r.id, wide: true },
|
||||
{ label: "任务 ID", value: r.taskId, wide: true },
|
||||
{ label: "种子 hash", value: r.download?.hash, wide: true },
|
||||
]}
|
||||
/>
|
||||
{r.media ? (
|
||||
@@ -253,6 +273,16 @@ export default function OrganizeRecordPage() {
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
{r ? (
|
||||
<DeleteRecordsDialog
|
||||
records={[r]}
|
||||
open={removing}
|
||||
onOpenChange={setRemoving}
|
||||
onDone={(removed) => {
|
||||
if (removed.includes(r.id)) navigate("/console/organize");
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{reorganize && r ? (
|
||||
<OrganizeWizard source={{ kind: "record", recordId: r.id }} onClose={() => setReorganize(false)} />
|
||||
) : null}
|
||||
|
||||
@@ -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 <span className="text-muted-foreground">成功</span>;
|
||||
const line = reasonLine(r.reason);
|
||||
return (
|
||||
<span className="whitespace-nowrap tabular-nums">
|
||||
<span className="text-foreground">
|
||||
{c.succeeded}/{c.total}
|
||||
</span>
|
||||
{c.failed ? <span className="text-destructive"> · 失败 {c.failed}</span> : null}
|
||||
{c.skipped ? <span className="text-muted-foreground"> · 跳过 {c.skipped}</span> : null}
|
||||
</span>
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
|
||||
{line ? (
|
||||
<span className="line-clamp-2 max-w-52 text-meta text-muted-foreground" title={line}>
|
||||
{line}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const COLUMNS: DataColumn<PublicOrganizationRecord>[] = [
|
||||
{
|
||||
key: "media",
|
||||
header: "作品 / 目标",
|
||||
cell: (r) => (
|
||||
<div className="min-w-0 max-w-md">
|
||||
<p className="truncate text-foreground">
|
||||
{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")}` : ""}`
|
||||
: ""}
|
||||
</p>
|
||||
<p className="truncate font-mono text-meta text-muted-foreground" title={r.target.path ?? undefined}>
|
||||
{r.target.path ?? r.source.path ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "状态",
|
||||
cell: (r) => (
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
|
||||
{r.reason ? (
|
||||
<span className="line-clamp-1 max-w-48 text-meta text-muted-foreground">{reasonLine(r.reason)}</span>
|
||||
/** 长路径只留最后两段(文件名完整),前面用 … 代替;完整路径放 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 (
|
||||
<div className="min-w-0 max-w-sm font-mono text-meta">
|
||||
<p className="truncate text-muted-foreground" title={r.source.path ?? undefined}>
|
||||
{shortPath(r.source.path) ?? "—"}
|
||||
</p>
|
||||
<p className="truncate text-foreground" title={r.target.path ?? undefined}>
|
||||
→ {shortPath(r.target.path) ?? (r.status === "failed" ? "未生成目标路径" : "—")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
icon,
|
||||
children,
|
||||
onClick,
|
||||
danger = false,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
children: ReactNode;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-caption transition-colors duration-micro hover:bg-foreground/[0.06] [&_svg]:h-4 [&_svg]:w-4",
|
||||
danger ? "text-destructive" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`操作:${recordTitle(r)}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-44 p-1.5" role="menu" onClick={(e) => e.stopPropagation()}>
|
||||
<MenuItem icon={<Eye />} onClick={run(onOpen)}>
|
||||
查看详情
|
||||
</MenuItem>
|
||||
{canReorganize(r) ? (
|
||||
<MenuItem icon={<Wand2 />} onClick={run(onReorganize)}>
|
||||
重新整理
|
||||
</MenuItem>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "action", header: "动作", hideBelow: "sm", cell: (r) => ACTION_LABEL[r.action] },
|
||||
{ key: "files", header: "文件", hideBelow: "md", cell: (r) => <Counts r={r} /> },
|
||||
{ key: "trigger", header: "来源", hideBelow: "lg", cell: (r) => TRIGGER_LABEL[r.trigger] },
|
||||
{
|
||||
key: "at",
|
||||
header: "时间",
|
||||
hideBelow: "sm",
|
||||
cell: (r) => (
|
||||
<span className="whitespace-nowrap text-muted-foreground">
|
||||
{relTime(r.finishedAt ?? r.startedAt ?? r.createdAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
{failed ? (
|
||||
<MenuItem
|
||||
icon={<Bot />}
|
||||
onClick={run(() =>
|
||||
openAgentDrawer({
|
||||
label: `整理记录:${recordTitle(r)}`,
|
||||
recordId: r.id,
|
||||
entityId: r.media?.entityId,
|
||||
goal: "排查这次整理为什么没有全部成功,给出恢复办法",
|
||||
}),
|
||||
)}
|
||||
>
|
||||
让智能体排查
|
||||
</MenuItem>
|
||||
) : null}
|
||||
{r.media ? (
|
||||
<MenuItem icon={<ListTree />} onClick={run(onOnlyEntity)}>
|
||||
只看这个作品
|
||||
</MenuItem>
|
||||
) : null}
|
||||
{!isRunning(r) ? (
|
||||
<MenuItem icon={<Trash2 />} onClick={run(onDelete)} danger>
|
||||
删除…
|
||||
</MenuItem>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
/** 按状态计数:只列非零项,点一下按该状态筛选 */
|
||||
function StatsLine({
|
||||
filters,
|
||||
status,
|
||||
onStatus,
|
||||
}: {
|
||||
filters: Omit<RecordFilters, "status">;
|
||||
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 (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-x-1 gap-y-1 text-caption text-muted-foreground tabular-nums">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStatus("")}
|
||||
className={cn(
|
||||
"rounded-md px-1.5 py-0.5 hover:text-foreground",
|
||||
!status && "bg-foreground/[0.06] text-foreground",
|
||||
)}
|
||||
>
|
||||
共 {s.total} 条
|
||||
</button>
|
||||
{parts
|
||||
.filter((p) => p.n > 0)
|
||||
.map((p) => (
|
||||
<button
|
||||
key={p.key}
|
||||
type="button"
|
||||
aria-pressed={status === p.key}
|
||||
onClick={() => onStatus(status === p.key ? "" : p.key)}
|
||||
className={cn(
|
||||
"rounded-md px-1.5 py-0.5 hover:text-foreground",
|
||||
p.tone,
|
||||
status === p.key && "bg-foreground/[0.06]",
|
||||
)}
|
||||
>
|
||||
{p.label} {p.n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<RecordFilters, "status"> = {
|
||||
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<Set<string>>(new Set());
|
||||
const [wizard, setWizard] = useState<OrganizeSource | null>(null);
|
||||
const [toDelete, setToDelete] = useState<Rec[] | null>(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<Rec> = {
|
||||
key: "pick",
|
||||
header: <Checkbox checked={allPicked} partial={selRows.length > 0 && !allPicked} label="全选" onToggle={pickAll} />,
|
||||
className: "w-10",
|
||||
cell: (r) => <Checkbox checked={sel.has(r.id)} label={`选择 ${recordTitle(r)}`} onToggle={() => toggle(r.id)} />,
|
||||
};
|
||||
const columns: DataColumn<Rec>[] = [
|
||||
...(selecting ? [pickColumn] : []),
|
||||
{
|
||||
key: "media",
|
||||
header: "作品",
|
||||
cell: (r) => (
|
||||
<div className="flex min-w-0 max-w-md items-center gap-3">
|
||||
<CoverPlaceholder
|
||||
small
|
||||
title={r.media?.title ?? "?"}
|
||||
kind={r.media?.kind ?? "other"}
|
||||
posterPath={r.media?.posterPath}
|
||||
className="h-12 w-8 rounded-md"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-foreground" title={recordTitle(r)}>
|
||||
{r.media ? recordTitle(r) : <span className="text-muted-foreground">未识别 · {recordTitle(r)}</span>}
|
||||
</p>
|
||||
<p className="truncate text-meta text-muted-foreground sm:hidden">
|
||||
{[ACTION_LABEL[r.action], r.sizeBytes ? formatBytes(r.sizeBytes, 1) : null, relTime(when(r))]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
{r.status !== "succeeded" || r.reason ? (
|
||||
<div className="mt-1 sm:hidden">
|
||||
<StatusCell r={r} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "path", header: "源 → 目标", hideBelow: "md", cell: (r) => <PathLines r={r} /> },
|
||||
{
|
||||
key: "action",
|
||||
header: "方式",
|
||||
hideBelow: "sm",
|
||||
cell: (r) => (
|
||||
<div className="whitespace-nowrap">
|
||||
<p>{ACTION_LABEL[r.action]}</p>
|
||||
{r.sizeBytes ? (
|
||||
<p className="text-meta text-muted-foreground tabular-nums">{formatBytes(r.sizeBytes, 1)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "status", header: "状态", hideBelow: "sm", cell: (r) => <StatusCell r={r} /> },
|
||||
{
|
||||
key: "trigger",
|
||||
header: "来源",
|
||||
hideBelow: "lg",
|
||||
cell: (r) => (
|
||||
<div className="whitespace-nowrap">
|
||||
<p>{TRIGGER_LABEL[r.trigger]}</p>
|
||||
{r.download?.downloaderName ? (
|
||||
<p className="max-w-32 truncate text-meta text-muted-foreground" title={r.download.name}>
|
||||
{r.download.downloaderName}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "at",
|
||||
header: "时间",
|
||||
hideBelow: "sm",
|
||||
cell: (r) => (
|
||||
<span className="whitespace-nowrap text-muted-foreground" title={formatDateTime(when(r))}>
|
||||
{relTime(when(r))}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "menu",
|
||||
header: <span className="sr-only">操作</span>,
|
||||
className: "w-10 pl-0",
|
||||
cell: (r) => (
|
||||
<RowMenu
|
||||
r={r}
|
||||
onOpen={() => 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 (
|
||||
<>
|
||||
<PageHeader
|
||||
title="整理记录"
|
||||
actions={
|
||||
<Button size="sm" onClick={() => navigate("/console/files")}>
|
||||
<FolderInput />
|
||||
手动整理
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
variant={selecting ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => (selecting ? exitSelect() : setSelecting(true))}
|
||||
>
|
||||
{selecting ? <X /> : <SquareCheck />}
|
||||
{selecting ? "退出批量" : "批量"}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => navigate("/console/files")}>
|
||||
<FolderInput />
|
||||
手动整理
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<FilterBar
|
||||
search={{ value: qInput, onChange: setQInput, placeholder: "搜索作品或路径" }}
|
||||
search={{ value: qInput, onChange: setQInput, placeholder: "作品、路径或 hash,支持 * ?" }}
|
||||
onReset={
|
||||
filtered
|
||||
? () => {
|
||||
setStatus("");
|
||||
setTrigger("");
|
||||
setQInput("");
|
||||
setParams({});
|
||||
setParams(v.group ? { group: v.group } : {});
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
variant={grouped ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
aria-pressed={grouped}
|
||||
onClick={() => setFilter("group", grouped ? "" : "entity")}
|
||||
>
|
||||
<ListTree />
|
||||
按作品分组
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<FilterSelect
|
||||
label="状态"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
value={v.status}
|
||||
onChange={(x) => setFilter("status", x)}
|
||||
options={Object.entries(RECORD_STATUS_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="类型"
|
||||
value={v.kind}
|
||||
onChange={(x) => setFilter("kind", x)}
|
||||
options={Object.entries(KIND_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="来源"
|
||||
value={trigger}
|
||||
onChange={setTrigger}
|
||||
value={v.trigger}
|
||||
onChange={(x) => setFilter("trigger", x)}
|
||||
options={Object.entries(TRIGGER_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="时间"
|
||||
value={v.time}
|
||||
allLabel="不限"
|
||||
onChange={(x) => setFilter("time", x)}
|
||||
options={Object.entries(TIME_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
{entityId ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setParams({})}>
|
||||
只看该作品
|
||||
@@ -166,23 +503,83 @@ export default function OrganizePage() {
|
||||
</Button>
|
||||
) : null}
|
||||
</FilterBar>
|
||||
{pinned ? null : <StatsLine filters={base} status={v.status} onStatus={(s) => setFilter("status", s)} />}
|
||||
{selecting ? (
|
||||
<div className="pg-slab sticky top-2 z-20 mb-3 flex flex-wrap items-center gap-2 rounded-xl px-3 py-2">
|
||||
<span className="text-sm text-foreground tabular-nums">已选 {selRows.length}</span>
|
||||
<Button variant="ghost" size="sm" onClick={pickAll}>
|
||||
{allPicked ? "取消全选" : "全选"}
|
||||
</Button>
|
||||
<span className="ml-auto flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" disabled={!selReorg} onClick={() => reorganize(selRows)}>
|
||||
<Wand2 />
|
||||
{selReorg && selReorg < selRows.length ? `重新整理 ${selReorg} 条` : "重新整理"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive"
|
||||
disabled={!selDeletable.length}
|
||||
onClick={() => setToDelete(selDeletable)}
|
||||
>
|
||||
<Trash2 />
|
||||
删除
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<DataTable
|
||||
columns={COLUMNS}
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(r) => 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 ? (
|
||||
<OrganizeWizard
|
||||
source={wizard}
|
||||
onClose={() => {
|
||||
setWizard(null);
|
||||
exitSelect();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<DeleteRecordsDialog
|
||||
records={toDelete ?? []}
|
||||
open={!!toDelete}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setToDelete(null);
|
||||
}}
|
||||
onDone={(removed) =>
|
||||
setSel((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of removed) next.delete(id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 分组模式)。
|
||||
- 统计的「进行中」= 排队 + 执行中;点它时列表只筛执行中。
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof OrganizationListQuery>;
|
||||
|
||||
/** HIS-1:统计与列表同一套筛选(不含状态,状态按计数分开给) */
|
||||
export const OrganizationStatsQuery = OrganizationListQuery.omit({ cursor: true, limit: true, status: true });
|
||||
export type OrganizationStatsQuery = z.infer<typeof OrganizationStatsQuery>;
|
||||
export const OrganizationStats = z.object({
|
||||
total: NonNegInt,
|
||||
succeeded: NonNegInt,
|
||||
partial: NonNegInt,
|
||||
failed: NonNegInt,
|
||||
/** 排队 + 执行中 */
|
||||
running: NonNegInt,
|
||||
cancelled: NonNegInt,
|
||||
});
|
||||
export type OrganizationStats = z.infer<typeof OrganizationStats>;
|
||||
|
||||
/**
|
||||
* 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<typeof OrganizationDeleteRequest>;
|
||||
|
||||
/** 单条记录不能删的原因: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<typeof OrganizationDeleteBlock>;
|
||||
|
||||
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<typeof OrganizationDeleteResult>;
|
||||
|
||||
/** 手动发起整理(organize.execute):返回 record/task ID,由任务页追踪 */
|
||||
export const OrganizeRequest = z
|
||||
.object({
|
||||
|
||||
Reference in new issue
Block a user