Repository navigation
Releases: Iranman/beets-web-manager
Release list
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 folde...
v0.13.5
Performance
- The folder-cleanup DB-reference scan no longer resolves
realpath(MUSIC_ROOT)once per Beets item.composite_workflows.norm_library_pathruns once per item during a full-library scan that repeats on every rename; on a ~5,000-folder live run that was arealpathsyscall storm that could make the run crawl or look hung. The root'srealpathis now resolved once per distinct MUSIC_ROOT string (functools.lru_cache) and reused. The normalization result is byte-for-byte identical; only the trusted root isrealpath-resolved, still never a caller path.
Fixed
-
A tracked album folder whose Beets DB path is a DANGLING symlink is now HELD (
needs_db_resync), not relocated into a loud partial move._album_db_paths_staleusedos.path.lexists, which reports a dangling symlink (link present, target gone) as present, so the relocation ran and Beets skipped every track (a rollback-ablepartial_move). It now usesos.path.exists, so a DB path with no real file behind it is treated as stale and held. Regression test:tests/test_folder_mbid_names_apply.py::TrackedRelocationTests::test_dangling_symlink_db_path_held_needs_db_resync. -
Renaming a Beets-TRACKED album folder to
Album (Year) {release-group-mbid}no longer fails withPATH_IS_TRACKEDwhen the DB and disk paths are desynced. The folder-MBID apply (_apply_folder_mbid_names) decided tracked-vs-disk-only from its own item-count map (_placeholder_folder_db), which keyed item paths with a rawos.path.dirname— no realpath rewrite and no relative-path resolution. On a symlinked/realpath-divergingMUSIC_ROOT(and after an earlier placeholder-strip left the DB naming the pre-rename path) that map reporteddb_item_count=0for a folder the engine still considered tracked, so the apply picked the disk-onlyfolder_renameand the engine's descendant-aware, realpath-aware DB-reference refusal (_library_refs_under) rejected it (folder_cleanup_rename_failed/PATH_IS_TRACKED). Both detectors now normalize through one sharedcomposite_workflows.norm_library_path, so a folder the engine treats as tracked is classified tracked by the apply and routed to the DB-aware relocation (relocate_album/ BeetsAlbum.move) — folder and DB item/album paths move together. When Beets names paths that are no longer on disk (the strip-desync), the album is HELD (needs_db_resync) rather than relocated off stale paths, so no file is lost and DB and disk never diverge. The existing holds (tracked_rgid_mismatch,tracked_multiple_albums,target_exists), disk-only renames, idempotentalready_correct, dry-run, the confirmed gate,batch_limit, resume and rollback-by-job are unchanged. Regression tests:tests/test_folder_mbid_names_apply.py::TrackedRelocationTests(realpath-desynced tracked relocation; stale-DBneeds_db_resynchold).
v0.13.4
Changed
-
A refused cross-origin redirect from a configured service URL now names the redacted redirect target.
SafeRedirectHandleralready refuses a redirect that leaves a configured service's own origin (the implicit trust pins scheme+host+port); itsOutboundPolicyErrornow includes the redacted target origin (scheme://host:port, path hashed — never a secret), so an operator diagnostic can say exactly where the service tried to redirect. Behaviour is otherwise unchanged. -
Temporary Lidarr boot-trust diagnostic (v0.13.3) extended to log the RAW outbound-policy error. The live v0.13.3 diagnostic confirmed the folder-MBID resolver's Lidarr call is rejected with a VALID configured-URL trust rule (
configured_urlcorrect,would_trust_rulevalid) — i.e. not the empty-resolver case and, per direct instrumentation, not a thread/contextvar-inheritance case (the trusted rule IS visible tosecure_urlopenon the worker thread). The one path that rejects a correctly-configured call is a cross-origin redirect from the service endpoint. The diagnostic now also logstype(exc)and the raw policy message (fixed sentences + redacted origins, no secret), so the next deploy names the exact redirect target (e.g.http -> httpson the same host:port, or a different port). Still fires only onProviderOutcome.REJECTED, once per process, and remains temporary pending removal once the live target is confirmed and the redirect-trust policy decision is made. New regression tests:tests/test_provider_trust_redirect_diagnostic.py.
v0.13.3
Fixed
- Starting a fresh AI-batch source scan (e.g. of
/downloads) no longer fails immediately with "ERROR: expected_revision is required to update existing batch …"._start_ai_batch_jobcreates — and bumps the revision of — the batch's durable store row before it unblocks the worker thread, but on a fresh (non-recover) run the worker's_run_ai_batch_importbuilt a brand-new_ai_batch_initial_state, which carries norevision. Its first_ai_batch_committherefore attempted an un-revisioned update of the already-existing row, which the ARCH-010 optimistic-concurrency (CAS) contract correctly rejects. The worker now loads the persisted row first (for the non-recover path too) and threads its current revision through every commit, so the scan proceeds and the stale-writer guard stays fully intact. The CAS contract itself is unchanged — an un-revisioned update of an existing batch is still refused; the fix is in the caller, which now passes the revision the state was last read at. Regression test:tests/test_ai_batch_retry_race.py::FreshBatchSourceScanRevisionTests.
Added
- TEMPORARY boot-trust diagnostic (to be removed once the live cause is confirmed). When the folder-MBID resolver's Lidarr call is REJECTED by the outbound URL policy (
ProviderOutcome.REJECTED),backend.lidarr_rgid_resolvernow emits a single WARNING (once per process) capturing, from the live serving process's own job thread, why the configured-URL trust was not active there:configured_url("lidarr"), whether a resolver is registered and its value,backend.app_runtime.LIDARR_URL, the recomputedimplicit_rule_for_url(configured_url)(would_trust_rule), the live_TRUSTED_RULE, and theid()s of thebackend.app_runtime/backend.security/backend.provider_boundarymodule objects. It logs only non-secret base URLs (userinfo stripped) and rule/module ids — never the Lidarr API key — fires only on the rejection, and changes no behavior otherwise. Diagnoses whether the live cause is the empty-resolver shadow (fixed below) or a duplicate module instance; remove after the v0.13.3 deploy confirms it.
Fixed
- A provider's configured-URL trust can no longer be lost in the long-lived serving process when its registered resolver comes back empty. The outbound boundary grants each operator-hosted provider implicit trust of its own configured URL (
provider_boundary.configured_url), resolved either from a registered resolver (Plex, Lidarr) or, as a robust fallback, from thebackend.app_runtimeconstant the service actually calls (_RUNTIME_URL_CONSTANTS: Lidarr, slskd, qBittorrent, yt-dlp PO). A registered resolver that returned""— e.g. a module-level closure in a route module that loaded before the setting was present, which is exactly how the running server could differ from a freshimport app— previously shadowed that fallback, so the configured URL earned no trust and the provider's own call to a private-LAN address (the Lidarr folder-MBID resolver's192.168.0.250:8686,ProviderOutcome.REJECTED, "blocked by outbound URL policy") was rejected.configured_urlnow falls through to the_RUNTIME_URL_CONSTANTS/sys.modules["backend.app_runtime"]value whenever the resolver yields nothing, so registering a resolver can only ADD a trust source, never remove the one the provider already had. Trust stays scoped to exactly the one configured scheme/host/port; the SSRF guard is unchanged for every other address and provider. New real-app-context regression tests (tests/test_lidarr_trust_boot_activation.py) boot the app the way the server does and assert the Lidarr configured URL is admitted throughprovider_boundary.openedend to end, including on a worker thread (the job engine runs jobs on threads), plus the resolver-fallthrough guard.
v0.13.2
Security
- The lazy
JobStore.get()id lookup now validates the job id with a positive, anchored allowlist before building a filesystem path, clearing two HIGH CodeQLpy/path-injectionalerts. Job ids are server-generated (uuid.uuid4().hex), so the lookup already rejected path-separator/dot/..input, but CodeQL's taint tracker does not recognise a negative traversal check as sanitization. Both path-construction sites now route through a singleJobStore._record_path()barrier that admits an id only via_SAFE_JOB_ID = re.compile(r"\A[A-Za-z0-9_-]{1,128}\Z").fullmatch(...)— a strict superset of every real id, with no separator/dot/traversal possible — before the id is joined onto the jobs directory; thepath.parent == rootcheck stays as defense in depth. Behaviour is unchanged: every valid id (server uuids, resumed/rollback ids, on-disk record stems) still resolves and only malformed/traversal input is refused.
Fixed
- Lidarr now registers its operator-configured URL with the provider boundary, so a private LAN
LIDARR_URLis trusted for Lidarr's own outbound calls (like Plex already is). The folder MBID-name resolver's Lidarr call was rejected by the outbound URL policy (ProviderOutcome.REJECTED, "blocked by outbound URL policy") wheneverLIDARR_URLresolved to a private/RFC-1918 address (e.g.192.168.0.250:8686) and the configured URL was not otherwise resolvable at call time: the boundary grants a provider implicit trust of its own configured URL, but Lidarr (unlike Plex) never registered an explicit resolver, so trust relied on a fragile fallback.routes_lidarr(a startup route module) now callsprovider_boundary.register_configured_url("lidarr", ...)at import time, reading the same livebackend.app_runtime.LIDARR_URLthe resolver uses (env, then theLIDARR_URL_FILEfallback and default). Trust stays scoped to exactly that one configured scheme/host/port for Lidarr's own calls; the SSRF guard is unchanged for every other address and provider. - Durable-job cold start is now fast and bounded regardless of accumulated job history. On a long-lived instance the jobs directory (
WEB_MANAGER_DATA_DIR/jobs) had grown to ~2,796 records (~34 MB), andJobStoreread and parsed every file synchronously at startup (_recoverglobbed andjson.loads'd all of them before the import that constructs the store returned). Over ZFS/NFS this made cold start take ~3 minutes with the process stuck in disk-sleep, so the container stayedunhealthythe whole time — which looked like a hang and triggered an unnecessary deploy rollback. Startup now reads only the most recentJOB_STARTUP_LOAD_LIMITrecords (default200, newest by mtime) on the critical path; the long tail is handled off-path by a background thread that recovers any interrupted job and prunes terminal records older thanJOB_RETENTION_DAYS(default7) from disk.JobStore.get()lazily loads any record by id, so the Jobs API, rollback-by-job and resume still resolve any non-pruned job even when it is outside the startup window.recovery_requiredrecords and running jobs are never auto-pruned, and the lazy id lookup rejects path-traversal input. New env knobsJOB_STARTUP_LOAD_LIMITandJOB_RETENTION_DAYS(seedocs/CONFIGURATION.md/.env.example);0or less for the load limit restores the old read-everything behaviour.
Added
- Regression guard:
POST /api/library/folder-mbid-names/applystarts its heavy work in the job, not the request thread. A new route-level test (tests/test_folder_mbid_async_start.py) asserts the endpoint returns{ok, job_id}promptly (sub-second) while the album-folder walk underMUSIC_ROOTand the Lidarr resolver snapshot — the ~9,022-dir-over-NFS cost that could block a request for ~173s and time out a client — run inside the job body (on the job thread), never synchronously in the request thread. The confirmed-gate (non-dry-run withoutconfirmed:true→ 400) is checked without touching the walk/resolver. Test-only; the handler already delegated the walk/resolver to the job viajobs.start_python, and this locks that structure against regression.
v0.13.1
Security
- Frontend
nextbumped16.4.0 → 16.4.2to clear a CRITICAL RCE and a HIGH DoS advisory flagged by thesecurityfrontend-audit step. Dependency bump only; no application behaviour or configuration change.
Fixed
- Lidarr release-group-MBID resolver scopes its fetch to the library's artists (no longer pulls Lidarr's entire album DB). The folder MBID-name dry-run fetched the full
GET /api/v1/album(all albums) in one call; on a large Lidarr (37,749 albums ≈ 315 MB JSON) that times out at ~30 s and aborts the whole run, even though the library only spans ~149 distinct artists.LidarrRgidResolver.from_lidarrnow takes the set of artist MBIDs actually present on disk (collected from the selected album folders) and fetches only those: oneGET /api/v1/artistmaps each neededforeignArtistIdto Lidarr's internalartist.id, then oneGET /api/v1/album?artistId=<id>per needed artist pulls just that artist's albums (small payloads). The in-memory artist-MBID→albums index and all matching/tie-break/strict-RGID logic are unchanged (each per-artist album still carriesartist.foreignArtistId). Per-run Lidarr calls drop from one 315 MB request to1 + (#distinct artists)small requests (~150 on the reported library). Artist-MBID lookups are deduped and cached for the run; an artist MBID Lidarr does not know is skipped (no album call) and still resolves toartist_not_in_lidarr. Every call still goes through the provider boundary (lidarr) and any provider failure (timeout, auth, rate limit, unavailable, malformed) still raises and aborts the run — never downgraded to "unresolved".
v0.13.0
Fixed
- Lidarr release-group-MBID resolver never guesses across distinct release groups (QA finding-7). Writing an
{release-group-mbid}into an album folder establishes canonical identity, so it must not be a guess. The resolver's tie-breakers (exact year, albumType rank, monitored, trackFileCount) previously picked one candidate even when the same-title candidates belonged to different release groups, baking a wrong MBID into the folder. The resolver now collapses matched candidates by distinctforeignAlbumId(release-group MBID) before tie-breaking: tie-breakers only choose which edition's album id to report among candidates that all share one release-group MBID (editions/releases of a single release group — the emitted MBID is identical whichever wins). If an artist+title(+year) match spans more than one distinct release-group MBID, the folder is held for review (ambiguous_multiple_albums) rather than resolved. This applies to every tier: Tier B (title+year) and Tier C (title-only) resolve only to a single distinct RGID and otherwise hold; Tier A (path-exact) with distinct RGIDs falls through to the title tiers rather than guessing. A match whose candidates carry a mix of one valid RGID and a missing one is also held. Provider failures still abort the run, the MBID is still taken verbatim fromforeignAlbumIdand_is_valid_mb_uuid-validated, and the four unresolved reasons and one-snapshot-per-run behaviour are unchanged. This only moves borderline multi-release-group matches from "resolved (guessed)" to "unresolved: ambiguous_multiple_albums"; it never loosens anything.
Added
- Folder MBID names — name every album folder
Album (Year) {release-group-mbid}from the Lidarr resolver, through the controlled mutation engine. NewPOST /api/library/folder-mbid-names/apply(dry-run by default;confirmed:truerequired to mutate; optionalbatch_limit,resume_from_job) andPOST /api/library/folder-mbid-names/rollback(byjob_idor explicitoperations). This is the correct replacement for the earlier strip-based placeholder apply, which wrongly removed the{...}token instead of filling the release-group MBID. One bulk Lidarr snapshot is taken per run (LidarrRgidResolver.from_lidarr); a provider failure aborts the run touching nothing and surfaces the provider error (never downgraded to "unresolved"). Per album folder: an already-correctly-named folder is an idempotent skip (already_correct); a disk-only folder is renamed throughfolder_cleanup_v1; a DB-tracked album is relocated through Beets (so its DB item paths follow) only when the DBmb_releasegroupidalready equals the resolver's, so a non-resolver MBID is never written; an unresolved folder, a target-path collision, a DB-rgid mismatch, or a multi-album folder is held for review with its reason. The MBID written to disk or to the DB template always comes verbatim from the resolver. Reuses the hardened placeholder-apply machinery (write-ahead checkpoint,batch_limitcounts only mutations, resume dedup, descendant-aware tracked detection,ok:falseon any error, running-job 409 guard, single-call rollback-by-job from the transaction audit records). Thereviewlist (unresolved folders, collisions, mismatches) is produced for a later manual-resolution UI; choosing/writingLIDARR_URLconfig is a deploy-time step.
v0.12.0
Fixed
- Recover My Uploads — rollback provenance now survives the plugin field allowlist (independent QA regression). The recovery import stamps a unique
webmanager_recover_token(andsource_platform) intoset_fields, but thewebmanagerBeets plugin'svalidate_fieldsallowlist (beetsplug/webmanager/schemas.py,ALLOWED_ITEM_FIELDS) dropped both fields, so the token never persisted: the post-import token read-back returned nothing and a recovery item that was actually imported falsely reportedimport_failedand recorded no rollback. Both provenance flexattrs are now in the allowlist, so they round-trip through the real Beets boundary. PluginPLUGIN_VERSIONis bumped1.18.4 → 1.18.5(PROTOCOL_VERSIONunchanged at1.0) — the embedded Beets must be restarted/recreated for the new allowlist to take effect (the rollout script restarts Beets when the plugin version changes). The post-import token lookup (_find_recovered_item_by_token) is also made fail-closed: a single returned row carrying a different non-empty token is rejected (returnsNone) rather than used as a fuzzy single-row fallback, so rollback can never bind to the wrong item's file. Signed-URL scrubbing of the authorization note/evidence (_scrub_authz_text) now strips signed/secretname=valuequery params (X-Amz-Signature,sig,token, …) regardless of URL form, including parenthesised URLs the previous URL regex stopped short of and scheme-relative//host/…URLs it never matched. The recover-import crash-resume inference is restricted to genuine resume statuses (Running / Recovery Required / Partially Rolled Back): on a Completed transaction a missing Beets row is now refused (recover_rollback_item_missing) instead of inferred to be a crash and proceeding toward the file unlink (the content-hash guard is unchanged). - Recover My Uploads — rollback is now operator-reachable and its provenance/containment hardened (independent review). The
recover_import_v1rollback is wired into the existingPOST /api/transactions/<id>/rollbackroute, so an authenticated operator can undo a recovery import from the UI (it dispatches toTransactionStore.rollback/rollback_recover_import, stays an explicit operator action, and is never automatic). Rollback provenance is now captured authoritatively: the import stamps a unique per-import token into the created item'sset_fieldsand looks the item up by that exact token, so two concurrent imports of the same track can never cross-record and delete the wrong item's file (previously a fuzzy post-import(artist, title)re-query). Rollback containment now requires the recorded path to be under the currentMUSIC_ROOTonly — a recordedmusic_rootthat differs (MUSIC_ROOT changed since import) is refused rather than trusted. Crash-resume is corrected so a crash between the Beets-row removal and the progress persist still resumes (the content hash still gates the unlink). The non-duplicate Beets-skip import outcome now carries theauthorizationrecord like every other outcome.own_accountURL verification namespaces a/user/<name>path segment by host, so an allowlistedyoutube.com/user/alicecan no longer verify a different platform's source whose bare uploader_id isalice(@handleandchannel_idbehaviour unchanged). Signed-URL query params (X-Amz-Signature=…etc.) embedded in the authorization note/evidence are stripped before persistence. - Recover My Uploads — a newly-imported recovery item can now be rolled back, safely and audited. A recovery singleton import (
_recover_import_one→beets_adapter.run_import,copy=True) previously created itstransaction_engine"Import" transaction withrollback_available=Falseand no operations, sotransactions.rollback(tx_id)raised "Rollback data has not been captured" — there was no way to undo a recovery import. The import now captures the exact, minimal undo at import time (through the auditedtransaction_enginestore): the single added Beets item id and the single file it created underMUSIC_ROOT, with that file's sha256 content hash + size and theMUSIC_ROOTin force, recorded as the transaction's oneremove_recovered_itemoperation withrollback_available=True(mutation familyrecover_import_v1). Capture is best-effort — if the copied file is not provably underMUSIC_ROOT, rollback stays unavailable rather than failing the import. Rolling back that transaction (rollback_recover_import, also dispatched byTransactionStore.rollback) removes only the recorded Beets item (throughbeets_adapter/the embedded engine — no raw sqlite) and the single recorded file, and only when all guards hold: the path is underMUSIC_ROOTand equals the recorded path (never a glob), with no symlinked component; the item id still maps to that path; and the file's current hash + size still match what was recorded at import (proof it is the file this import created, not something added/changed later). Any guard failure refuses the rollback and leaves the item and file in place; a hash/size mismatch (the recorded file was changed or replaced after import) is refused withrecover_rollback_tampered. It never deletes via a wildcard, never touches any item or file not recorded by this transaction, never touches the staged original underDOWNLOADS_ROOT, and never asks Beets to delete files (the recorded file is deleted under the engine's ownMUSIC_ROOT-containment guard, re-verified immediately before the unlink). The rollback transitions the transaction to a terminal Rolled Back status (or Recovery Required on an unexpected mid-rollback error) and logs the item id + path removed, with durable per-step progress so a crash between the DB-row removal and the file deletion resumes cleanly on retry. Authorization gate, DRM refusal, SSRF/host-allowlist guard, staged-root confinement, never-overwrite-on-import, and the identity gate are all unchanged. - Recover My Uploads — opus-in-.webm now imports. A YouTube
bestaudioupload stages as a.webm(opus) container, which passes authorization, integrity, and identity but which Beets/MediaFile adds no library item from ("beets import completed without adding item to library") even though the identical audio as.opusimports. The recovery import path (_recover_import_one) now, before callingbeets_adapter.run_import, detects an unimportable container by its actual ffprobe container (not just the extension;webm/matroska) and produces a lossless remux (ffmpeg -vn -map 0:a:0 -c:a copy) into the codec's native importable container (opus/vorbis →.opus/.ogg, aac/alac →.m4a); a high-quality transcode is used only as a fallback when a remux cannot yield an importable file (and is recorded as not-lossless). The converted copy is written underDOWNLOADS_ROOT/recovered(staged-root-confined, never overwriting, NUL/symlink/hard-link-safe name) and is verified before import (ffprobe-decodable, has an audio stream, nonzero size, decoded duration within a tight tolerance of the original); verification failure returns a cleanimport_failedwith a sanitized reason and never imports. The original staged file is preserved untouched and only the converted copy is imported. The conversion (original container, converted container, lossless-remux vs transcode) is recorded on the import outcome, the provenance sidecar, and the transaction metadata. Already-importable containers (.opus,.m4a,.mp3,.flac,.ogg) are imported unchanged. DRM refusal, authorization gate, SSRF/host-allowlist guard, identity gate, and never-overwrite rule are unchanged. OptionalAUDIO_FFMPEG_TIMEOUT(default 180s) bounds the conversion. - Recover My Uploads —
RECOVER_OWNED_ACCOUNTSnow reaches the container. The shipped Compose files (docker-compose.yml,docker-compose.full.yml) now forwardRECOVER_OWNED_ACCOUNTSto thebeets-web-managerservice'senvironment:(${RECOVER_OWNED_ACCOUNTS:-}, matching the other optional operator vars), so an operator who sets theown_accountallowlist in the stack.envactually has it take effect — previously it was documented and read at runtime but never plumbed into Compose, soown_accountverification silently stayed inert in the shipped deployment.
Added
- Recover My Uploads — authorization + provenance. Recover fetch and import now require an
authorizationobject{basis, note}on the request.basismust be one oforiginal_creation,own_account,licensed_permission, oropen_license; a missing or invalid basis is refused withauthorization basis required (...)(the probe/diagnostics routes stay open). The user's asserted claim is recorded separately from independently verified evidence on all three of the provenance sidecar, the recovery run record, and the import transaction metadata, asauthorization: {asserted_basis, asserted_note, verified, verified_basis, verified_evidence, platform, recorded_at}; an asserted claim is never copied into averified_*field. Two bases can be independently verified:own_account, when an operator-configuredRECOVER_OWNED_ACCOUNTSentry matches one of the source's immutable identifiers — yt-dlpchannel_id/uploader_id(exact, case-insensitive) orchannel_url/uploader_url(canonical-URL /@handle//channel/<id>//user/<name>path-segment match), captured on probe and fetch and persisted on the sidecar; andopen_license, when the source's own platformlicensemetadata (captured from yt-dlp on probe/fetch and persisted assource_license) matches an open-license pattern (creative commons,cc-by,cc0,public domain,reuse allowed) — the verification uses the platform's license, never the user's claim. The mutable displayuploader/channelname is never evidence, so a stranger's upload whose display ...
v0.11.0
Upgrade Notes
- Playlist auto-sync no longer runs by default. If you relied on the background playlist scheduler, it is now off after upgrade and manual/explicit syncs are unaffected. To restore automatic syncing, set
PLAYLIST_AUTO_SYNC=1(accepts1/true/yes/on); tune the cadence withPLAYLIST_AUTO_SYNC_INTERVAL(default300s, minimum60). No migration or other operator action is required.
Changed
- Playlist auto-sync is now opt-in (default OFF).
PLAYLIST_AUTO_SYNCdefaults to off; set it to1/true/yes/onto enable the background scheduler. Manual/explicit playlist sync is unchanged. When enabled, the scheduler no longer does a ~30 s first wake (the oldmin(30, interval)); it honorsPLAYLIST_AUTO_SYNC_INTERVAL(default300, minimum60) from the first run.
Fixed
- Library background disk reconcile now reaches an already-open page, and polling is bounded regardless of
library_versionchurn (follow-up to the fast-tree work below). When a cache is served past its TTL the backend returns it with bothreconciled:trueandreconciling:trueand refreshes it in the background; the Library view derived its "keep polling" state only fromreconciled === false, so after the TTL it showed stale disk state and the background refresh never reached the open page until the user re-navigated. It now keeps polling whilereconciling === trueas well, and picks up the refreshed tree in place. The poll budget is also no longer reset whenlibrary_versionchanges, so a backend that churned the version on every response can no longer poll forever — it terminates at the bounded stall/retry UI. - Cold Library fast tree now shows albums and stops the frontend polling forever (follow-up to the fast-tree work below). The cold-cache fast tree requested the album
yearfield, which is not in the plugin's album field allowlist, so the read returned HTTP 400 and every album was silently dropped (only singletons showed); album year is now derived from the item rows instead. The cold fast response also emitted a freshlibrary_versionon every call (it fell back totime.time()while the cache was cold), which reset the frontend's poll budget every 2 s; it now emits a stable version that only changes once reconciled data is available. The background disk reconcile is now scheduled only after the fast build succeeds, so an offline engine no longer queues a doomed reconcile job behind every failed page load. - Library view paints instantly and fills in disk state in the background. The Library screen now renders from the first (possibly
reconciled:false)/api/libraryresponse without a blocking spinner, shows a subtle non-blocking "checking disk" indicator while the backend reconciles disk state, and polls/api/librarywith a bounded backing-off cadence untilreconciledbecomes true (or thelibrary_versionchanges) to pick up disk-only and missing-on-disk items. Polling stops once reconciled or after a bounded budget (a stalled check offers a manual retry instead of spinning). The heavyinclude_tracks=1pull is still fetched lazily (on track search / album expand), not on first paint. - Library listing no longer blocks on a synchronous NFS disk walk.
GET /api/library(without alimit) used to build its artists/albums tree by walking the whole music tree on disk on the request thread whenever the shared cache was cold or stale — and the cache was only kept warm by the opt-in legacy local scan, so on deployments with that scan disabled every page load paid for the full walk (measured ~120s to time out on a 411-album NFS library). The request path now serves the cached disk-reconciled payload when warm, otherwise an engine-only fast tree built with no music-tree/NFS disk walk (it still reads the small local artist-image cache, never the music tree), and runs the disk reconciliation (files missing on disk shown red, plus disk-only/not-imported files) as a deduped backgroundjob_enginejob that warms the cache for later loads. The disk-missing/disk-only feature is preserved, just moved off the page-load critical path. The response carries areconciledflag (trueonce the served payload includes disk state).?refresh=1now schedules a background rebuild instead of blocking the request. In-process callers that need the full disk-reconciled tree synchronously (the acquisition-queue build job, which already runs in the background) passblock=Trueand keep the previous behavior. Cold-cache loads when the engine is offline return the existingENGINE_OFFLINE503 instead of a 500.
v0.10.0
Added
- Recover My Uploads — stream diagnostics, safe import, retries & UI (increment 2). The recovery path now, after fetch+stage, runs read-only stream/manifest/DRM diagnostics on each item (
inspect_stream_diagnostics: HLS/DASH/SABR/progressive classification, container/codec, and EME/key-system detection only), verifies audio integrity and identity (AcoustID + MusicBrainz, with a safe singleton fallback for genuinely-original uploads), and imports into the library throughbeets_adapter.run_importwithout ever overwriting an existing library item (staged-root-confined; paths outside staging are refused). Run state is persisted so failed/unrecovered URLs can be retried, and playlist coverage is refreshed after import. New endpointsGET /api/recover/runs,POST /api/recover/retry, andPOST /api/recover/importjoin the increment-1 probe/enumerate/fetch routes, and a new Recover My Uploads panel surfaces per-item recovery states (Available / Authentication required / Unsupported format / DRM-protected / Provider unavailable / Download failed / Recovered / Verified / Imported). ADRM-protectedstate is shown only on real EME/DRM evidence, never inferred from a failed download.sanitize_diagnostic_reportaccepts an external/laboratory streaming diagnostic (e.g. from the Streaming Security Lab's opt-in exporter), strips every secret field (content keys, KIDs, PSSH/init-data, licenses, cookies, tokens, headers) by recursive allowlisting, and unconditionally reportsdecryption_supported=false/widevine_decryptable=false— a ClearKey laboratory observation never implies commercial Widevine decryptability, and no DRM is circumvented.