Repository navigation
v0.14.0
Upgrade Notes
- AI review/history state moves to the data volume (ARCH-034).
ai_match_history.json,recent_imports.jsonandai_review_decisions.jsonnow live underWEB_MANAGER_DATA_DIR(default/web-manager-data), next toai_batch_jobs/, instead of under/config(the Beets config mount). On the first start after upgrade each file is moved from its old/configlocation 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; keepWEB_MANAGER_DATA_DIRon 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.5to1.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 withrollback_unavailableinstead 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.pyformat sniffing) viaImage.open(formats=...),MAX_IMAGE_PIXELS,verify(),init()/OPEN,n_frames/is_animatedandDecompressionBombWarning— 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 inrequirements.txt;requirements-dev.txtinherits 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-failederrorfields (backend/transaction_engine.rollback_recover_import, surfaced to the client through_rollback_dispatch_body), and the embedded-Beets config/library self-test messages returned byGET /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 11py/path-injectionalerts in the webmanager rollback ops (restore_ops/replace_ops/remove_ops/art_ops) were reviewed as false positives: every request-derived path flows throughengine_common._manifest_dir, which accepts only a[0-9a-f]{32}id and re-verifies containment withrealpath+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.tmpwithO_NOFOLLOW, andremove_ops/replace_opsvalidate 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_downloadvalidated the incomingrecovery_carrier_idonly as 32 hex characters and otherwise trusted it: a crafted/stale id belonging to a different in-flight recovery flowed intorecovery_carrier.create_carrier(an unconditional upsert) and reset that victim carrier'scheckpoint→CREATEDandverification_evidence→{}while replacing itsexisting_album_id/mb_releasegroupid/folder, and the IMPORTED/PLEX_SYNCED resume short-circuit returned the victim's completed state as a falseimportedsuccess 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 — sameexisting_album_id, and same canonicalmb_releasegroupidwhen 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-resettingadvance_stateCAS 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. Theauto_import=falseReview hand-off is now fail-closed: when the enqueue fails, the carrier (excluded from the AI batch) is marked a recoverablefailedand the job returns a non-successreview_handoff_failedresult rather than a bare success that would strand the folder. Separately,recovery_carrier.advance_statenow ignores (with a logged warning) the reservedpayload_extrakeyscheckpoint/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_downloadare now fail-closed: (1) a transient carrier-read error inside the create step no longer falls through torecovery_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'scheckpoint/verification_evidence(re-opening the Finding-A clobber under a read error); (2) theauto_import=falseReview 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 aNone/raised reload is marked recoverablefailedand the job returnsreview_handoff_failedinstead 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 intomb_albumid, not onlymb_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 staleexpected_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-idstored the request's wholeai_suggestion: when the import was refused, itspreflight,track_mapping,mb_valid,review_evidenceandfolder_signaturewent into the Review queue as if the server had computed them, bypassing the rulePOST /api/ai-pending-reviewalready 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. Anai_suggestionthat 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.jsonandai_review_decisions.jsonnow default toWEB_MANAGER_DATA_DIR/<name>(likeai_batch_jobs/andrecovery_carrier), with a one-time, no-data-loss migration from the old/configlocation 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-queuenow setssuggestion_staleon 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 fromevidence.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 thenote, and the indicator never looks falsely "successful"/green until the workflow is genuinely Complete. Thenotequalifier is surfaced compactly. The indicator is screen-reader intelligible — an ordered list witharia-current="step"and arole="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/partialrecovery_stage(older carriers), rendering nothing instead of crashing. NewRecoveryStageIndicatorinfrontend/src/features/importReview/MbidLink.tsx;RecoveryStagetype added tofrontend/src/api/types.tsmirroring 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 sharedMbidLinkcomponent (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 readsevidence.recovery_carrier(falling back to the flattenedrecovery_*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 infrontend/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 sharedMbidLink, so the addendum ("every MBID shown is the full UUID, clickable to the correct MB entity in a new tab withrel="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 canonicalmb_releasegroupidstays 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 thehandleSuggestreachable-"no match" warning-not-success path). - Missing-track recovery identity carrier (Phase-2 spine, ARCH-030). New
backend/recovery_carrier.pyis a durable, schema-versioned, compare-and-swap SQLite store (under<WEB_MANAGER_DATA_DIR>/recovery_carriers,RECOVERY_CARRIER_STATE_DIR, modelled onai_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 theallow_existingboolean + 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 holdsexisting_album_id, the canonicalmb_releasegroupid(ADR-0002; the supporting Release is stored separately inrelease_idand is never substituted for the Release Group), the expected recording/release-track (recording_id/track/disc/duration),destination_album_path,wanted_tracks, the downloadsource/method + provenance,origin_job_id, averification_evidenceslot and the recovery/checkpoint state for one guarded, resumable recovery workflow (recovery_workflow_id, reusingjob_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 byrecovery_carrier.load_for_folder. Published accessors for the downstream specialist phases:create_carrier(...),load_for_folder(path)/load_for_job(job_id), andadvance_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 inmatching_service,import_service,ai_serviceandacquisition_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_preflightnow runs a first-class single-track-recovery mode: the existing album, canonicalmb_releasegroupidand 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 viaadvance_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_existingnow recognises a recovery carrier bound to the staged folder (looked up by path throughrecovery_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 intransaction_recovery/rollback_guard) — no new mutation path. Before any library change it claims the carrier intoimportingwith a compare-and-swap (a carrier alreadyimporting/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 toimported+ checkpointIMPORTED; a merge that moved nothing (different Release Group, or slots held for review) goes toreview_required; a merge that raised leaves a recoverablefailedstate 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_downloadnow populates the carrier with the recording-levelexpected_tracks(recording_id/track/disc/duration/title, taken from the missing-track queue already reconciled against MusicBrainz completeness) and thesource{method, provenance}, keeping the canonicalmb_releasegroupiddistinct from the supportingrelease_id(never substituted). The auto-import path drives the carrier's durable checkpoints as the download progresses (CREATED→DOWNLOADEDwhen the staged folder is bound →STAGEDwhen verified audio is ready for Beets;VERIFIED/IMPORTED/PLEX_SYNCEDremain the downstream owners'). A missing-track recovery now runs as one guarded, resumable job whosejob_contract.guarded(workflow=…)slug isrecovery_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-albumdownload-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+ checkpointIMPORTED),import_service._recovery_plex_sync_legtriggers one best-effort Plex library refresh (plex_service._trigger_plex_refresh, new allow-listedrecoveryworkflow) and advances the carrier toPLEX_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 atIMPORTED(retriable). The operator's opt-inPLEX_SYNC_AUTO_REFRESHis respected — when it is off (or Plex is not configured) no refresh is forced and the carrier records a skip and stays terminalimported. The leg is idempotent (a carrier alreadyPLEX_SYNCEDis a no-op). - Checkpoint resume (F7). A recovery re-triggered with its existing
recovery_carrier_idreuses 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 atIMPORTED/PLEX_SYNCEDshort-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_jobis now wired into the resume lookup. Durability does not depend on the UI. - Replacement is not a recovery (F8).
acquisition_serviceno longer classifies a format replacement (replace_existing) carryingwanted_tracksfor 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_folderlookup,import_service._recovery_rebind_restagedrebinds the carrier to the staged path so the merge still runs under carrier ownership/audit (reachingIMPORTED/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 markedreview_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, plusneeds_review/failedterminals):{stage, stage_index, label, terminal, note}. It is surfaced on the Review item viareview_identity(...)['recovery_stage']for the Phase-6 status UI. Tests:tests/test_mtr_backend_complete.py,tests/test_mtr_recovery_merge.py.
- Plex-sync leg (F9). Once the verified track reaches the existing album (carrier
- Plex library-sync audit and opt-in auto-heal (
POST /api/plex/sync/audit,POST /api/plex/sync/heal). Newbackend/plex_sync_service.py(bound byroutes_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 underMUSIC_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, default568— the linuxserver.io PUID/PGID convention), POSIX ACL masks decoded fromsystem.posix_acl_access; beets — items joined by album directory throughbeets_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 separatein_plex/missing_from_plex/not_in_beets/plex_unavailable,beets_only_artistsnames 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'sallow_chmod/allow_touch/allow_refresh/max_pathsoverride 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 shapechmodcannot fix is counted as blocked with the exactsetfaclcommand 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(default20000, minimum100) caps permission edits per run and reports the remainder aspaths_remainingrather 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 variablesPLEX_FS_GID,PLEX_SYNC_AUTO_CHMOD,PLEX_SYNC_AUTO_TOUCH,PLEX_SYNC_AUTO_REFRESH,PLEX_SYNC_MAX_REPAIR_PATHS(documented indocs/CONFIGURATION.mdand.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) andtests/test_plex_sync_routes.py(both endpoints start the right job, reuse a running one, refuse an all-disabled heal, clampmax_paths, forward body flags to the service). - Exact-path item restore for trusted rollbacks. New plugin operation
POST /webmanager/item-path/restore(andBeetsAdapter.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_rollbackremains 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.pydrives 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 legacyrollback_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_releasegroupidon 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_metadatanow 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_reviewsnow 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_pendingloads withprune_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 markedimport_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_folderre-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 originalconflict/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_existingadvanced the carrier toimported+ checkpointIMPORTEDwheneverattempted = 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 reachedIMPORTED, 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_presentby recording id or (disc, track). A strict-subset (partial) delivery leaves the carrier in the existing recoverablereview_requiredstate (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 makesacquisition_service._requested_tracks_coveredcorrect 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 theMbidLinkentity fromitem.target_kind(item→recording, otherwiserelease), 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 entityrelease, linking tohttps://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 arecording; an album target is the canonicalrelease-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 (themb_albumidfallback), in which case it is arelease. 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, plusapplyMbidEntityunit 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 reassignsaldir, 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 toIMPORTED→PLEX_SYNCED. Tests:tests/test_mtr_autopath_rebind.py(end-to-end auto progression over the realstart_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_stagefrom the Review item's stored evidence snapshot, which the non-auto hand-off captures before the carrier advances toreview_requiredand which the read path never refreshed — so the queue could show e.g. "Verifying" while the carrier was actuallyreview_required/failed. The Review-queue serializer (GET /api/import/review-queue) now recomputesrecovery_stagefresh from the live carrier at read time (recovery_carrier.get_carrier→workflow_stage) viapending_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 fromitem.mb_valid, which the backend sets by UUID syntax only (re.matchon the UUID shape) — so a syntactically-valid-but-unverified id (e.g. one lifted fromevidence.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: ifmb_validshould 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 (theattach_recording_confirmkind had been migrated butapplyhad not), now renders its id throughMbidLinkwith the correct entity (recordingfor a per-item attach, otherwiserelease) and the invalid-not-linked behavior. The frontend-builtmusicbrainz.orgnavigation-button URLs (musicBrainzUrl/musicBrainzReleaseGroupUrl/musicBrainzRecordingUrland the inline release-group href) now validate the UUID withMbidLink'sisValidMbidbefore 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-recomputedrecovery_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 validatedMbidLink(full UUID,/release/link in a new tab withrel="noreferrer noopener", Copy, typed, neutral Suggested status). (3)applyMbidEntityread the release (edition) id assm.representative_release_id || item.mb_albumid, so with a selected match presentitem.mb_albumidwas 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,reviewItemStateKeystage-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_updatedid aSELECT, a Python-level revision pre-check, then anUPDATE ... WHERE carrier_id=? AND revision=?, but never inspected the UPDATE's affected-row count and unconditionally returnedget(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 — socas_updatehanded the loser the winner's record as a false success. That defeatedimport_service._recovery_claim_importing's compare-and-swap claim intoimporting— the only serializer on the manual Reviewapply_confirmed_importpath — letting two confirmers import the same track into the existing album (a duplicate audio merge).cas_updatenow raisesRecoveryCarrierConflictErrorwhenever a genuine 0-row UPDATE of an existing row occurs (mirroringai_batch_state_store), including on anexpected_revision=Noneblind 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 inimporting/importedor a terminal state back toverifying/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_v1andgenre_repair_v1are 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 becomesRecovery Requiredbefore the restart sweep can mistake the earlier apply's success for the rollback's outcome; resumable folder-cleanup rollbacks instead remainRunningfor 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 durableapplyingmanifest before mutation, restore rows in the right order, prove live album membership undermutation_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
embedartup front, snapshots every cover filename/extension fetchart may overwrite (not justcover.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 reportrollback_unavailable. A confirmed import that wrote tags in place reportsPartially Rolled Backwithfile_tags_restored: falserather 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_idwhile 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-readyimports 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_THREADSis below 8) and answer503 suggest_busystraight 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
pathno longer raises inside the route.