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

+2
View File
@@ -2,11 +2,13 @@ export {
type ExcludedFile,
type ExistingPolicy,
type ExistingTarget,
type FileOverride,
type OrganizeConflict,
type OrganizeFileKind,
type OrganizeInput,
type OrganizeOperation,
type OrganizePlan,
overrideFor,
type PlannedAction,
type PlannedFile,
type Precondition,
+32
View File
@@ -54,6 +54,32 @@ export interface OrganizeInput {
parse?: ParseOptions;
/** 随行字幕里此语言加 .default 标记(对照 MP DEFAULT_SUB);不设不加 */
defaultSubtitleLang?: DefaultSubtitleLang;
/**
* 逐文件季/集覆写(人工确认,优先于文件名解析):键为源文件路径或其所在目录(最长前缀生效)。
* 只影响剧集的季目录选择与命名;电影忽略。
*/
overrides?: Readonly<Record<string, FileOverride>>;
}
export interface FileOverride {
season?: number;
episodes?: { start: number; end: number };
}
/** 源路径的覆写:精确命中优先,否则取最长的目录前缀 */
export function overrideFor<T extends FileOverride>(
overrides: Readonly<Record<string, T>> | undefined,
path: string,
): T | undefined {
if (!overrides) return undefined;
const exact = overrides[path];
if (exact) return exact;
let best: { len: number; o: T } | undefined;
for (const [k, o] of Object.entries(overrides)) {
const dir = k.replace(/\/+$/, "");
if (path.startsWith(`${dir}/`) && (!best || dir.length > best.len)) best = { len: dir.length, o };
}
return best?.o;
}
export type PlannedAction = "transfer" | "replace" | "skip" | "hold";
@@ -195,6 +221,12 @@ export function planOrganize(input: OrganizeInput): OrganizePlan {
continue;
}
const parsed = parseReleasePath(segmentsFor(f.path, input.sourceRoot), input.parse ?? {});
const ov = input.mediaType === "tv" ? overrideFor(input.overrides, f.path) : undefined;
if (ov?.season !== undefined) parsed.season = ov.season;
if (ov?.episodes) {
parsed.episodes = ov.episodes;
delete parsed.airDate;
}
if (parsed.contentType === "extra" && parsed.evidence.includes("dir:extras")) {
excluded.push({ src: f.path, kind, reason: "extra_content" });
continue;
+38
View File
@@ -270,6 +270,44 @@ describe("W4-E4:字幕 .default 与 latest 旧版本判定口径(对照 MP)", ()
expect(subs.some((n) => n?.endsWith(".eng.srt"))).toBe(true);
});
it("逐文件季/集覆写:优先于文件名解析,目录前缀对其下文件生效,电影忽略", () => {
const D = "/dl/Weird.Name";
const plan = planOrganize({
operation: "copy",
mediaType: "tv",
sourceRoot: D,
files: [
{ path: `${D}/weird-a.mkv`, size: GiB },
{ path: `${D}/weird-a.chs.srt`, size: 1000 },
{ path: `${D}/Show.S01E05.mkv`, size: GiB },
{ path: `${D}/sub/Show.S01E07.mkv`, size: GiB },
],
entity: { title: "剧", year: 2020 },
target: { seasonDirs: { 1: "/lib/剧/Season 1", 2: "/lib/剧/Season 2" } },
overrides: {
[`${D}/weird-a.mkv`]: { season: 2, episodes: { start: 3, end: 4 } },
[`${D}/Show.S01E05.mkv`]: { episodes: { start: 6, end: 6 } },
[`${D}/sub`]: { season: 2 },
},
});
expect(plan.files.map((f) => f.dst)).toEqual([
"/lib/剧/Season 2/剧 - S02E03-E04.mkv",
"/lib/剧/Season 1/剧 - S01E06.mkv",
"/lib/剧/Season 2/剧 - S02E07.mkv",
"/lib/剧/Season 2/剧 - S02E03-E04.chi.zh-cn.srt",
]);
expect(plan.files[0]).toMatchObject({ season: 2, episodes: { start: 3, end: 4 } });
const movie = planOrganize({
operation: "copy",
mediaType: "movie",
files: [{ path: "/dl/M.2020.mkv", size: GiB }],
entity: { title: "M", year: 2020 },
target: { movieDir: "/lib/M (2020)" },
overrides: { "/dl/M.2020.mkv": { season: 3 } },
});
expect(movie.files[0]!.dst).toBe("/lib/M (2020)/M (2020).mkv");
});
it("findOtherVersions:同季同集同 Part、只看视频扩展名", () => {
const sib = [
"/lib/S01/Show - S01E01 - 1080p.mkv",