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 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-30 23:57:19 +08:00
1 parent f60254e265
commit 51e82f1cd9
8 files changed
+850 -17

No files matched your search

@@ -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)