Repository navigation
v0.16.0
- Clean All's job body in
routes_maintenance.pyis 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_KEYset, AI requests and the setup probe sent that key toapi.openai.com; withAI_BASE_URLon OpenRouter,OPENAI_API_KEYwas sent ahead ofOPENROUTER_API_KEY. Now a blankAI_BASE_URLwith onlyOPENROUTER_API_KEYcallshttps://openrouter.ai/api/v1,OPENROUTER_API_KEYnever goes to another host, and a custom host getsAI_API_KEY.OPENAI_API_KEYon a non-OpenAIAI_BASE_URLwith 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 ashttps://api.openai.com/v1. A key typed into the AI test with no URL is tested againstAI_BASE_URL, or OpenAI when that is blank. The setup status counts AI as configured only when a key belongs toAI_BASE_URL, andGET /api/setup/envreports the OpenRouter URL for an OpenRouter-only setup. A running AI job keeps sending to the URL its key was read for, even ifAI_BASE_URLis saved meanwhile. AnAI_BASE_URLthat 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. Seedocs/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_failpointandtimeout, arguments left over from the removed HTTP engine, so every call raisedTypeError. 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/e2eruns 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 newe2eworkflow. 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 (seefrontend/e2e/README.md). No production code changed.
Changed
-
Backend consolidation with no API change (#505). Library job-start routes share one helper (
_start_jobinroutes_library.py);_resolve_setting_iteminroutes_setup.pyis one metadata table and one item builder instead of nine hand-written 22-key dictionaries; Beets error translation lives inbackend/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 intobackend/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_diskandstart_folder_import_with_idnow hand their work to separately testable steps inbackend/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_downloadinto 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_scanand the 430-linededup_ai_reviewinto 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_downloadand 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.pyhas a newenvironmentlayer for advanced tunables and legacy names, anddocs/CONFIGURATION.mdlists them;tests/test_env_contract.pyfails 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/envchanges as follows, and these are the only payload changes. Eachdefaultnow matches what the code uses, or is on a justified exemption list intests/test_env_contract.py(AI_MODEL, for one, matches the setup probe, while matching picks its fallback per call). This also changesvalueandeffective_valuewhen nothing is set:AI_MODELno longer shows an OpenRouter model when onlyOPENROUTER_API_KEYis set, because the code does not use it (AI_BASE_URLin that case showshttps://openrouter.ai/api/v1, the URL the code calls; see Fixed, #523);BEETS_LONG_OPERATION_MAX_SECONDS600 (was 1800),BEETS_LONG_OPERATION_POLL_SECONDS5 (was 2),BEETS_OUTBOUND_TIMEOUT_SECONDS20 (was 30),PLAYLIST_AUTO_SYNC0 (was 1),PLAYLIST_DOWNLOAD_METHODSslskd,ytdlp,soundcloud(was withspotiflac),LIDARR_URLhttp://lidarr:8686(was localhost), andPLEX_URL,QBITTORRENT_URLandQBITTORRENT_USERNAMEblank (were example values reported as active).IMPORT_REVIEW_QUARANTINE_DIRshows no default because the code has no single one (was/config/quarantine, a path nothing used), and itscontainer_pathis gone.BEETS_VERSION_PROBE_TIMEOUT_SECONDSis retired: nothing read it, it is no longer listed, and a saved value is removed at boot. The threeBEETS_OUTBOUND_*limits, bothBEETS_LONG_OPERATION_*settings,SPOTIFLAC_SERVICES,DISCOGS_TOKEN,SLSKD_API_KEYandBEETS_ARTIST_RECONCILE_TIMEOUT_SECONDSnow reportapply: restartwithrestart_required: true(were live). A canonical row now also sees a set legacy name, soQBITTORRENT_URLshows a DockerQBIT_URLwith sourceenvironment, and saving that row's shortened value back writes nothing. A*_URLsetting 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-onlyPLEX_MUSIC_SECTION,PLEX_BEETS_MUSIC_ROOTorYTDLP_COOKIES_FROM_BROWSERno longer hides a set legacy name; a whitespace-onlyPLEX_SECTION_KEYorBEETS_MUSIC_ROOTis now ignored instead of used as the value; and a whitespace-onlyDISCOGS_USER_TOKENno 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_ROOTandYTDLP_COOKIES_BROWSERkeep 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.mdlists 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) orBEETS_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_KEYused with a non-OpenAIAI_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_KEYorAI_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 infrontend/distwas absent) is deleted, andapp.pyno longer tries anywhere else: the SPA comes fromfrontend/distalone, 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 answer503with a small inlinetext/htmlpage ("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 answers503 {"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/readyand/healthare 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 livefrontend/src/views/Playlists.tsx(tests/test_frontend_hostname_parsing.py); the protections that existed only inside the deleted legacy file (folder-browse inlineonclickcontainment, manual-MBIDhrefvalidation) expire with it.
Fixed
- Saved outbound limits now work.
BEETS_OUTBOUND_TIMEOUT_SECONDS,BEETS_OUTBOUND_MAX_RESPONSE_BYTESandBEETS_OUTBOUND_MAX_REDIRECTSsaved in Settings were shown as active but did nothing, becausebackend/security.pyread 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 andBEETS_OUTBOUND_TOTAL_TIMEOUT_SECONDSnow 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_SECONDSbelow 0.1 s now also uses the default. QBIT_PASSandQB_PASSare masked in/api/setup/env. A value for either name in the saved settings file came back in plaintext fromGETandPOST /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,pwdandpassphrase, quoted keys such as"password":"x",{'qb_pass': 'x'}and"Authorization":"Bearer x". Coverage is not complete: under an exact key such aspassword,tokenorsecret, 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 asQB_PASS=a b, is also redacted only up to the space.passandpwdmatch only as a whole word or suffix, sobypassis untouched, but a line such aspass: 3orPWD=/configis now redacted too. The plugin report on/api/setup/statusalso redacts itscategorieslists, which repeat the same notes and errors and were returned unredacted without login, andGET /api/setup/plugins,POST /api/setup/plugins/verifyand their/api/plugins/*routes now redact their reports the same way. - A credential in a
*_URLquery string is masked inGET /api/setup/env. A savedAI_BASE_URLwith?api_key=...came back in plaintext. - Reading a transaction no longer rewrites it.
GET /api/transactionsandGET /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 (seedocs/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 arunning_sincefield; 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.pyis 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 inlibrary_service; the fake beet-CLI shim around album genre repair (_lastgenre_cmd/_require_beet_ok/_log_beet_output_excerpt/_beet_output) sostart_library_fix_genresandPOST /api/albums/<id>/fix-genrecallcomposite_workflows.repair_album_genredirectly (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 independentbackend/mb_alignment.pymodule, folded away —summarize_mb_track_alignmentand its helpers moved intobackend/matching/track_alignment.py(output verified identical) and the zero-callergreedy_album_track_alignmentwas deleted; the_acoustid_lookupcompatibility wrapper and the unusedfetch_mb_release_tracklistinhelpers_mb.py(the live AcoustID service path is untouched); and a final single-symbol stratum acrossdedup_service,cleanup_service,ai_service,acquisition_service,artwork_service,plex_service,slskd_service,musicbrainz_service,ytdlp_service,setup_service,app_runtime,securityandcomposite_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,WantedPanelwith itsWantedPanel.test.tsx, and the never-renderedTorrentRepairPanel) and the now-orphaned client layer around them: 28api/client.tsfunctions (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), theirapi/types.tsresponse clusters, five deadlib/libraryHealth.tshelpers and the unuseddownloadMethodShortLabel. Live equivalents (Find Music/AcquisitionPanel, the/clean→/jobsredirect,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-suggestkeeps its URL, slot limit and JSON contract, but its orchestration moved out ofroutes_library.pyintobackend/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_releaseinstead of two identical copies of the candidate-to-Release/Release Group binding, andhelpers_mb.pyparses release labels, media and cover art in one place instead of separately for search and lookup. Behavior differences: a release whosemediahas a missing or invalidtrack-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-listlabel-infoin release lookup is ignored. On the item, album and folder suggestions, an AI endpoint that cannot be used (for example an invalidAI_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 tolib/dates.ts; two simple pollers useuseInterval; 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_albumidandmb_artistidalone are not usable) counts as AI being unavailable: the response falls back to the MusicBrainz/AcoustID evidence withok: trueand shows the reason, including for a non-string ID, which used to returnok: false. AImb_albumidandmb_artistidare never used; only MusicBrainz supplies them, along withmb_releasegroupid,selected_recording_candidate,requires_confirmationandsafety_result. A recording ID outside the candidates is alwayslowconfidence with a warning, even with no candidates. The decision anddecision_versionshown 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 asrequires_confirmation: trueandmb_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-stringalbumartistnow also falls back to the evidence (other reply fields are not type-checked on these paths), acandidate_indexthat is not an integer selects nothing, and AImb_artistid,requires_confirmation,safety_resultandselected_releaseare 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 atmediumconfidence. 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.