feat(files): W5-A2 move/copy via planImport, organize overrides, delete records, download

- /files/move|copy go through planImport's new `transfer` variant (keep names, directory
  expanded to per-file plan) → organization_record + verifyAndImport/executeRecord: per-file
  ledger resumable, fsproxy, cross-volume directory move = per-file copy→verify→delete then
  remove empty source dirs; shared plan store + plan_stale; asset_location follows per file,
  path_binding revised when a whole directory moved (finalizeTransfer); request waits up to
  FILES_TRANSFER_WAIT_SEC, else executed.pending
- /files/organize per-item overrides take effect: season/episodes (domain planOrganize
  overrides), contentUnitId coverage, targetDir (no binding), operation, slotId (satisfied
  after verify); warnings only for inapplicable overrides
- delete/trash/restore write organization_record (action trash|delete|restore, file-level
  results; migration 0026)
- GET /files/download (admin session, nfo/subtitle/image, size cap, streamed, audited)
- GET /files/records?ids|planIds for the organize page
- fs race errors map to 503/404/403/400 instead of 500
- runbook updated

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 23:53:50 +08:00
1 parent 3641ab9573
commit 36aa99b8aa
25 files changed
+1766 -271

No files matched your search

@@ -0,0 +1,30 @@
import type { Kysely } from "kysely";
import { execDdl, oneOf } from "../src/migration-helpers.ts";
// W5-A2 文件管理:删除 / 回收站 / 还原也写整理记录(organization_record + 逐文件结果),整理记录页可追溯。
// action / mode 取值新增 trash(移入回收站)、delete(永久删除)、restore(从回收站还原)。
const OLD = ["copy", "move", "rename", "relocate", "hardlink"] as const;
const NEW = [...OLD, "trash", "delete", "restore"] as const;
const swap = (values: readonly string[]) => `
alter table organization_record drop constraint organization_record_action_check;
alter table organization_record add constraint organization_record_action_check ${oneOf("action", values)};
alter table organization_file_result drop constraint organization_file_result_mode_check;
alter table organization_file_result add constraint organization_file_result_mode_check ${oneOf("mode", values)};
`;
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(db, swap(NEW));
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`delete from organization_file_result where mode in ('trash', 'delete', 'restore');
delete from organization_record where action in ('trash', 'delete', 'restore');
${swap(OLD)}`,
);
}
+18 -2
View File
@@ -154,12 +154,28 @@ export type MediaServerInstanceHealthStatus = "unknown" | "healthy" | "degraded"
export type MetadataFieldSource = "tmdb" | "user" | "agent" | "nfo" | "site" | "import";
export type ObservationStatus = "new" | "linked" | "ignored" | "error";
export type OrganizationFileResultRole = "main" | "subtitle" | "audio" | "bluray_dir" | "nfo" | "image" | "other";
export type OrganizationFileResultMode = "copy" | "move" | "rename" | "relocate" | "hardlink";
export type OrganizationFileResultMode =
| "copy"
| "move"
| "rename"
| "relocate"
| "hardlink"
| "trash"
| "delete"
| "restore";
export type OrganizationFileResultStatus = "queued" | "running" | "succeeded" | "failed" | "skipped" | "cancelled";
export type OrganizationRecordTrigger = "auto" | "manual" | "agent";
export type OrganizationRecordActorType = "user" | "agent" | "system" | "api_key";
export type OrganizationRecordSourcePathView = "downloader" | "local" | "library";
export type OrganizationRecordAction = "copy" | "move" | "rename" | "relocate" | "hardlink";
export type OrganizationRecordAction =
| "copy"
| "move"
| "rename"
| "relocate"
| "hardlink"
| "trash"
| "delete"
| "restore";
export type OrganizationRecordStatus = "queued" | "running" | "succeeded" | "partial" | "failed" | "cancelled";
export type OutboxEventStatus = "pending" | "dispatched" | "failed" | "dead";
export type PathBindingBindingKind = "entity" | "season";