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:
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 { randomUUID } from "node:crypto";
|
||||||
import { Api, type OrganizationChangesResponse } from "@umt/contracts";
|
import { Api, type OrganizationChangesResponse, type OrganizationStatsQuery } from "@umt/contracts";
|
||||||
import { sql } from "@umt/db";
|
import { type DB, sql } from "@umt/db";
|
||||||
|
import type { ExpressionBuilder, SelectQueryBuilder } from "kysely";
|
||||||
import { ApiException } from "../../../http/errors.ts";
|
import { ApiException } from "../../../http/errors.ts";
|
||||||
import type { ModuleApp } from "../../types.ts";
|
import type { ModuleApp } from "../../types.ts";
|
||||||
import { type AoDeps, ensureTaskRow, markTask } from "../services/common.ts";
|
import { type AoDeps, ensureTaskRow, markTask } from "../services/common.ts";
|
||||||
@@ -28,27 +29,39 @@ export function registerOrganizationRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
app,
|
app,
|
||||||
Api.organization.list,
|
Api.organization.list,
|
||||||
async (_c, { query }) => {
|
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.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);
|
const p = await paginate(q, query);
|
||||||
return { items: await loadRecordsPublic(db(), p.rows, false), nextCursor: p.nextCursor };
|
return { items: await loadRecordsPublic(db(), p.rows, false), nextCursor: p.nextCursor };
|
||||||
},
|
},
|
||||||
{ tag: TAG },
|
{ 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 早于当前快照最小活跃事务,避免"序号小但提交晚"被跳过
|
// 增量读取:change_xid 早于当前快照最小活跃事务,避免"序号小但提交晚"被跳过
|
||||||
mount(
|
mount(
|
||||||
app,
|
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>) {
|
async function audit(d: AoDeps, actor: string, action: string, detail: Record<string, unknown>) {
|
||||||
await d.ctx.db
|
await d.ctx.db
|
||||||
.insertInto("audit_event")
|
.insertInto("audit_event")
|
||||||
|
|||||||
@@ -1821,7 +1821,8 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[
|
|||||||
if (rows.length === 0) return [];
|
if (rows.length === 0) return [];
|
||||||
const ids = rows.map((r) => r.id);
|
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 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
|
includeFiles
|
||||||
? exec
|
? exec
|
||||||
.selectFrom("organization_file_result")
|
.selectFrom("organization_file_result")
|
||||||
@@ -1854,8 +1855,27 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[
|
|||||||
"00000000-0000-0000-0000-000000000000",
|
"00000000-0000-0000-0000-000000000000",
|
||||||
])
|
])
|
||||||
.execute(),
|
.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 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(中文),有就用它,不把内部串直接给人看
|
// blocked:* 的说明在 error.conflicts[].detail(中文),有就用它,不把内部串直接给人看
|
||||||
const conflictText = (err: unknown) =>
|
const conflictText = (err: unknown) =>
|
||||||
((err as { conflicts?: { detail?: string }[] } | null)?.conflicts ?? [])
|
((err as { conflicts?: { detail?: string }[] } | null)?.conflicts ?? [])
|
||||||
@@ -1897,6 +1917,7 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[
|
|||||||
externalIds: idents
|
externalIds: idents
|
||||||
.filter((i) => i.media_entity_id === e.id)
|
.filter((i) => i.media_entity_id === e.id)
|
||||||
.map((i) => ({ provider: i.provider, entityType: i.entity_type, externalId: i.external_id })),
|
.map((i) => ({ provider: i.provider, entityType: i.entity_type, externalId: i.external_id })),
|
||||||
|
posterPath: posterOf(e.metadata),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
trigger: r.trigger,
|
trigger: r.trigger,
|
||||||
@@ -1913,6 +1934,14 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[
|
|||||||
target: targetRef,
|
target: targetRef,
|
||||||
reason: reason(r.reason, r.error && r.status === "failed" ? r.error : null),
|
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 },
|
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: includeFiles
|
||||||
? files
|
? files
|
||||||
.filter((f) => f.organization_record_id === r.id)
|
.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 { organizeFiles } from "./organize.ts";
|
||||||
import { previewFile } from "./preview.ts";
|
import { previewFile } from "./preview.ts";
|
||||||
import { probeFiles, scrape, scrapeBatch } from "./probe.ts";
|
import { probeFiles, scrape, scrapeBatch } from "./probe.ts";
|
||||||
|
import { deleteRecords } from "./record-delete.ts";
|
||||||
import { findRecords } from "./records.ts";
|
import { findRecords } from "./records.ts";
|
||||||
import { suggestName } from "./suggest-name.ts";
|
import { suggestName } from "./suggest-name.ts";
|
||||||
import { transfer } from "./transfer.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.tree, async (_c, { query }) => treeOf(d, query));
|
||||||
mount(app, F.recordSources, async (_c, { query }) => recordSources(d, query.recordId));
|
mount(app, F.recordSources, async (_c, { query }) => recordSources(d, query.recordId));
|
||||||
mount(app, F.suggestName, async (_c, { body }) => suggestName(d, body));
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1431,6 +1431,22 @@ export const Api = {
|
|||||||
query: organization.OrganizationChangesQuery,
|
query: organization.OrganizationChangesQuery,
|
||||||
response: organization.OrganizationChangesResponse,
|
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({
|
create: ep({
|
||||||
method: "post",
|
method: "post",
|
||||||
path: "/organization-records",
|
path: "/organization-records",
|
||||||
|
|||||||
@@ -228,6 +228,8 @@ export const PublicOrganizationRecord = z.object({
|
|||||||
episodeNumber: z.number().int().nullable(),
|
episodeNumber: z.number().int().nullable(),
|
||||||
/** 已确认的外部身份,如 tmdb:movie:693134 */
|
/** 已确认的外部身份,如 tmdb:movie:693134 */
|
||||||
externalIds: z.array(z.object({ provider: z.string(), entityType: z.string(), externalId: z.string() })),
|
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(),
|
.nullable(),
|
||||||
trigger: OrganizationTrigger,
|
trigger: OrganizationTrigger,
|
||||||
@@ -240,6 +242,13 @@ export const PublicOrganizationRecord = z.object({
|
|||||||
target: PublicPathRef,
|
target: PublicPathRef,
|
||||||
reason: PublicReason.nullable(),
|
reason: PublicReason.nullable(),
|
||||||
counts: z.object({ total: NonNegInt, succeeded: NonNegInt, failed: NonNegInt, skipped: NonNegInt }),
|
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 */
|
/** includeFiles=false 时为 null */
|
||||||
files: z.array(PublicOrganizationFile).nullable(),
|
files: z.array(PublicOrganizationFile).nullable(),
|
||||||
createdAt: Timestamp,
|
createdAt: Timestamp,
|
||||||
@@ -280,10 +289,87 @@ export const OrganizationListQuery = z.object({
|
|||||||
trigger: OrganizationTrigger.optional(),
|
trigger: OrganizationTrigger.optional(),
|
||||||
mediaEntityId: Id.optional(),
|
mediaEntityId: Id.optional(),
|
||||||
downloadTaskId: Id.optional(),
|
downloadTaskId: Id.optional(),
|
||||||
|
/** 作品标题、源/目标路径、种子 hash;支持 * 与 ? 通配 */
|
||||||
q: z.string().max(200).optional(),
|
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>;
|
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,由任务页追踪 */
|
/** 手动发起整理(organize.execute):返回 record/task ID,由任务页追踪 */
|
||||||
export const OrganizeRequest = z
|
export const OrganizeRequest = z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
Reference in new issue
Block a user