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:
1 parent
3641ab9573
commit
36aa99b8aa
25 files changed
+1766
-271
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user