Skip to content

v0.16.0

Choose a tag to compare

@github-actions github-actions released this 03 Nov 19:40
· 54 commits to main since this release
ace13eb
  • Clean All's job body in routes_maintenance.py is split into named steps, pinned by golden traces recorded from the old code; no behavior change (#505).

Fixed

  • An AI key is only sent to its own provider (#523). With only OPENROUTER_API_KEY set, AI requests and the setup probe sent that key to api.openai.com; with AI_BASE_URL on OpenRouter, OPENAI_API_KEY was sent ahead of OPENROUTER_API_KEY. Now a blank AI_BASE_URL with only OPENROUTER_API_KEY calls https://openrouter.ai/api/v1, OPENROUTER_API_KEY never goes to another host, and a custom host gets AI_API_KEY. OPENAI_API_KEY on a non-OpenAI AI_BASE_URL with none of that URL's keys set still works, as the first-run wizard saved it that way, and logs a one-time warning naming the variable to use; the wizard now saves the key under the provider's own variable, and a blank Base URL is tested and saved as https://api.openai.com/v1. A key typed into the AI test with no URL is tested against AI_BASE_URL, or OpenAI when that is blank. The setup status counts AI as configured only when a key belongs to AI_BASE_URL, and GET /api/setup/env reports the OpenRouter URL for an OpenRouter-only setup. A running AI job keeps sending to the URL its key was read for, even if AI_BASE_URL is saved meanwhile. An AI_BASE_URL that cannot be parsed gets no key, and the setup status and AI test no longer fail on it or put its text in a log. See docs/CONFIGURATION.md.
  • Clean All no longer stalls for up to 600 seconds on artist folders that need an MBID stamp. The apply call passed acceptance_failpoint and timeout, arguments left over from the removed HTTP engine, so every call raised TypeError. That error was logged as a lost response and a never-applied transaction was polled until the deadline. The call now matches the function, and an unexpected error fails at once as an internal error.
  • Artist folder stamping and scan merge are now marked unavailable. They have done nothing since the old engine was removed and are not rebuilt here. Clean All finishes, marks Artist Folder Merge as skipped when folders need stamping, and shows the reason. The Stamp and Merge buttons on the Artist Folders panel stay visible but disabled with the same reason; Scan still works. Calling the routes directly ends in a failed job: a refused merge now reports zero counts, and the import auto-merge no longer implies a merge happened. No folders change in any of these cases.
  • An artist folder operation with an empty stored plan is refused at apply. A Clean All checkpoint saved by an older version could otherwise have completed having done nothing; resume now reports it and re-scans.

Testing

  • Playwright end-to-end suite (#505). frontend/e2e runs the real app against a synthetic library in a temp directory, with non-loopback network refused and providers stubbed. It covers the library overview, browsing, import and review, jobs, Clean All, playlists, settings, plugins, deep links and the missing-build 503 page. It runs in the new e2e workflow. Run it only in a throwaway container or CI runner: locally that needs Docker, and the harness refuses any host where it could touch real data (see frontend/e2e/README.md). No production code changed.

Changed

  • Backend consolidation with no API change (#505). Library job-start routes share one helper (_start_job in routes_library.py); _resolve_setting_item in routes_setup.py is one metadata table and one item builder instead of nine hand-written 22-key dictionaries; Beets error translation lives in backend/serializers.py (beets_offline_response, beets_auth_failed_response, beets_engine_error_response); the MusicBrainz-coverage and album-tracks payloads moved from their route handlers into backend/library_service.py. Some log lines for Beets failures now carry the exception class name instead of a per-class sentence.

  • Import jobs split into named steps (#505). start_reimport_disk and start_folder_import_with_id now hand their work to separately testable steps in backend/import_service.py. Behavior is unchanged: golden traces recorded from the old code still match.

  • Playlist downloads: split the 600-line job body in start_playlist_download into named steps (source resolution, checkpoint import, download rounds, final sync). No behavior change; golden traces of 25 pipeline scenarios (resume, partial completion, review holds, cancel) were recorded before the split and still match. Playlist list and detail payloads now share one builder per shape, with identical keys and values (#505).

  • Duplicate scan and AI duplicate review: split the 516-line start_dedup_scan and the 430-line dedup_ai_review into named steps (library index, match strategies, fingerprint verification, AI batches). No behavior change; golden traces of 186 scenarios were recorded before the split and still match (#505).

  • Album downloads: split the 1,300-line start_album_download and its job body into named steps (request parsing, recovery identity, release resolution, slskd candidates, direct-source fallback, import, Review hand-off). No behavior change; golden traces of 134 scenarios were recorded before the split and still match (#505).

  • Every environment variable the code reads is now known and documented (#505). backend/config_layers.py has a new environment layer for advanced tunables and legacy names, and docs/CONFIGURATION.md lists them; tests/test_env_contract.py fails when code, layer and docs drift. Nothing that was not saveable became saveable, and layer labels in the Settings API are unchanged. GET /api/setup/env changes as follows, and these are the only payload changes. Each default now matches what the code uses, or is on a justified exemption list in tests/test_env_contract.py (AI_MODEL, for one, matches the setup probe, while matching picks its fallback per call). This also changes value and effective_value when nothing is set: AI_MODEL no longer shows an OpenRouter model when only OPENROUTER_API_KEY is set, because the code does not use it (AI_BASE_URL in that case shows https://openrouter.ai/api/v1, the URL the code calls; see Fixed, #523); BEETS_LONG_OPERATION_MAX_SECONDS 600 (was 1800), BEETS_LONG_OPERATION_POLL_SECONDS 5 (was 2), BEETS_OUTBOUND_TIMEOUT_SECONDS 20 (was 30), PLAYLIST_AUTO_SYNC 0 (was 1), PLAYLIST_DOWNLOAD_METHODS slskd,ytdlp,soundcloud (was with spotiflac), LIDARR_URL http://lidarr:8686 (was localhost), and PLEX_URL, QBITTORRENT_URL and QBITTORRENT_USERNAME blank (were example values reported as active). IMPORT_REVIEW_QUARANTINE_DIR shows no default because the code has no single one (was /config/quarantine, a path nothing used), and its container_path is gone. BEETS_VERSION_PROBE_TIMEOUT_SECONDS is retired: nothing read it, it is no longer listed, and a saved value is removed at boot. The three BEETS_OUTBOUND_* limits, both BEETS_LONG_OPERATION_* settings, SPOTIFLAC_SERVICES, DISCOGS_TOKEN, SLSKD_API_KEY and BEETS_ARTIST_RECONCILE_TIMEOUT_SECONDS now report apply: restart with restart_required: true (were live). A canonical row now also sees a set legacy name, so QBITTORRENT_URL shows a Docker QBIT_URL with source environment, and saving that row's shortened value back writes nothing. A *_URL setting no longer shows its query string or fragment, and saving that shortened value back keeps the stored one, so saving the shortened URL does not remove a credential: clear the setting first, then save the URL. Legacy names of secrets are masked (see Fixed). Whitespace handling changes slightly: a whitespace-only PLEX_MUSIC_SECTION, PLEX_BEETS_MUSIC_ROOT or YTDLP_COOKIES_FROM_BROWSER no longer hides a set legacy name; a whitespace-only PLEX_SECTION_KEY or BEETS_MUSIC_ROOT is now ignored instead of used as the value; and a whitespace-only DISCOGS_USER_TOKEN no longer makes setup status report a Discogs token as configured.

Removed

  • Unused helpers with no caller in routes, jobs or scripts: _write_playlist_import_beets_config, _playlist_release_group_albumartist, _plex_delete_playlist_by_title, plex_sync_heal_job, _worse_name_status (#505).

Upgrade Notes

  • Deprecated variable names (still working). QBIT_URL/QB_URL, QBIT_USER/QB_USER, QBIT_PASS/QB_PASS, QBIT_CATEGORY, QBIT_FILTER, BEETS_WEB_TOKEN, DISCOGS_USER_TOKEN, ACOUSTID_KEY, PLEX_SECTION_KEY, BEETS_MUSIC_ROOT and YTDLP_COOKIES_BROWSER keep working when the canonical name is blank, and now log a deprecation warning once (name only, never the value). Nothing needs changing today; docs/CONFIGURATION.md lists the canonical names.
  • Outbound limits above a ceiling stop applying. If BEETS_OUTBOUND_TIMEOUT_SECONDS (300), BEETS_OUTBOUND_MAX_RESPONSE_BYTES (200 MiB), BEETS_OUTBOUND_MAX_REDIRECTS (20) or BEETS_OUTBOUND_TOTAL_TIMEOUT_SECONDS (600) is above its ceiling, saved or in the Docker environment, the app now logs a warning and uses the default instead of your value. Lower the value to the ceiling or below to keep a non-default limit.
  • AI keys are matched to their provider (#523). An OPENAI_API_KEY used with a non-OpenAI AI_BASE_URL (as older first-run wizards saved it) still works and logs a one-time warning naming the variable to use instead. Move the key to that provider's own variable (OPENROUTER_API_KEY or AI_API_KEY) to silence it. See Fixed.
  • Removed: the legacy single-file fallback UI and its serving fallback. Root index.html (~366 KB of inline legacy UI, served only when the built frontend in frontend/dist was absent) is deleted, and app.py no longer tries anywhere else: the SPA comes from frontend/dist alone, which is also all the image ever shipped. While the build exists, /, deep-link refreshes, /assets/… and /_next/static/… behave exactly as before. When the build is missing or empty, the UI routes now answer 503 with a small inline text/html page ("The web frontend build is missing. Rebuild or reinstall the application." — no application chrome or external assets, no-store), and an unknown /api/* path that falls through to the SPA catch-all answers 503 {"ok": false, "error": "frontend_missing", "message": …} (application/json) instead of a bare 404 — so a broken or absent frontend build is visible instead of silently serving an old UI. Real /api/* endpoints (liveness, health, setup, jobs, …) keep answering without the build; /api/health, /health/live, /health/ready and /health are unchanged, and with the build present unknown /api/* paths keep their ordinary 404. Tests: tests/test_serving_frontend.py (serving behavior with and without the build), and the surviving playlist-platform CodeQL coverage now tests the live frontend/src/views/Playlists.tsx (tests/test_frontend_hostname_parsing.py); the protections that existed only inside the deleted legacy file (folder-browse inline onclick containment, manual-MBID href validation) expire with it.

Fixed

  • Saved outbound limits now work. BEETS_OUTBOUND_TIMEOUT_SECONDS, BEETS_OUTBOUND_MAX_RESPONSE_BYTES and BEETS_OUTBOUND_MAX_REDIRECTS saved in Settings were shown as active but did nothing, because backend/security.py read them before saved settings loaded. They now apply at the next start, so a value saved earlier starts taking effect after this upgrade. An invalid value logs a warning and uses the default. These limits and BEETS_OUTBOUND_TOTAL_TIMEOUT_SECONDS now have ceilings, whether the value is saved or set in the Docker environment, so a value cannot remove the protection they give: 300 s for the timeout, 200 MiB for the response size, 20 redirects and 600 s for the total timeout. A value above its ceiling logs a warning by name and uses the default. BEETS_OUTBOUND_TOTAL_TIMEOUT_SECONDS below 0.1 s now also uses the default.
  • QBIT_PASS and QB_PASS are masked in /api/setup/env. A value for either name in the saved settings file came back in plaintext from GET and POST /api/setup/env, because the name matched no secret pattern. Secrecy now also follows the name's canonical variable. These names still cannot be saved from the API.
  • Diagnostic text redacts more secret forms. Redaction now covers secret variable names and legacy aliases, the key names pass, passwd, pwd and passphrase, quoted keys such as "password":"x", {'qb_pass': 'x'} and "Authorization":"Bearer x". Coverage is not complete: under an exact key such as password, token or secret, a value containing a space, comma or semicolon (password='x y', password=x,y) is still redacted only up to that character, as it was before (#524). An unquoted value with a space, such as QB_PASS=a b, is also redacted only up to the space. pass and pwd match only as a whole word or suffix, so bypass is untouched, but a line such as pass: 3 or PWD=/config is now redacted too. The plugin report on /api/setup/status also redacts its categories lists, which repeat the same notes and errors and were returned unredacted without login, and GET /api/setup/plugins, POST /api/setup/plugins/verify and their /api/plugins/* routes now redact their reports the same way.
  • A credential in a *_URL query string is masked in GET /api/setup/env. A saved AI_BASE_URL with ?api_key=... came back in plaintext.
  • Reading a transaction no longer rewrites it. GET /api/transactions and GET /api/transactions/<id> synced every job into its transaction on each read, so one read could rewrite hundreds of audit records and time out on a large job history. Job state now reaches a transaction when any job naming it ends, including route-claimed applies and rollbacks. A background pass after startup settles Running transactions whose job a restart interrupted, and retries a write that failed. A Running transaction's read shows its job's live log, or an outcome not yet written, from memory without writing. A job that finished before a later rollback's claim can no longer settle that transaction, except in a very short window while a metadata apply is finishing (see docs/TECHNICAL_DEBT.md). A re-sync that changes nothing writes nothing, and notes the transaction recorded itself are no longer overwritten by the job's log. A Running transaction with no job record is left as it is and logged. Transaction records gain a running_since field; the response format is otherwise unchanged.
  • The "Stamp MB IDs on artist folders" job now reports failure. A refused plan, an unavailable engine or a failed apply used to finish as a successful job with zero changes. A failure before anything was planned now fails the job; a failed apply fails it and keeps the operation id in the result. A plan that returned no operation no longer raises KeyError.
  • Placeholder folder rename: a folder Beets refuses as tracked is held for relocation. It used to fail the whole job, although nothing had changed; the MBID rename already held it. The refusal also no longer uses up the batch limit.
  • Album deduplicate: an apply that raises before claiming its approved cleanup now cancels it instead of leaving it Approved, as the other cleanup routes already did.
  • Transaction records and other state files are fsynced before they replace the old copy, and concurrent writers no longer share one temp file.

Changed

  • Consolidated repeated logic with no API change: atomic JSON state saves (backend/json_state.py), the placeholder and MBID folder rename loops (_run_folder_rename_batch), the stamp route and Clean All's artist-folder step, and album deduplicate and the reviewed-cleanup approval (backend/dedup_service.py). backend/dedup_authorization.py is unchanged.
  • Removed _maintenance_safe_folder_renames, which nothing but its own test called.

Removed

  • Backend dead-code sweep (modernization W2). Removed backend symbols that were provably unreachable in the running product: the legacy playlist staging-manifest stratum in playlist_service (_playlist_staging_*/_legacy_manifest_*); the artist-alias and delete-SQL serialization helpers in library_service; the fake beet-CLI shim around album genre repair (_lastgenre_cmd/_require_beet_ok/_log_beet_output_excerpt/_beet_output) so start_library_fix_genres and POST /api/albums/<id>/fix-genre call composite_workflows.repair_album_genre directly (HTTP paths, payloads and per-album WARN/continue semantics unchanged — the only visible delta is that genre job logs no longer print the shim's console-output excerpt, which was never the repair result); the independent backend/mb_alignment.py module, folded away — summarize_mb_track_alignment and its helpers moved into backend/matching/track_alignment.py (output verified identical) and the zero-caller greedy_album_track_alignment was deleted; the _acoustid_lookup compatibility wrapper and the unused fetch_mb_release_tracklist in helpers_mb.py (the live AcoustID service path is untouched); and a final single-symbol stratum across dedup_service, cleanup_service, ai_service, acquisition_service, artwork_service, plex_service, slskd_service, musicbrainz_service, ytdlp_service, setup_service, app_runtime, security and composite_workflows (e.g. _maintenance_duplicate_report, _prune_empty_review_dirs, _track_ai_match_status, _audio_files_under, _album_art_ext_for_bytes, _plex_section_track_lookup, _slskd_file_matches_wanted, _sqlite_write_retry, _pip_install, clean_empty_albums, discover_import_sources). Every deletion was AST reachability-checked against the current source before removal, tests that only pinned a removed wrapper were retargeted to the live entry point or deleted with it, and no route, request/response payload or job-outcome semantic changed (the genre job-log excerpt noted above is the only worker-visible delta).
  • Dead frontend surface removed. Four unused panels (views/Clean.tsx, DiscographyPanel, WantedPanel with its WantedPanel.test.tsx, and the never-rendered TorrentRepairPanel) and the now-orphaned client layer around them: 28 api/client.ts functions (the singleton-rescue trio, the qBittorrent status/hardlink pair, artist discography/release-art/completeness, duplicate-resolver plan/apply, Lidarr wanted and artist-albums-by-name, startMoveAll, libraryImportAll/getLibraryImportAllLast/retryLibraryImportAllFailed, per-album art status/url/upload/delete/move-to-library, getGenreStats/fixAlbumGenre, repairAlbumMbTracks/fixAlbumMetadata, batchSuggestAlbums, reconcileAutoEnqueueImport, getAlbumMbFormat), their api/types.ts response clusters, five dead lib/libraryHealth.ts helpers and the unused downloadMethodShortLabel. Live equivalents (Find Music/AcquisitionPanel, the /clean → /jobs redirect, runLidarrAlbumSearch, per-album artwork fetch, fixGenres, downloadMethodShortLabel's data field) are untouched. Tests that only stubbed removed functions keep their live confirm-gate assertions.

Changed

  • Identity suggestion orchestration consolidated (modernization M4, #505). POST /api/items/<id>/ai-suggest keeps its URL, slot limit and JSON contract, but its orchestration moved out of routes_library.py into backend/item_ai_suggest_service.py, with evidence gathering, AI ranking, identity validation and response shaping as separate steps. A golden test (tests/test_item_ai_suggest_contract.py, 22 scenarios) pins the full response and the AI request body. Its first 18 scenarios were recorded against the previous handler, 4 were added after the extraction, and the golden was then updated where this change differs on purpose: the AI handling described under Fixed. The AI, MusicBrainz and enrichment steps are stubbed in it, so a separate test runs the real enrichment. The album and folder AI paths now share _ai_bind_selected_release instead of two identical copies of the candidate-to-Release/Release Group binding, and helpers_mb.py parses release labels, media and cover art in one place instead of separately for search and lookup. Behavior differences: a release whose media has a missing or invalid track-count, or a non-object entry, now has an unknown track count (0) instead of raising or summing the valid mediums, so it can no longer pass as an exact track-count match and, when a track count is wanted, ranks below valid editions within one release group, whatever its distance or status, in release search, release-group resolution and the alternative-release order during import. The album and folder candidate scorer gives it a track-count component score no higher than any valid edition, so artist and album similarity can still put it first. Unchanged: with no local track count, an unknown count gets no penalty, and release search still cuts its list to the limit across release groups, so a group whose best edition has an unknown count can keep its place above a valid group. Also changed: non-string label, catalog-number or format values in release search are coerced to text instead of raising, and a non-list label-info in release lookup is ignored. On the item, album and folder suggestions, an AI endpoint that cannot be used (for example an invalid AI_BASE_URL) now falls back to the MusicBrainz/AcoustID evidence with the reason shown, instead of raising (HTTP 500 on the item route). Which AI replies fall back is listed under Fixed. Other than the AI handling described under Fixed, no identity or review rule changed.
  • Frontend infrastructure consolidated (modernization M6). The two API clients share request-header preparation, fetch-failure text and HTTP failure classification; 155 inline error-text expressions use lib/errors.ts; repeated timestamp formatting moved to lib/dates.ts; two simple pollers use useInterval; unused duplicate API types were removed. Displayed text and API calls are unchanged.

Fixed

  • AI replies can no longer supply identity or safety fields on POST /api/items/<id>/ai-suggest. Only the fields the prompt asks for are read. A reply with a wrong type, an oversized text field, a confidence other than high, medium or low, or no usable field (mb_albumid and mb_artistid alone are not usable) counts as AI being unavailable: the response falls back to the MusicBrainz/AcoustID evidence with ok: true and shows the reason, including for a non-string ID, which used to return ok: false. AI mb_albumid and mb_artistid are never used; only MusicBrainz supplies them, along with mb_releasegroupid, selected_recording_candidate, requires_confirmation and safety_result. A recording ID outside the candidates is always low confidence with a warning, even with no candidates. The decision and decision_version shown are now the single deterministic evaluation that attach-recording rebuilds, where a second pass could previously show a different decision under an old version, with or without AI. A candidate whose evaluation failed reads as requires_confirmation: true and mb_trusted: false. Attach never relied on these fields: it rebuilds the candidates on the server and refuses any other ID. In the album and folder suggestions, a non-object reply or a non-string albumartist now also falls back to the evidence (other reply fields are not type-checked on these paths), a candidate_index that is not an integer selects nothing, and AI mb_artistid, requires_confirmation, safety_result and selected_release are dropped. In Import All discovery, a malformed AI reply or an unusable AI endpoint now follows the same fallback as an AI outage instead of going straight to Import Review, so its top candidate can proceed at medium confidence. A candidate that fails identity validation (identity_validated: false) now always goes to Import Review, whether it came from the fallback or from an AI reply; before, this step never checked that result.
  • Library actions reach the right Jobs panel. "Track Cleanup" and "Merge split album" linked to /clean?tab=…, whose redirect dropped the query and left the user at the top of Jobs. They now open Advanced Maintenance and scroll to Album Track Repair, or open and scroll to Database Health, and old /clean?tab=… links keep working.