Skip to content

v0.14.0

Choose a tag to compare

@github-actions github-actions released this 27 Oct 14:43
· 657 commits to main since this release
cc4e8c6

Upgrade Notes

  • AI review/history state moves to the data volume (ARCH-034). ai_match_history.json, recent_imports.json and ai_review_decisions.json now live under WEB_MANAGER_DATA_DIR (default /web-manager-data), next to ai_batch_jobs/, instead of under /config (the Beets config mount). On the first start after upgrade each file is moved from its old /config location to the new one automatically and exactly once — existing import/match history is preserved, a file already present at the new location is never overwritten, and on any move error the old file is left in place rather than lost. No action is required; keep WEB_MANAGER_DATA_DIR on the data volume. A fresh install starts with these files empty, as before.
  • Restart or recreate Web Manager after upgrading to reload its embedded Beets plugin. The webmanager plugin moves from 1.18.5 to 1.19.0: it adds the durable import/album-row rollback operations and the exact-path item restore used by artist-folder rollback. No config or library migration is required. Older transactions that lack before-state refuse rollback with rollback_unavailable instead of being reported as restored.

Security

  • Pillow upgraded 12.3.0 → 13.0.0, clearing all 82 open Dependabot alerts. Every open Dependabot alert on the repository (10 critical, 46 high, 26 moderate) was a Pillow advisory with no fix in the 12.x line; 13.0.0 is the first patched version for all of them. Pillow is only ever used to validate/measure uploaded or provider-fetched artwork bytes (backend/artwork_service.py, beetsplug/webmanager/art_ops.py, backend/transaction_engine.py format sniffing) via Image.open(formats=...), MAX_IMAGE_PIXELS, verify(), init()/OPEN, n_frames/is_animated and DecompressionBombWarning — all unchanged in 13.0.0. The only breaking change that applies is the drop of Python 3.10; the runtime image is Python 3.12 and the test image Python 3.13, so no code change was required. The single pin lives in requirements.txt; requirements-dev.txt inherits it via -r requirements.txt, which is why Dependabot attributed the alerts to both manifests.

  • Rollback and health-detail responses no longer echo raw exception text (SEC-007; CodeQL py/stack-trace-exposure #1433/#1443/#1444/#1445). Four sinks returned stringified caught exceptions to the client: the recover-import rollback route (routes_maintenance.api_transaction_rollback, the (KeyError, ValueError) branch), the recover-import rollback executor's lookup-failed and execution-failed error fields (backend/transaction_engine.rollback_recover_import, surfaced to the client through _rollback_dispatch_body), and the embedded-Beets config/library self-test messages returned by GET /api/health/detail (backend/beets_embedded.py). Each now logs the full detail server-side (and keeps it in the transaction's persisted operator-only logs where one exists) and returns only a bounded message plus the exception's type name — never its text, which could carry internal paths or config contents. The 11 py/path-injection alerts in the webmanager rollback ops (restore_ops/replace_ops/remove_ops/art_ops) were reviewed as false positives: every request-derived path flows through engine_common._manifest_dir, which accepts only a [0-9a-f]{32} id and re-verifies containment with realpath+commonpath, so request-driven traversal is impossible. A Codex second-opinion review additionally prompted defense-in-depth hardening of the same ops (exploiting the remaining gaps needs write access to the engine-private quarantine dir under /config): the manifest writers now open their .tmp with O_NOFOLLOW, and remove_ops/replace_ops validate manifest-derived move sources with resolved containment instead of a lexical prefix check. Tests: tests/test_sec007_recover_rollback_route.py, tests/test_recover_uploads.py, tests/test_beets_embedded.py, tests/test_sec007_manifest_write_nofollow.py, tests/test_webmanager_quarantine_remove.py.

  • Missing-track recovery resume cannot be steered by a client-supplied carrier id (ARCH-030). acquisition_service.start_album_download validated the incoming recovery_carrier_id only as 32 hex characters and otherwise trusted it: a crafted/stale id belonging to a different in-flight recovery flowed into recovery_carrier.create_carrier (an unconditional upsert) and reset that victim carrier's checkpoint→CREATED and verification_evidence→{} while replacing its existing_album_id/mb_releasegroupid/folder, and the IMPORTED/PLEX_SYNCED resume short-circuit returned the victim's completed state as a false imported success for the attacker's unrelated request. Cross-album/cross-recovery imports stayed blocked by the merge album-equality gate, the CAS claim and the live-album precheck; this closes the carrier-row integrity gap underneath them. The resume path now cross-checks the stored carrier's identity against the request — same existing_album_id, and same canonical mb_releasegroupid when the request names one (ADR-0002) — before honoring the id: on any mismatch (or a carrier that cannot be loaded) the incoming id is ignored and a fresh uuid is minted, so a stolen id can neither clobber another carrier nor drive the short-circuit or the victim's guarded-workflow slug. A legitimately-matching resume now reuses its carrier through the non-resetting advance_state CAS instead of the resetting upsert, so its durable checkpoint and verification evidence survive the resume. The short-circuit additionally requires the completed carrier to actually cover the requested track(s) before reporting already-imported, and a transient carrier-read failure on a genuine resume surfaces a retryable error instead of silently re-downloading/re-importing a possibly-complete recovery. The auto_import=false Review hand-off is now fail-closed: when the enqueue fails, the carrier (excluded from the AI batch) is marked a recoverable failed and the job returns a non-success review_handoff_failed result rather than a bare success that would strand the folder. Separately, recovery_carrier.advance_state now ignores (with a logged warning) the reserved payload_extra keys checkpoint/verification_evidence/recovery_workflow_id, so those durable fields can only be set through its dedicated validated kwargs (the checkpoint allow-list / evidence slot) and never smuggled in unchecked by a future caller. No credentials, tokens or secrets are involved; the warning logs only key names and the carrier id. Tests: tests/test_mtr_acquisition_carrier.py, tests/test_mtr_backend_complete.py, tests/test_recovery_carrier.py.

  • Missing-track recovery resume closes its remaining fail-open integrity edges (ARCH-030, round 3). Five residual edges in the security-sensitive resume path of acquisition_service.start_album_download are now fail-closed: (1) a transient carrier-read error inside the create step no longer falls through to recovery_carrier.create_carrier's resetting upsert — it logs only the exception type and skips creation, so a read blip can never reset a possibly-existing carrier's checkpoint/verification_evidence (re-opening the Finding-A clobber under a read error); (2) the auto_import=false Review hand-off now also fails closed when the carrier cannot be reloaded (not only when the enqueue returns falsy) — the carrier-bound folder is excluded from the AI batch, so a None/raised reload is marked recoverable failed and the job returns review_handoff_failed instead of a bare success that strands the folder; (3) a track with neither a recording id nor a known position is treated as UNPROVEN and never compares as COVERED, so a title-only/unknown-position request can no longer be reported a false already-imported for the same album + Release Group; (4) the requested canonical Release Group is resolved from the same field(s) the handler already accepts (a release-group reference folded into mb_albumid, not only mb_releasegroupid), so the identity cross-check is never silently downgraded to album-only (the Release Group stays canonical — a release id is never substituted, ADR-0002); and (5) a same-album + same-Release-Group resume whose requested track scope differs from the stored carrier's now mints a fresh uuid instead of reusing a sibling carrier's stale expected_tracks. Track matching now bridges recording-id and (disc, position) signatures so a legitimate resume still reuses its carrier. No credentials, tokens or secrets are involved; logs carry only exception type names and the carrier id. Tests: tests/test_mtr_resume_coverage.py.

  • Import Review keeps only the identity a client proposes, never its evidence. POST /api/folders/import-with-id stored the request's whole ai_suggestion: when the import was refused, its preflight, track_mapping, mb_valid, review_evidence and folder_signature went into the Review queue as if the server had computed them, bypassing the rule POST /api/ai-pending-review already enforced. Both routes now keep only a proposed identity (Release, Release Group, artist, album, year, label, country, reason) and bounded display-only origin labels, whose folder is always the validated one; revalidation recomputes everything else. An ai_suggestion that is not an object is ignored instead of failing the job.

Changed

  • AI review/history state files live under WEB_MANAGER_DATA_DIR, not /config (ARCH-034). ai_match_history.json, recent_imports.json and ai_review_decisions.json now default to WEB_MANAGER_DATA_DIR/<name> (like ai_batch_jobs/ and recovery_carrier), with a one-time, no-data-loss migration from the old /config location on first start (ai_service._migrate_legacy_config_state; see Upgrade Notes). Tests: tests/test_arch034_state_migration.py.

Added

  • Import Review marks a stored AI suggestion stale when its folder changed (ARCH-034). GET /api/import/review-queue now sets suggestion_stale on each pending row from the same folder-signature check the unattended import uses (import_review_service._import_review_suggestion_is_stale), and the Review page shows a "Suggestion stale" badge on such an item (hidden once a fresh candidate is selected). It is display-only — Apply still requires a fresh Find Match or a validated ID with a fresh target preview, unchanged. Tests: frontend/tests/ImportReviewSuggestionStale.test.tsx.
  • Import Review shows a 7-stage Download-Missing recovery status indicator (ARCH-030 Phase-6, frontend). A carrier-backed Review item now renders the recovery workflow's position as the 7 linear stages — Missing → Searching → Downloading → Verifying → Importing → Plex Sync → Complete — as an ordered list with prior stages marked done, the current one highlighted, and later ones upcoming, positioned by the backend's stage_index (never guessed). The two off-path terminals (needs_review, failed) render as a distinct attention/error badge rather than a linear step. It consumes the backend status contract read-only from evidence.recovery_carrier.recovery_stage (backend/recovery_carrier.py::workflow_stage): a best-effort Plex failure stays at the Plex Sync step as an in-progress/attention state (the import is already durable — not a red error), Plex skipped-by-config or not-configured reads as Complete with the reason in the note, and the indicator never looks falsely "successful"/green until the workflow is genuinely Complete. The note qualifier is surfaced compactly. The indicator is screen-reader intelligible — an ordered list with aria-current="step" and a role="status" summary announcing "step N of 7: ", with color always paired with a text label and marker (not color-only) — and resilient to a missing/partial recovery_stage (older carriers), rendering nothing instead of crashing. New RecoveryStageIndicator in frontend/src/features/importReview/MbidLink.tsx; RecoveryStage type added to frontend/src/api/types.ts mirroring the contract exactly. Tests: frontend/tests/RecoveryStageIndicator.test.tsx.
  • Import Review renders missing-track recovery truthfully (ARCH-030, frontend). A carrier-backed Review item now shows a visually distinct "Missing-track recovery" panel — "Recovery of <album> — track N", the existing target album (with its library album_id) and its canonical Release-Group MBID, the supporting Release and each expected track's Recording ID — instead of looking like a brand-new full-album import. Every MusicBrainz ID on the card goes through a shared MbidLink component (frontend/src/features/importReview/MbidLink.tsx) that shows the full UUID, links to the correct MB entity (release-group/release/recording/artist) in a new tab only after validating the UUID and entity, offers a one-click Copy button, labels the id by type, and carries a Verified / Suggested / Conflicting status. Recovery identities are shown as Suggested (unverified) — an unverified suggestion is never labelled as a confirmed match — because the carrier reaches Review precisely because it could not auto-resolve. The item reads evidence.recovery_carrier (falling back to the flattened recovery_* fields on the suggestion); it consumes the backend contract read-only. The Delete Folder action is grouped in a visually separated destructive zone and still requires the existing explicit confirmation. Tests: frontend/tests/MbidLink.test.tsx.
  • Every MusicBrainz ID on the Import Review page now routes through MbidLink, not just the recovery panel (ARCH-030, frontend). The remaining non-recovery MBID displays in frontend/src/features/importReview/ImportReviewPage.tsx — the item-level canonical Release-Group ID, the current Release ID shown on a "needs release group" row, the candidate-carousel release group / representative release, the recording-evidence panel's Suggested Recording / Release / Release Group IDs, each linked-release id, and the recording-attach confirmation id — previously rendered as bare <a>/mono text with no UUID validation, Copy button, type label or status. They now use the shared MbidLink, so the addendum ("every MBID shown is the full UUID, clickable to the correct MB entity in a new tab with rel="noreferrer noopener", has a Copy button, is typed, carries a Verified / Suggested / Conflicting status, and is validated before linking") holds across the whole Review page. The canonical mb_releasegroupid stays labelled as the Release Group and is never swapped for a Release id (ADR-0002). Status defaults to the neutral Suggested and is only Conflicting where the attach candidate carries conflicts; no Verified state is invented from syntax validity (see the truthfulness fix under Fixed). MusicBrainz navigation buttons that show a label (not a UUID) and the "submit a new release at musicbrainz.org/release/add" instructional link are unchanged, as they display no MBID. Tests: frontend/tests/ImportReviewMbidMigration.test.tsx (correct per-entity URL, neutral-vs-verified status, an invalid id never linked, and the handleSuggest reachable-"no match" warning-not-success path).
  • Missing-track recovery identity carrier (Phase-2 spine, ARCH-030). New backend/recovery_carrier.py is a durable, schema-versioned, compare-and-swap SQLite store (under <WEB_MANAGER_DATA_DIR>/recovery_carriers, RECOVERY_CARRIER_STATE_DIR, modelled on ai_batch_state_store) that binds a staged missing-track download back to the album it is recovering a track for, so the identity survives process restarts and job splits and is read back as structured data — never parsed from a folder name. Phase-1 proved the pipeline carried that identity only in the live download job's closure/metadata and reduced it to the allow_existing boolean + a free-text suggestion at the Review hand-off, so a single downloaded track was treated as a brand-new full-album import and failed full-tracklist preflight. Each carrier holds existing_album_id, the canonical mb_releasegroupid (ADR-0002; the supporting Release is stored separately in release_id and is never substituted for the Release Group), the expected recording/release-track (recording_id/track/disc/duration), destination_album_path, wanted_tracks, the download source/method + provenance, origin_job_id, a verification_evidence slot and the recovery/checkpoint state for one guarded, resumable recovery workflow (recovery_workflow_id, reusing job_engine + transaction_recovery/rollback_guard). The spine creates the carrier at download (acquisition_service.start_album_download), binds it to the staged folder, and threads the structured identity into the Review hand-off (pending_review_store._queue_folder_for_manual_review) in place of the lossy boolean; the AI-batch scan, pending-review and preflight can resolve the album identity by recovery_carrier.load_for_folder. Published accessors for the downstream specialist phases: create_carrier(...), load_for_folder(path) / load_for_job(job_id), and advance_state(carrier_id, state, checkpoint=…, evidence=…) (checkpointed transitions for the recovery workflow). Specialist depth (preflight single-track mode, verified insertion/merge via the transaction engine, provider carrier population, Review UI) plugs into the # HOOK(<owner>) points left in matching_service, import_service, ai_service and acquisition_service; the spine lowers no AcoustID/confidence/identity-proof threshold. Tests: tests/test_recovery_carrier.py.
  • Single-track-recovery preflight keyed off the carrier (Phase-2 identity depth, ARCH-030). When a recovery carrier is bound to a staged folder, matching_service._folder_release_preflight now runs a first-class single-track-recovery mode: the existing album, canonical mb_releasegroupid and the expected missing track(s) come from the carrier store (recovery_carrier.load_for_folder) — never re-derived from the folder name (ADR-0002) — so a legitimate one-track download for a known multi-track album is verified against the intended album by the deterministic per-recording proof (MusicBrainz recording/release-track evidence, AcoustID fingerprint, requested-track + duplicate checks, artist/track-number consistency) instead of being rejected for lacking all album tracks or accepted by the weaker tracklist-ratio relaxation. A supplemental duration-tolerance check (the carrier's expected duration is milliseconds, the measured audio length seconds — converted, never compared raw; canonical tolerance) sends an AcoustID-proven but wrong-length copy to review. No AcoustID/confidence/identity-proof threshold is lowered (ADR-0003); an uncertain file stays staged for review, and the preflight records what it proved into the carrier via advance_state(..., evidence=…). The AI-batch scan (ai_service._ai_batch_find_audio_dirs) now consumes the carrier for an app-managed staging folder and leaves a carrier-bound recovery out of the generic new-album batch (its own guarded recovery workflow owns it) rather than re-deriving identity from the folder name and importing it as a new album. Tests: tests/test_mtr_carrier_preflight.py.
  • Missing-track recovery: verified single-track insertion into the existing album (Phase-2, ARCH-030, HOOK library-transaction). backend/import_service._merge_imported_album_into_existing now recognises a recovery carrier bound to the staged folder (looked up by path through recovery_carrier.load_for_folder, never parsed from the folder name) that names the album being merged into, and drives it through the carrier's recovery state instead of treating the download as an ordinary merge. The insertion reuses the existing, already transaction-backed reconcile (composite_workflows.{plan,apply}_existing_album_reconcile → the transaction engine's preview/apply/audit/recovery and the durable rollback in transaction_recovery/rollback_guard) — no new mutation path. Before any library change it claims the carrier into importing with a compare-and-swap (a carrier already importing/imported, or a lost CAS, means a second recovery job — or a crash that left it mid-import — and is refused so the same track is never imported twice; album resource locks the reconcile apply already takes serialise same-album concurrency) and runs fail-closed preconditions: the target album must still exist and every expected (missing) track must still be absent, else the folder is routed to Review with its structured identity and nothing is imported or deleted. On success the carrier advances to imported + checkpoint IMPORTED; a merge that moved nothing (different Release Group, or slots held for review) goes to review_required; a merge that raised leaves a recoverable failed state with the imported album row untouched. The merge never lowers an identity/AcoustID threshold and never deletes the download, existing music or a conflicting file. A folder with no carrier is a strict no-op — the ordinary merge is unchanged. Tests: tests/test_mtr_recovery_merge.py.
  • Missing-track recovery carrier enrichment + single guarded workflow (Phase-2, ARCH-030). acquisition_service.start_album_download now populates the carrier with the recording-level expected_tracks (recording_id/track/disc/duration/title, taken from the missing-track queue already reconciled against MusicBrainz completeness) and the source {method, provenance}, keeping the canonical mb_releasegroupid distinct from the supporting release_id (never substituted). The auto-import path drives the carrier's durable checkpoints as the download progresses (CREATED → DOWNLOADED when the staged folder is bound → STAGED when verified audio is ready for Beets; VERIFIED/IMPORTED/PLEX_SYNCED remain the downstream owners'). A missing-track recovery now runs as one guarded, resumable job whose job_contract.guarded(workflow=…) slug is recovery_carrier.recovery_workflow_id(carrier_id), so a restart resumes the same recovery instead of splitting into decoupled jobs (a plain full-album download keeps the per-album download-import-… workflow). Carrier population and checkpointing are best-effort: a carrier-store failure never breaks the download/import. Tests: tests/test_mtr_acquisition_carrier.py.
  • Missing-track recovery is now one continuous download → verify → verified-insert → Plex-sync workflow (Phase-2 backend tail, ARCH-030). The recovery pipeline's remaining robustness gaps are closed and the workflow runs end to end:
    • Plex-sync leg (F9). Once the verified track reaches the existing album (carrier imported + checkpoint IMPORTED), import_service._recovery_plex_sync_leg triggers one best-effort Plex library refresh (plex_service._trigger_plex_refresh, new allow-listed recovery workflow) and advances the carrier to PLEX_SYNCED. It is best-effort and auditable: a Plex failure never fails or rolls back the completed import — it is recorded on the carrier (plex_sync.status) and the import stays intact at IMPORTED (retriable). The operator's opt-in PLEX_SYNC_AUTO_REFRESH is respected — when it is off (or Plex is not configured) no refresh is forced and the carrier records a skip and stays terminal imported. The leg is idempotent (a carrier already PLEX_SYNCED is a no-op).
    • Checkpoint resume (F7). A recovery re-triggered with its existing recovery_carrier_id reuses the one guarded workflow (recovery_workflow_id) and resumes from the last durable checkpoint instead of restarting from scratch or splitting into decoupled jobs. A carrier already at IMPORTED/PLEX_SYNCED short-circuits the job: no second download and no second import (and the carrier CAS claim + merge precheck still refuse a double import on any path). recovery_carrier.load_for_job is now wired into the resume lookup. Durability does not depend on the UI.
    • Replacement is not a recovery (F8). acquisition_service no longer classifies a format replacement (replace_existing) carrying wanted_tracks for a PRESENT track as a recovery, so it is not minted a carrier and mis-routed to Review by the fail-closed precheck; the Preview-until-approved replacement path owns it.
    • Review re-entry rehydration + restage rebind (F5/F6). When an import re-enters from the Review queue (not the auto path), the carrier's structured identity (existing album + still-missing tracks) is rehydrated from the store and the single verified track is routed through the existing-album missing-track merge instead of importing a second album row. When the still-missing subset is restaged to a new path before the merge's load_for_folder lookup, import_service._recovery_rebind_restaged rebinds the carrier to the staged path so the merge still runs under carrier ownership/audit (reaching IMPORTED/PLEX_SYNCED).
    • Non-auto hand-off (F3). A carrier-bound folder downloaded with auto_import=false — deliberately excluded from the generic new-album AI batch — is handed to Review with its structured carrier identity (and the carrier marked review_required) instead of being stranded in /downloads.
    • User-facing workflow status contract. recovery_carrier.workflow_stage(carrier) is a pure, secret-free mapping from the carrier's durable state + checkpoint onto the seven Download-Missing UI stages (Missing → Searching → Downloading → Verifying → Importing → Plex Sync → Complete, plus needs_review/failed terminals): {stage, stage_index, label, terminal, note}. It is surfaced on the Review item via review_identity(...)['recovery_stage'] for the Phase-6 status UI. Tests: tests/test_mtr_backend_complete.py, tests/test_mtr_recovery_merge.py.
  • Plex library-sync audit and opt-in auto-heal (POST /api/plex/sync/audit, POST /api/plex/sync/heal). New backend/plex_sync_service.py (bound by routes_plex_sync.py) answers why a reachable Plex library still misses music the Web Manager can see. It reconciles three views of the same library: filesystem — every artist/album folder under MUSIC_ROOT — folder naming (literal {Album MbId}-style residue, unresolved template tokens, malformed release-group stamps, plus the real {rgid} suffix style canonical cleanup emits) and per-file readability judged exactly as the kernel would for the group the Plex server runs as (PLEX_FS_GID, numeric, default 568 — the linuxserver.io PUID/PGID convention), POSIX ACL masks decoded from system.posix_acl_access; beets — items joined by album directory through beets_adapter (canonical identity: mb_releasegroupid); Plex — the music section's track index joined by file path only, never by text identity (a failure or absence of Plex leaves the filesystem findings usable). The audit is read-only: per-album verdicts separate in_plex / missing_from_plex / not_in_beets / plex_unavailable, beets_only_artists names artists whose folders Plex has never indexed, naming/readability totals and per-artist examples are capped (50) so the payload stays bounded. The heal runs three individually opt-in repairs (off by default; the request body's allow_chmod / allow_touch / allow_refresh / max_paths override the environment for that single run; a heal with every action disabled is refused): chmod adds group read (and directory traverse) bits — and, only where no ACL entry covers Plex's group and a plain-mode fix suffices, chgrps the file (bits are only ever added, never removed — the slskd-shape file, an ACL named entry with the bits under a zero mask, is repaired by chmod raising the mask, while an ACL entry or maskless group-owner shape chmod cannot fix is counted as blocked with the exact setfacl command in the error text) — and repairs unreadable directories independently of file states, since a folder Plex cannot traverse hides every file inside it; touch bumps repaired album directories and their parents, because Plex's incremental scanner decides what to reopen from mtimes — a permission fix alone stays invisible to it (seen live 2026-10-23); refresh triggers one Plex library scan through _trigger_plex_refresh, which may re-send the idempotent refresh POST once on a transient failure (BA-5 protects only non-idempotent resends). PLEX_SYNC_MAX_REPAIR_PATHS (default 20000, minimum 100) caps permission edits per run and reports the remainder as paths_remaining rather than silently skipping. Nothing here deletes, moves or rewrites content; duplicate removal stays behind the unattended-deletion authorization (backend/dedup_authorization), which this feature does not touch. New environment variables PLEX_FS_GID, PLEX_SYNC_AUTO_CHMOD, PLEX_SYNC_AUTO_TOUCH, PLEX_SYNC_AUTO_REFRESH, PLEX_SYNC_MAX_REPAIR_PATHS (documented in docs/CONFIGURATION.md and .env.example). Tests: tests/test_plex_sync_service.py (POSIX ACL decode, readability/repair classification, naming verdicts, walk aggregation, beets join, Plex path presence, opt-in heal incl. the disabled/blocked/capped paths and the gated refresh) and tests/test_plex_sync_routes.py (both endpoints start the right job, reuse a running one, refuse an all-disabled heal, clamp max_paths, forward body flags to the service).
  • Exact-path item restore for trusted rollbacks. New plugin operation POST /webmanager/item-path/restore (and BeetsAdapter.restore_item_paths) moves explicitly named tracks back to their recorded paths inside the allowed roots. It is hash-resumable, never overwrites an existing target, refuses allowed-root escapes and updates the Beets row only after the file is in place. undo_import_rollback remains the operator/recovery primitive for reversing a completed import rollback; it is not a UI transaction-state transition.
  • Real-library rollback regression coverage (#486 a–j). tests/test_arch004_023_real_restore.py drives disposable Beets libraries and real files through album-row/import rollback and undo (bytes, rows, membership, quarantine), artwork fetch/rollback (cover and embedded bytes, changed-art refusal), item/album/genre restore truth, generic-route reachability, crash/restart recovery and durable manifests, concurrent rollback/apply, idempotent replay, exact-path restore (including containment and resume), artist-folder rollback and legacy rollback_unavailable. The frontend contract tests cover multipart key scoping, 5xx key retention and 4xx settlement.

Fixed

  • Rolling back a Missing IDs Release Group fill no longer blanks an item whose file tag already carried that Release Group (ARCH-032, QA #401 N-d). The Missing IDs batch fills mb_releasegroupid on an album that has a Release ID but no Release Group through a Metadata Update with tag writes (routes_library._batch_write_album → composite_workflows.{plan,apply}_album_metadata). The plan's before-state captured only the album-level value (empty) and, for items, only those named in an explicit per-item update — so an album-level tag write captured no per-item values. A Beets album write with tags rewrites every item's tags, so the rollback (rollback_album_metadata → _restore_rows) stamped the album's captured empty value onto every item, blanking the embedded Release Group of an item whose own file tag already carried it. plan_album_metadata now captures each album item's own value of every album-written field in the before-state (explicit per-item updates still add their own fields on top), so the rollback restores the album's value to the album and each item's own value to that item — the item keeps the Release Group it had. The apply/audit/recovery transaction contract is unchanged (only the before-state capture is widened; apply still writes only the album and any explicit per-item updates), and no threshold is lowered. This is the root-cause fix for the whole class: any album-level metadata write (name, genre, albumartist) now rolls back to each item's own prior value rather than the album's. Tests: tests/test_arch032_release_group_fill_rollback.py (fails before the fix: the item is blanked; passes after).
  • Review Queue follow-ups: lock-free prune, idempotent batch hand-off, preserved evidence (ARCH-033, QA #410 N1-N3). Three Review Queue edges are closed. N3 — a list-only Review Queue load pruned stale rows while holding the global queue lock, and the prune made Beets calls, so a slow Beets serialized every queue reader and writer. _load_pending_reviews now computes the Beets-backed resolution checks before the lock (_compute_prune_resolutions(_read_pending_snapshot())) and the locked pass only re-reads and applies them, dropping a row only when its resolution-relevant fields still match the snapshot; no Beets HTTP call happens while the queue lock is held. _add_to_pending loads with prune_resolved=False (it already makes its own library check outside the lock), keeping its locked section Beets-free. N1 — a partial-import batch folder whose Review enqueue failed was marked import_failed, and a retry re-ran its Stage 1 import against a folder Beets had already partly imported. The inline enqueue failure now records the imported album id(s) and stashes the Review payload; on retry _ai_batch_process_folder re-creates the Review row from the stash and never re-runs Stage 1 for an already-imported folder. N2 — the final-pass review for such a folder built a minimal row, dropping the original conflict / skipped_paths / kept_album_ids / deterministic_decision. The stash now feeds both the final pass and the N1 retry, so that evidence survives on the Review row. No queue/lock safety invariant is weakened. Tests: tests/test_arch033_review_followups.py.
  • Missing-track recovery is marked IMPORTED only when every expected/wanted track was actually delivered, so a partially-delivered recovery is never falsely reported imported or stranded (ARCH-030, QA round-2 Finding F-2 / QA-2-r). import_service._merge_imported_album_into_existing advanced the carrier to imported + checkpoint IMPORTED whenever attempted = bool(plan.move_ids or plan.duplicate_rows) — i.e. any move/dup marked the whole recovery imported, even when the merge delivered only a strict subset of the recovery's expected/wanted tracks. Downstream, the acquisition resume short-circuit judges "already-done" by coverage of the carrier's intended tracks (expected_tracks ∪ wanted_tracks) against a carrier that reached IMPORTED, so a resubmission of an intended-but-undelivered track returned a false {"imported": True} and was stranded — the exact "don't lose the track / don't falsely report success" failure this recovery path exists to prevent. The outcome is now proven from the reconcile plan: IMPORTED is reached only when the merge landed on the existing album and every intended track (expected_tracks ∪ wanted_tracks, the same set the resume short-circuit treats as covered) is present in the album as it stands after the merge — the existing rows plus the imported rows this merge moves onto them — checked by reusing the existing _recovery_slot_present by recording id or (disc, track). A strict-subset (partial) delivery leaves the carrier in the existing recoverable review_required state (no fabricated state value, no IMPORTED checkpoint), so a resubmission of an undelivered track is not short-circuited as done. This makes IMPORTED mean "fully delivered", which makes acquisition_service._requested_tracks_covered correct without changing it. No identity/AcoustID/threshold is weakened and no mutation path is added (the delivery check is read-only classification; the CAS claim and fail-closed precheck are unchanged and reused). Tests: tests/test_mtr_recovery_merge.py (test_partial_delivery_is_not_marked_imported, test_full_multi_track_delivery_reaches_imported).
  • Import Review apply-confirm dialog links an album id to the correct MusicBrainz entity, not a dead /release/{release-group-id} (ARCH-030, QA round-3 Finding 6). The dialog hardcoded the MbidLink entity from item.target_kind (item → recording, otherwise release), but the id it shows (mbids[item.id]) is populated from the release-GROUP id (release_group_id) on every album apply path. So the common album confirmation rendered a release-group UUID as entity release, linking to https://musicbrainz.org/release/{release-group-id} (wrong entity / dead link) and mislabelling it "Release ID". The apply intent now carries the id's actual MB entity, derived (applyMbidEntity) from the known source fields rather than guessed: a per-item target is a recording; an album target is the canonical release-group (ADR-0002 — a release-group id is never relabelled a release) unless the id specifically matches the known release (edition) id and differs from the release-group id (the mb_albumid fallback), in which case it is a release. The dialog links/labels from that entity (/release-group/… "Release Group ID", /release/… "Release ID", /recording/… "Recording ID"); the invalid-not-linked behaviour is unchanged. Tests: frontend/tests/ImportReviewMbidMigration.test.tsx (the release-group apply case now asserts /release-group/; added a genuine-release /release/ case and a per-item /recording/ case, plus applyMbidEntity unit cases).
  • Missing-track recovery no longer strands the carrier on the automatic import path when the staged subset is narrowed (ARCH-030, QA round-2 Finding 1). import_service.start_reimport_disk's auto path restages the still-missing subset to a new folder and reassigns aldir, after which the verified-insert merge and the Plex-sync leg look the carrier up by that reassigned path. The restage rebind (_recovery_rebind_restaged) was wired only into the Review re-entry path, so when the second source scan narrowed the subset (a new staged folder is created, not the #427 in-place branch), the carrier — still bound to the pre-restage folder — was orphaned: the merge ran carrier-less, the Plex-sync leg no-oped, and the Download-Missing status stalled at "Verifying". The auto path now rebinds the carrier to the restaged path (same album-equality gate; never parsed from the folder name; a genuinely absent carrier stays a strict no-op leaving the non-recovery merge unchanged) so the verified single-track insertion runs under carrier ownership/audit and the carrier advances normally to IMPORTED → PLEX_SYNCED. Tests: tests/test_mtr_autopath_rebind.py (end-to-end auto progression over the real start_reimport_disk, failing before this fix).
  • Import Review shows the missing-track recovery stage live, not a stale snapshot (ARCH-030, QA Finding 7). The Download-Missing status indicator read recovery_stage from the Review item's stored evidence snapshot, which the non-auto hand-off captures before the carrier advances to review_required and which the read path never refreshed — so the queue could show e.g. "Verifying" while the carrier was actually review_required/failed. The Review-queue serializer (GET /api/import/review-queue) now recomputes recovery_stage fresh from the live carrier at read time (recovery_carrier.get_carrier → workflow_stage) via pending_review_store._refresh_recovery_stage, so the UI always reflects the carrier's current state + checkpoint. The stage object keeps its exact {stage, stage_index, label, terminal, note} shape and carries no secrets/paths; a missing or unreadable carrier degrades to the stored snapshot rather than crashing or fabricating a stage. This also makes the "snapshot taken before the advance" timing moot without changing the acquisition hand-off. (A still-in-flight download that is not yet a Review item has no queue row and is out of scope.) Tests: tests/test_mtr_backend_complete.py::RecoveryStageFreshnessTests.
  • Import Review never labels a MusicBrainz ID "Verified" from UUID syntax alone, and every MBID shown routes through the validated MbidLink (ARCH-030, frontend). The item-level canonical Release-Group chip derived its status from item.mb_valid, which the backend sets by UUID syntax only (re.match on the UUID shape) — so a syntactically-valid-but-unverified id (e.g. one lifted from evidence.preflight.release_group) rendered a green Verified chip, contradicting the addendum rule that an unverified id is never shown as a confirmed match. The chip now defaults to the neutral Suggested (no genuine verified signal exists on the item today; a valid UUID still links and is copyable — validity governs linking, not verification). Follow-up for music-identity: if mb_valid should ever carry a real verified signal, surface it as a distinct field. The apply-confirm dialog, which showed a bare truncated {intent.mbid} with no validation/Copy/type/status (the attach_recording_confirm kind had been migrated but apply had not), now renders its id through MbidLink with the correct entity (recording for a per-item attach, otherwise release) and the invalid-not-linked behavior. The frontend-built musicbrainz.org navigation-button URLs (musicBrainzUrl/musicBrainzReleaseGroupUrl/musicBrainzRecordingUrl and the inline release-group href) now validate the UUID with MbidLink's isValidMbid before building an href, so a malformed id can no longer produce a dead outbound Button link. Tests: frontend/tests/ImportReviewMbidMigration.test.tsx.
  • Import Review surfaces a live recovery-stage change in the queue, migrates the last raw MBID renderer, and corrects the apply-entity derivation under a contradictory selected match (ARCH-030 Phase-6 frontend, NF-4). Three frontend follow-ups on the already-shipped Download-Missing indicator: (1) the 60s Review-queue auto-refresh compared items with reviewItemStateKey, which omitted the recovery stage, so the live-recomputed recovery_stage (backend _refresh_recovery_stage) never changed the snapshot and the "updates available" banner never fired for a recovery in progress — the state key now includes the stage, so a Download-Missing transition (Searching → Downloading → …) is surfaced for refresh (no new backend endpoint; the indicator is wired where the contract is served — the Review item). (2) The one remaining raw non-recovery MBID renderer — the "Representative Release ID" panel, previously bare truncated mono text — now routes through the validated MbidLink (full UUID, /release/ link in a new tab with rel="noreferrer noopener", Copy, typed, neutral Suggested status). (3) applyMbidEntity read the release (edition) id as sm.representative_release_id || item.mb_albumid, so with a selected match present item.mb_albumid was never consulted and an applied id equal to it (but differing from the match's representative release) was mislabelled/linked as a release-group; it now checks the applied id against both known release ids, keeping the ADR-0002-safe release-group default. Tests: frontend/tests/ImportReviewMbidMigration.test.tsx (NF-4 contradictory-match cases, reviewItemStateKey stage-transition cases).
  • Missing-track recovery carrier CAS no longer reports a false success to the loser of a concurrent claim (ARCH-030). recovery_carrier.RecoveryCarrierStore.cas_update did a SELECT, a Python-level revision pre-check, then an UPDATE ... WHERE carrier_id=? AND revision=?, but never inspected the UPDATE's affected-row count and unconditionally returned get(carrier_id). Under two WAL connections that both snapshot the same revision before either commits, both pass the pre-check, one UPDATE matches 1 row (the winner) and the loser's matches 0 rows without raising — so cas_update handed the loser the winner's record as a false success. That defeated import_service._recovery_claim_importing's compare-and-swap claim into importing — the only serializer on the manual Review apply_confirmed_import path — letting two confirmers import the same track into the existing album (a duplicate audio merge). cas_update now raises RecoveryCarrierConflictError whenever a genuine 0-row UPDATE of an existing row occurs (mirroring ai_batch_state_store), including on an expected_revision=None blind write that loses a race; an uncontended blind write on a real row still succeeds. Relatedly, a delayed/concurrent folder preflight (matching_service._record_carrier_preflight_evidence) can no longer downgrade a carrier already in importing/imported or a terminal state back to verifying/review_required (which would have reopened the claim gate for a second claimer): it re-reads state, skips a claimed/terminal carrier, and otherwise advances with a compare-and-swap on the revision it just read. Tests: tests/test_mtr_carrier_cas_race.py.
  • Import Review "Find Match" no longer shows a green success banner when nothing matched. A 200 response from the album/folder or recording suggest endpoints that selected no candidate (no release-group match, no valid MusicBrainz id, no recording id) now renders a neutral "no candidate" warning with the real outcome text, instead of a green success alert reading "Find Match completed" / "No confident recording match found". The success state is reserved for a response that actually selected a match.
  • ARCH-004 / ARCH-023 transaction and rollback reliability (#486). Composite rollback is now real, reachable and truthful for the covered families. item_metadata_repair_v1 and genre_repair_v1 are registered with the generic rollback route, and rollback eligibility mirrors the executors. The rollback claim and recovery marker are one durable write. An interrupted rollback becomes Recovery Required before the restart sweep can mistake the earlier apply's success for the rollback's outcome; resumable folder-cleanup rollbacks instead remain Running for an explicit retry from their durable steps. The redundant synchronous startup sweep is gone. Album-row removal/rollback and confirmed-import rollback/undo write a durable applying manifest before mutation, restore rows in the right order, prove live album membership under mutation_lock, preflight every quarantine source, refuse missing sources rather than re-add rows pointing at absent media, and replay completed retries from stored idempotency results. Quarantine/undo moves verify bytes and never overwrite an occupied destination. Failed album-row removal compensates a completed removal before reporting it as restored.
  • Artwork rollback verifies bytes, not pathnames (#486 H7/H8). Artwork set and fetch record the cover's sha256 and each item's embedded-image hashes after the apply, and rollback refuses when either changed under the same name. Fetch-and-embed now requires embedart up front, snapshots every cover filename/extension fetchart may overwrite (not just cover.jpg), compensates any post-mutation failure, and restores the previous cover and embedded images byte-for-byte. The operator-requested fetch is forced so an existing cover is actually replaced instead of the request reporting success while doing nothing.
  • Metadata, genre and artist-folder rollback restore the library and the files (#486 C3/H10–H12). The covered families now take the same album/item resource locks across snapshot, apply and verification, and validate the engine's result and counts instead of assuming success. An empty DB diff no longer hides changed file tags: DB values, file tags and paths are verified independently, and only a genuine no-op is reported as Rolled Back. Artist-folder rollback restores each track to its exact original path (through the new plugin operation), its original audio payload and its tags; Beets' path-format move alone is not accepted as a restore. Legacy or incomplete records report rollback_unavailable. A confirmed import that wrote tags in place reports Partially Rolled Back with file_tags_restored: false rather than claiming a full restore, and never removes the operator's files.
  • Multipart job starts no longer collide, and a 5xx is not treated as a definitive answer (#486 H9). The frontend scopes a multipart upload's random idempotency key to the form fields and each file's name, size, modification time and type, so a different artwork upload to the same endpoint starts its own job. A 5xx or lost response keeps the key so a retry follows the job the server may already have started; a 2xx/4xx settles it so the next deliberate action is new. The two fetch helpers now share that behaviour.
  • A concurrent artist-discography request follows the running job instead of returning 409. The route already promises a job_id while the lookup runs; a duplicate start now returns the existing job, matching the UI's polling contract.
  • AI match history records only a suggestion that was imported. After every Review import, history recorded the submitted suggestion, including imports of a different release the operator chose and manual imports with no suggestion at all. It now records an entry only when the suggestion's Release Group (or, without one, its Release) is what Beets imported, stores the server-verified IDs, and writes the file atomically.
  • The unattended Review import no longer acts on a suggestion computed for different files. auto-enqueue-ready imports from the stored track mapping and evidence, and the folder signature meant to detect a folder that changed after Find Match was never checked. It is now: a changed folder is skipped with "The folder changed after its match was computed" in the job log until it is revalidated. Find Match and revalidation take the signature before they gather evidence, so files arriving during a check also make the stored result stale. Suggestions stored before signatures existed are not treated as stale.
  • Find Match can no longer occupy every server worker. Each Find Match runs for seconds (fingerprints, rate-limited MusicBrainz, the AI call) inside a web request, and the Review page started one for every item the cursor landed on, so paging through the queue could stall the whole UI. The folder, album and item Find Match routes now run at most two at once (one when WEBCONTROL_THREADS is below 8) and answer 503 suggest_busy straight away beyond that; the page runs one automatic Find Match at a time, gives the item under the cursor its turn when it finishes, and shows a refusal as a warning to retry.
  • Find Match and import-with-id answer a malformed request body with an error instead of a 500. A non-object JSON body or a non-text path no longer raises inside the route.