Skip to content

Releases: Iranman/beets-web-manager

v0.14.0

Choose a tag to compare

@github-actions github-actions released this 27 Oct 14:43
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 folde...
Read more

v0.13.5

Choose a tag to compare

@github-actions github-actions released this 23 Oct 03:02
ebfaa46

Performance

  • The folder-cleanup DB-reference scan no longer resolves realpath(MUSIC_ROOT) once per Beets item. composite_workflows.norm_library_path runs once per item during a full-library scan that repeats on every rename; on a ~5,000-folder live run that was a realpath syscall storm that could make the run crawl or look hung. The root's realpath is 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 is realpath-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_stale used os.path.lexists, which reports a dangling symlink (link present, target gone) as present, so the relocation ran and Beets skipped every track (a rollback-able partial_move). It now uses os.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 with PATH_IS_TRACKED when 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 raw os.path.dirname — no realpath rewrite and no relative-path resolution. On a symlinked/realpath-diverging MUSIC_ROOT (and after an earlier placeholder-strip left the DB naming the pre-rename path) that map reported db_item_count=0 for a folder the engine still considered tracked, so the apply picked the disk-only folder_rename and 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 shared composite_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 / Beets Album.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, idempotent already_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-DB needs_db_resync hold).

v0.13.4

Choose a tag to compare

@github-actions github-actions released this 23 Oct 00:07
b15d22a

Changed

  • A refused cross-origin redirect from a configured service URL now names the redacted redirect target. SafeRedirectHandler already refuses a redirect that leaves a configured service's own origin (the implicit trust pins scheme+host+port); its OutboundPolicyError now 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_url correct, would_trust_rule valid) — i.e. not the empty-resolver case and, per direct instrumentation, not a thread/contextvar-inheritance case (the trusted rule IS visible to secure_urlopen on 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 logs type(exc) and the raw policy message (fixed sentences + redacted origins, no secret), so the next deploy names the exact redirect target (e.g. http -> https on the same host:port, or a different port). Still fires only on ProviderOutcome.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

Choose a tag to compare

@github-actions github-actions released this 22 Oct 23:11
b846580

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_job creates — 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_import built a brand-new _ai_batch_initial_state, which carries no revision. Its first _ai_batch_commit therefore 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_resolver now 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 recomputed implicit_rule_for_url(configured_url) (would_trust_rule), the live _TRUSTED_RULE, and the id()s of the backend.app_runtime / backend.security / backend.provider_boundary module 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 the backend.app_runtime constant 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 fresh import 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's 192.168.0.250:8686, ProviderOutcome.REJECTED, "blocked by outbound URL policy") was rejected. configured_url now 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 through provider_boundary.opened end to end, including on a worker thread (the job engine runs jobs on threads), plus the resolver-fallthrough guard.

v0.13.2

Choose a tag to compare

@github-actions github-actions released this 22 Oct 21:51
5dbb085

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 CodeQL py/path-injection alerts. 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 single JobStore._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; the path.parent == root check 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_URL is 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") whenever LIDARR_URL resolved 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 calls provider_boundary.register_configured_url("lidarr", ...) at import time, reading the same live backend.app_runtime.LIDARR_URL the resolver uses (env, then the LIDARR_URL_FILE fallback 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), and JobStore read and parsed every file synchronously at startup (_recover globbed and json.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 stayed unhealthy the whole time — which looked like a hang and triggered an unnecessary deploy rollback. Startup now reads only the most recent JOB_STARTUP_LOAD_LIMIT records (default 200, 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 than JOB_RETENTION_DAYS (default 7) 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_required records and running jobs are never auto-pruned, and the lazy id lookup rejects path-traversal input. New env knobs JOB_STARTUP_LOAD_LIMIT and JOB_RETENTION_DAYS (see docs/CONFIGURATION.md / .env.example); 0 or less for the load limit restores the old read-everything behaviour.

Added

  • Regression guard: POST /api/library/folder-mbid-names/apply starts 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 under MUSIC_ROOT and 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 without confirmed:true → 400) is checked without touching the walk/resolver. Test-only; the handler already delegated the walk/resolver to the job via jobs.start_python, and this locks that structure against regression.

v0.13.1

Choose a tag to compare

@github-actions github-actions released this 22 Oct 18:32
8cac02c

Security

  • Frontend next bumped 16.4.0 → 16.4.2 to clear a CRITICAL RCE and a HIGH DoS advisory flagged by the security frontend-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_lidarr now takes the set of artist MBIDs actually present on disk (collected from the selected album folders) and fetches only those: one GET /api/v1/artist maps each needed foreignArtistId to Lidarr's internal artist.id, then one GET /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 carries artist.foreignArtistId). Per-run Lidarr calls drop from one 315 MB request to 1 + (#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 to artist_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

Choose a tag to compare

@github-actions github-actions released this 22 Oct 16:52
8f144fd

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 distinct foreignAlbumId (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 from foreignAlbumId and _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. New POST /api/library/folder-mbid-names/apply (dry-run by default; confirmed:true required to mutate; optional batch_limit, resume_from_job) and POST /api/library/folder-mbid-names/rollback (by job_id or explicit operations). 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 through folder_cleanup_v1; a DB-tracked album is relocated through Beets (so its DB item paths follow) only when the DB mb_releasegroupid already 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_limit counts only mutations, resume dedup, descendant-aware tracked detection, ok:false on any error, running-job 409 guard, single-call rollback-by-job from the transaction audit records). The review list (unresolved folders, collisions, mismatches) is produced for a later manual-resolution UI; choosing/writing LIDARR_URL config is a deploy-time step.

v0.12.0

Choose a tag to compare

@github-actions github-actions released this 22 Oct 05:32
883907d

Fixed

  • Recover My Uploads — rollback provenance now survives the plugin field allowlist (independent QA regression). The recovery import stamps a unique webmanager_recover_token (and source_platform) into set_fields, but the webmanager Beets plugin's validate_fields allowlist (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 reported import_failed and recorded no rollback. Both provenance flexattrs are now in the allowlist, so they round-trip through the real Beets boundary. Plugin PLUGIN_VERSION is bumped 1.18.4 → 1.18.5 (PROTOCOL_VERSION unchanged at 1.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 (returns None) 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/secret name=value query 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_v1 rollback is wired into the existing POST /api/transactions/<id>/rollback route, so an authenticated operator can undo a recovery import from the UI (it dispatches to TransactionStore.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's set_fields and 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 current MUSIC_ROOT only — a recorded music_root that 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 the authorization record like every other outcome. own_account URL verification namespaces a /user/<name> path segment by host, so an allowlisted youtube.com/user/alice can no longer verify a different platform's source whose bare uploader_id is alice (@handle and channel_id behaviour 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 its transaction_engine "Import" transaction with rollback_available=False and no operations, so transactions.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 audited transaction_engine store): the single added Beets item id and the single file it created under MUSIC_ROOT, with that file's sha256 content hash + size and the MUSIC_ROOT in force, recorded as the transaction's one remove_recovered_item operation with rollback_available=True (mutation family recover_import_v1). Capture is best-effort — if the copied file is not provably under MUSIC_ROOT, rollback stays unavailable rather than failing the import. Rolling back that transaction (rollback_recover_import, also dispatched by TransactionStore.rollback) removes only the recorded Beets item (through beets_adapter/the embedded engine — no raw sqlite) and the single recorded file, and only when all guards hold: the path is under MUSIC_ROOT and 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 with recover_rollback_tampered. It never deletes via a wildcard, never touches any item or file not recorded by this transaction, never touches the staged original under DOWNLOADS_ROOT, and never asks Beets to delete files (the recorded file is deleted under the engine's own MUSIC_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 bestaudio upload 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 .opus imports. The recovery import path (_recover_import_one) now, before calling beets_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 under DOWNLOADS_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 clean import_failed with 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. Optional AUDIO_FFMPEG_TIMEOUT (default 180s) bounds the conversion.
  • Recover My Uploads — RECOVER_OWNED_ACCOUNTS now reaches the container. The shipped Compose files (docker-compose.yml, docker-compose.full.yml) now forward RECOVER_OWNED_ACCOUNTS to the beets-web-manager service's environment: (${RECOVER_OWNED_ACCOUNTS:-}, matching the other optional operator vars), so an operator who sets the own_account allowlist in the stack .env actually has it take effect — previously it was documented and read at runtime but never plumbed into Compose, so own_account verification silently stayed inert in the shipped deployment.

Added

  • Recover My Uploads — authorization + provenance. Recover fetch and import now require an authorization object {basis, note} on the request. basis must be one of original_creation, own_account, licensed_permission, or open_license; a missing or invalid basis is refused with authorization 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, as authorization: {asserted_basis, asserted_note, verified, verified_basis, verified_evidence, platform, recorded_at}; an asserted claim is never copied into a verified_* field. Two bases can be independently verified: own_account, when an operator-configured RECOVER_OWNED_ACCOUNTS entry matches one of the source's immutable identifiers — yt-dlp channel_id/uploader_id (exact, case-insensitive) or channel_url/uploader_url (canonical-URL / @handle / /channel/<id> / /user/<name> path-segment match), captured on probe and fetch and persisted on the sidecar; and open_license, when the source's own platform license metadata (captured from yt-dlp on probe/fetch and persisted as source_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 display uploader/channel name is never evidence, so a stranger's upload whose display ...
Read more

v0.11.0

Choose a tag to compare

@github-actions github-actions released this 21 Oct 21:17
52d39b2

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 (accepts 1/true/yes/on); tune the cadence with PLAYLIST_AUTO_SYNC_INTERVAL (default 300 s, minimum 60). No migration or other operator action is required.

Changed

  • Playlist auto-sync is now opt-in (default OFF). PLAYLIST_AUTO_SYNC defaults to off; set it to 1/true/yes/on to enable the background scheduler. Manual/explicit playlist sync is unchanged. When enabled, the scheduler no longer does a ~30 s first wake (the old min(30, interval)); it honors PLAYLIST_AUTO_SYNC_INTERVAL (default 300, minimum 60) from the first run.

Fixed

  • Library background disk reconcile now reaches an already-open page, and polling is bounded regardless of library_version churn (follow-up to the fast-tree work below). When a cache is served past its TTL the backend returns it with both reconciled:true and reconciling:true and refreshes it in the background; the Library view derived its "keep polling" state only from reconciled === 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 while reconciling === true as well, and picks up the refreshed tree in place. The poll budget is also no longer reset when library_version changes, 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 year field, 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 fresh library_version on every call (it fell back to time.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/library response without a blocking spinner, shows a subtle non-blocking "checking disk" indicator while the backend reconciles disk state, and polls /api/library with a bounded backing-off cadence until reconciled becomes true (or the library_version changes) 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 heavy include_tracks=1 pull 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 a limit) 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 background job_engine job 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 a reconciled flag (true once the served payload includes disk state). ?refresh=1 now 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) pass block=True and keep the previous behavior. Cold-cache loads when the engine is offline return the existing ENGINE_OFFLINE 503 instead of a 500.

v0.10.0

Choose a tag to compare

@github-actions github-actions released this 21 Oct 16:05
2747028

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 through beets_adapter.run_import without 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 endpoints GET /api/recover/runs, POST /api/recover/retry, and POST /api/recover/import join 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). A DRM-protected state is shown only on real EME/DRM evidence, never inferred from a failed download. sanitize_diagnostic_report accepts 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 reports decryption_supported=false / widevine_decryptable=false — a ClearKey laboratory observation never implies commercial Widevine decryptability, and no DRM is circumvented.