Skip to content

feat(organize): library-wide organize sweep with metadata-stub target replacement#726

Draft
kevinheneveld wants to merge 1 commit into
Listenarrs:canaryfrom
kevinheneveld:feat/organize-library-sweep
Draft

feat(organize): library-wide organize sweep with metadata-stub target replacement#726
kevinheneveld wants to merge 1 commit into
Listenarrs:canaryfrom
kevinheneveld:feat/organize-library-sweep

Conversation

@kevinheneveld

Copy link
Copy Markdown
Contributor

What

A library-wide Organize flow (Settings → General): preview every book whose on-disk location disagrees with the configured naming pattern, then apply the moves as queued jobs.

  • Preview (LibraryOrganizeSweepWorkflow): computes each book's expected path via the same LibraryPathPlanner that add/move use (so organize can never disagree with them), and groups results — will_move, already_organized, and per-reason invalid buckets (missing base path, unresolvable pattern, flatten-infeasible, target occupied…).
  • Apply: enqueues standard move jobs (existing IMoveQueueService machinery — nothing new runs in-request), with progress via the existing MoveJob update channel. Returns 503 when the worker isn't available rather than silently dropping.
  • Metadata-stub replacement: a will_move whose target folder exists but contains only metadata (cover/nfo — no audio at any depth, nothing in the DB referencing it) is flagged ReplaceStubTarget (new MoveJobs column, hand-written migration) instead of invalid. At execution time MoveJobProcessor re-verifies the no-audio invariant on disk and deletes the stub so the move can replace it; any audio found since enqueue → the job fails instead. Populated non-stub targets keep failing exactly as before.
  • UI: OrganizeLibraryModal (grouped preview, per-group selection, apply progress) + a pending-moves banner, in a new maintenance section on the General settings tab.

Notes for review

  • Filesystem probing lives behind IOrganizeFilesystem (infrastructure); the workflow stays IO-free per the architecture tests.
  • MoveJobProcessor's target gate moved to a TargetGate partial (the main file was at the 500-line cap).
  • Migration AddReplaceStubTargetToMoveJobs is hand-written (INTEGER, default false) with Designer + snapshot in agreement; boots and migrates clean in the e2e host.

Tests

31 new: organize preview/apply workflow + controller tests (ported from the original suite), processor stub-gate tests, and pure filesystem-classifier tests. Full suite 1046/1046; vue-tsc (pinned 3.3.4) + eslint + prettier + vitest clean.

🤖 Generated with Claude Code

… replacement

A library-wide "Organize" action (Settings → General → Library
Maintenance): GET /library/organize/preview buckets every audiobook
against the canonical path its metadata computes to — already_canonical
/ will_move / collision / invalid_target, with machine-readable reason
codes so the UI can group invalid rows and offer per-kind resolution.
POST /library/organize/apply re-validates each confirmed id against
live state and queues per-book background moves; rows that no longer
qualify are skipped with a reason instead of aborting the rest.

Targets are computed by the same LibraryPathPlanner the add/move flows
use, so organize can never disagree with the paths the rest of the app
computes. Fileless rows are excluded entirely (nothing on disk to
move); rows whose BasePath equals a library root are refused at both
preview and apply (the post-copy source delete would wipe the root).

A populated target is only a hard conflict when it holds something
worth protecting: a metadata-only husk (covers, .opf — leftovers from a
removed release) that nothing in the DB references is classified as a
replaceable stub and queued with an explicit ReplaceStubTarget flag
persisted on the MoveJob (new migration). The move processor re-verifies
the on-disk no-audio invariant before deleting the stub.

"Nested one level too deep" rows (target is an ancestor of source —
refused by the move queue) get a strongly-guarded one-click flatten
(POST /library/organize/flatten): refuses unless every file under the
target already lives under the source, so only empty wrapper
directories are ever collapsed, and rebases BasePath + file paths in
place on success.

GET /library/move/summary aggregates the move queue (per-status counts,
in-flight jobs, recent completed/failed tails, per-job file count/bytes)
for a live MoveQueueStatusBanner shown in the modal and the Maintenance
section; the modal also subscribes to MoveJobUpdate so per-job errors
land next to the right book.

Filesystem primitives (stub detection, flatten evaluate/execute) live
in infrastructure behind a new IOrganizeFilesystem application contract,
keeping the api/application layers filesystem-free per the architecture
tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant