#949 moved the "token still valid -> return True" short-circuit in TidalClient.is_authenticated()
into the boot-phase branch ONLY, so after boot every call fell through to the silent refresh
regardless of whether the token was actually expired. With multiple workers polling Tidal every
few seconds, that produced a constant "access token expired -> refresh -> success" loop (wolf39us
logs) — needless token churn, not an actual auth problem.
Restored the valid-token short-circuit on the post-boot path. The download client is unaffected
(it defers the tidalapi session differently, no manual expiry loop).
3 regression tests: valid token post-boot does NOT refresh, expired token still does, valid token
during boot returns True without probing.
Bumps base version 2.8.1 → 2.8.2 and the docker-publish default tag. A stability + performance
release: Spotify reliability (Docker boot-hang #949, the token-cache re-auth fix, on-demand
Sync-to-Spotify), the "slow after update" password-manager fix + Max Performance mode (#948), and
large-library imports that no longer time out the import page (#947).
Updates the five release touch-points: web_server version, docker-publish default, pr_description.md,
helper.js WHATS_NEW + VERSION_MODAL_SECTIONS (current release + Earlier-in-2.8.1 summary), and the
new RELEASE_2.8.2_discord.md (truncated for Discord, 1351 chars).
Re-review caught a real bug in the phase-2 polling: it refetched only the files query, but the
album import tab uses its OWN groups query (album-import-tab.tsx). So after a large-folder scan
completed, the album tab would stay stuck on its initial {scanning} response and never populate
(the singles tab was fine — it reads the polled files query). Switched the scan poll to
invalidateImportStagingQueries (files + groups + suggestions) so every mounted staging query
refetches when the scan finishes. (Suggestions is already async/cached, so it just no-ops.)
typecheck clean, scanning test still passes.
Frontend half of the async staging scan. The endpoints now return {scanning, progress} while a
large staging folder is still being scanned in the background; the page surfaces that and fills in
automatically when it completes — no manual refresh, no timeout.
- types: ImportStagingFiles/GroupsPayload gain optional scanning + progress (additive).
- useImportStaging exposes `scanning`/`scanProgress` and, while scanning, polls via a plain
setInterval(refetch, 1500). Deliberately NOT react-query's refetchInterval — and a plain interval
that only runs while scanning leaves the normal + error query states completely untouched.
- the header shows "Scanning N of M files…" instead of a count while the scan runs.
vitest: new test asserts the scan-progress header renders from a {scanning} response; typecheck
clean. Note: -route.test.tsx's pre-existing "staging files fail to load" test fails only in the
full-file run (passes in isolation) — verified it also fails on clean dev with all my changes
stashed, so it's a pre-existing test-isolation flake, not from this change.
A whole-library migration (ramonskie copied his Lidarr library into /staging) makes the synchronous
staging scan walk + tag-read tens of thousands of files INSIDE the GET request, blowing past
gunicorn's 120s timeout — and because the killed request never warms the cache, every reload
re-times-out. Moves the SAME scan off the request thread; the page reports progress instead of
hanging.
- _scan_staging_records gains an optional `progress` param (additive; default None = unchanged).
Refactored to two passes: a fast walk to collect the audio-file list (total), then the slow
tag-read loop updating scanned. A generation guard stops a scan that finishes AFTER an import
from committing stale records.
- ensure_background_staging_scan(path): idempotent background runner filling the existing cache.
- get_staging_records_or_status(): warm cache or a scan that finishes within a ~3s grace → records
(so small/normal folders still answer in one request, no UX change); else ("scanning", progress).
A scan error is re-raised so the endpoints log + return it exactly as before.
- /staging/files|groups|hints return {scanning, progress} when the scan is still running instead of
blocking; new lightweight /staging/scan-status for cheap progress polling.
Single source preserved (same scan + cache, just off the request thread). 13 new tests (progress,
idempotent ensure, grace ready-vs-scanning, generation guard discards stale, endpoint scanning
shape, error contract, status ready/cold); full import suite 626 green; ruff clean.
Next: phase 2 — the React import page polls scan-status + shows a progress bar, then renders.
Introduce a boot-phase guard so gunicorn worker import never blocks on Spotify, Qobuz, Deezer, or Tidal network validation. Network auth checks run only after module initialization completes.
Avoid blocking Spotify auth probes during gunicorn worker import and add a request timeout to the auth probe client so unreachable API calls cannot stall startup indefinitely.
Co-authored-by: Cursor <cursoragent@cursor.com>
Double-checking the on-demand auth flow: the needs_auth handler called window.open() AFTER an
await, which breaks the user-gesture chain so browsers popup-block it — the user would see "approve
in the new tab" with no tab. Replaced with a clickable authorize link (a direct click is never
blocked).
Adds two endpoint tests via the Flask test client: Spotify export returns needs_auth + the
/auth/spotify/export url (and short-circuits before the DB) when the token lacks write scope, and
does NOT short-circuit when write scope is present. 10 service-export tests green, 64 script-integrity
green, ruff clean.
Brings back Spotify playlist export WITHOUT the regression that forced every user to re-auth.
The safety property: the global login scope (SPOTIFY_OAUTH_SCOPE) is NEVER changed, so no
existing token is invalidated. The write permission is requested only when a user actually
exports to Spotify.
- SPOTIFY_EXPORT_SCOPE = the global read scope + playlist-modify, used ONLY by the new
/auth/spotify/export route. Spotify returns a superset token; the normal /callback exchanges
and stores it unchanged (read ⊆ read+write keeps the standard auth check valid) — no callback
changes needed.
- SpotifyClient.has_write_scope() checks the cached token for playlist-modify.
- start_playlist_export_service returns {needs_auth, auth_url} for Spotify when the token lacks
write, instead of starting a doomed job. The modal opens the consent in a new tab and tells the
user to retry once approved; the "Sync to Spotify" button is back, gated on connection as before.
- Release notes (pr_description / What's New / version modal / discord) restored to Spotify &
Deezer with the one-time-permission note; discord back under 2000 chars (1983).
Tests: export scope is a strict superset of the (still read-only) global scope; has_write_scope
true/false for write/readonly/missing tokens and no-client. 275 spotify/oauth tests green, ruff
clean, 64 script-integrity green.
Follow-up to the auth hotfix (633aa82b). The Spotify playlist-write scope was reverted out of the
global OAuth scope (it was force-invalidating every user's token on upgrade), so "Sync to Spotify"
can't get write access yet — clicking it would dead-end on a misleading "reconnect Spotify". So:
- removed the "Sync to Spotify" button from the export modal (Deezer stays); the backend write
client + endpoint are left in place, dormant, for when on-demand write-auth lands
- modal copy is now Deezer-only ("Match missing tracks (Deezer)", "stored Deezer ID")
- release notes (pr_description, helper.js WHATS_NEW + version modal, RELEASE_2.8.1_discord.md)
reworded from "Spotify & Deezer" to "Deezer", with a "Spotify export coming in a follow-up" note
64 script-integrity tests green; discord file back under the 2000-char limit (1952); no stale
Sync-to-Spotify mentions remain. Deezer export (live-verified) is unaffected.
Two compounding bugs broke Spotify auth for every user on the nightly (reported by wolf39us):
1. TRIGGER (regression from #945 increment 2): adding playlist-modify-* to the global
SPOTIFY_OAUTH_SCOPE invalidated every existing token. Spotipy's validate_token treats a cached
token as invalid the moment the requested scope stops being a subset of the token's granted
scope, so growing the scope forced a re-auth on upgrade ("token refresh may have failed").
Reverted: the write scope is OUT of the global scope; Spotify export must request it on-demand
(incremental auth) instead of breaking everyone on upgrade.
2. LATENT bug the trigger exposed: both global OAuth callbacks wrote the freshly-exchanged token to
the legacy FILE cache (config/.spotify_cache) while the client reads DatabaseTokenCache (the DB
store added for the earlier "unauthenticating daily" fix), which only imports the file when the
DB is empty. So a re-auth's new token never reached the client → "token exchange succeeded but
authentication validation failed", and re-auth was a dead end. Both callbacks now write
DatabaseTokenCache — the same store the client reads.
The scope revert alone re-validates existing tokens (no re-auth needed); the cache fix makes any
future re-auth actually take effect.
Tests: scope must not contain playlist-modify (the forced-re-auth guard) + the read scopes stay;
global callbacks must use DatabaseTokenCache, not the file. 271 spotify/oauth tests green, ruff clean.
NOTE: with the write scope gone, "Sync to Spotify" export can't get write access yet — needs a
follow-up on-demand grant. Deezer export is unaffected.
The settings info icons are role="button" spans with a text "i" glyph but no
cursor/user-select, so hovering the glyph gave the I-beam text caret on Windows
(Linux happened to resolve a pointer). Add cursor:pointer + user-select:none so
it reads as a button on every platform.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Max Performance only neutralized animation/blur/shadow globally but didn't
replicate the reduce-effects-specific display:none rules, so with reduce-
effects OFF the sidebar aura orbs (.sidebar::before/::after) survived as two
hard static circles, the dash-card cursor-glow layers stayed, and nav-button
hover kept the expensive treatment. Depended on whether reduce-effects was on
before enabling Max Performance. Extend those three rule blocks to also match
body.max-performance — flash-free since the body class is server-rendered.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Expose window.__pmSuppress.disable()/enable() on the suppression IIFE so a
before/after benchmark can reproduce the pre-fix "before" state (managers
re-attach their autofill overlay) and restore it, without a rebuild. The
app itself never calls these; suppression stays on by default.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two CPU regressions surfaced in software-rendered / no-GPU containers
(Docker), where transform/opacity and canvas radial-gradient fills
rasterize on the CPU instead of a compositor:
1. Worker-orbs canvas + decorative motion saturate a core and freeze the
UI. A new opt-in "Max Performance" mode is the nuclear low-power switch:
body.max-performance CSS kills blur/shadow/filter AND all
animation/transition (spinners go static), and JS halts every canvas
loop (orbs, particles, cursor-glow, API sparks) via window._maxPerfActive.
Reduce Visual Effects is now decoupled from the orbs — they follow their
own toggle; only Max Performance force-kills them. While Max Performance
is on, the Orbs/Particles/Reduce-Effects checkboxes lock + grey out, and
save reads the runtime flags so prefs aren't clobbered.
2. Password managers (Bitwarden et al.) rebuild their autofill overlay on
every DOM mutation; a captured trace showed Bitwarden using ~6x the CPU
of the whole app (~400 setupOverlayOnField/sec). suppressPasswordManager-
Autofill() tags non-credential inputs with data-bwignore / data-1p-ignore
/ data-lpignore / data-form-type=other so the managers skip them; real
login/PIN fields are left alone.
Wired through: web_server.py (_initial_appearance_context), index.html
(inline flag + body class + checkbox), init.js (applyMaxPerformance +
bootstrap + listener + autofill suppression), settings.js (load/save),
worker-orbs.js / particles.js / api-monitor.js (gates), style.css.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bumps base version 2.8.0 → 2.8.1 and the docker-publish default tag. Headline is the
Spotify/Deezer playlist export (#945); also the Library Reorganize rename-only mode (#875),
broader lossless handling (#941/#939), download + search fixes, the refined reduce-visual-effects
pass, and merged contributor PRs (#942/#943/#944).
Updates the five release touch-points: web_server version, docker-publish default, pr_description.md,
helper.js WHATS_NEW + VERSION_MODAL_SECTIONS (current release + Earlier-in-2.8.0 summary), and the
new RELEASE_2.8.1_discord.md.
Double-checking the backfill logic found a real correctness bug. Spotify search_tracks defaults to
allow_fallback=True, so when Spotify is rate-limited or in free mode it returns iTunes/Deezer tracks
whose .id is an iTunes/Deezer id, NOT a Spotify id. The backfill took that .id as a Spotify track id
and would push wrong/garbage tracks into the exported Spotify playlist. The unit tests used fake
Track objects with hand-set ids, so they could never surface this cross-service contamination.
Fix: the Spotify backfill search now passes allow_fallback=False — real Spotify hits or nothing
(an unmatched track is left out, never replaced by a non-Spotify id). Deezer is unaffected: its
search fallback is query-only and stays within Deezer, so its ids are always Deezer ids.
Regression test asserts the Spotify backfill search is invoked with allow_fallback=False. 8
orchestration tests green, ruff clean.
The export modal now checks connection on open via /api/discover/your-albums/sources (cheap
token/ARL check, no live verify) and greys out + relabels any service that is not connected
("Not connected — set up X in Settings → Connections first"). Clicking a gated button nudges to
Settings instead of starting an export that would just fail with "not connected". The fetch runs
after the modal renders, so a slow/failed check never blocks the modal (buttons stay usable).
Pairs with the existing scope-403 handling: a Spotify token without playlist-modify still shows as
connected (it IS), and the writer returns the clear "Reconnect Spotify to grant playlist write
access" message — so "not connected at all" and "connected but needs reconnect for write" are both
covered. Static file, no rebuild.
Adds the third resolver stage for tracks the discovery cache + library can't resolve — a live
search of the target service, gated behind a "Match missing tracks" toggle so the API cost is opt-in.
The whole point is coverage WITHOUT the wrong-track risk, so it's a CONFIDENT match, not "search
and grab":
- search_service_track_id(artist, title, search_fn): searches the service, reranks via the existing
relevance scorer (filter_and_rerank), and returns the top hit's id ONLY if it clears
BACKFILL_MIN_SCORE (1.2 on the score_track scale). A wrong-artist hit (no 1.5x exact-artist boost,
caps ~1.0) or a karaoke/cover (x0.05) can't clear the floor → None, and the track is left out
rather than added wrong. search_fn injected → unit-testable without a live service.
- resolve_service_track_ids gains an optional search_id_fn: cache → library → search. Tallies
from_search separately.
- _run_service_export builds the search fn from the service's metadata search client only when
job['backfill'] is set; the endpoint reads `backfill` from the body; the modal adds the toggle and
the status line shows "(N matched live)".
Store-back of confident matches deferred: a mirrored-only track may have no library row to write to,
so persisting needs the track→library mapping — a follow-up, not correctness.
9 new tests incl. the safety ones: wrong-artist rejected, karaoke/cover rejected, real-over-cover
picked, fail-safe on search error, and the cache→library→search waterfall + toggle wiring (on/off).
28 export/orchestration tests green, 64 script-integrity green, ruff clean.
Boulder: "all 50 tracks are discovered to Deezer already — it's not using any of that." Right —
the export only checked tracks.deezer_id (library) and ignored the IDs discovery already resolved
and stored in each mirrored track's extra_data. So tracks that were discovered+downloaded but not
separately enriched showed as "not on Deezer" and got dropped.
Adds a per-track waterfall for service export:
- service_id_from_extra_data(track, service): the id discovery already matched, read from
extra_data.matched_data.id — FREE (no API call) and reliable (it's the same id used to mirror
the track). Trusted only when discovered ON the target service (provider == service); a
wing_it_fallback (low-confidence guess) does NOT match here, so it falls through rather than
risk a wrong track in the export.
- resolve_service_track_ids(tracks, service): cache (extra_data) → library stored id → unmatched.
Reports from_cache / from_library / unmatched. _run_service_export now uses this instead of the
artist/title MBID-style resolver.
For Boulder's playlist this means all 50 resolve straight from the cache — full coverage, zero API
calls. (A live confident-search backfill for the genuinely-missing remainder is the optional next
step, gated + thresholded.)
9 new tests: extra_data id only when provider matches + wing_it excluded + bad-json/not-discovered
guards, the cache→library→unmatched waterfall with stat tallies, and _run_service_export resolving
straight from the cache end-to-end. 49 export tests green, ruff clean.
The broad except->None in db_service_track_id would mask a column/join typo as "no match"
for every track (so export would silently say "nothing to export"). Adds a test that builds
a real tracks/artists sqlite schema and runs the ACTUAL query — confirms it returns the right
spotify_track_id/deezer_id column, case-insensitively, and None on no-match. Closes the one
integration gap the mocked unit tests left open.
Adds "Sync to Spotify" and "Sync to Deezer" buttons to the mirrored-playlist export modal
(#pl-export-modal), alongside the existing ListenBrainz/JSPF options. They POST to the new
/export/service/<service> endpoint; the shared poller now reports service progress
("Pushing to Spotify…"), the matched/unmatched count, and links the created playlist on
done. ListenBrainz/JSPF paths unchanged. Static file, no rebuild.
This completes the #945 vertical: resolver → Spotify+Deezer write clients → export job +
endpoint → modal. Reverse-sync to a service is a clean export (uses the stored per-track
service IDs from enrichment), distinct from true bidirectional sync.
Ties the resolver + write clients into a working backend, reusing the ListenBrainz export's
resolve→push→store-target shape:
- _run_service_export(job, db, playlist_id, title, service, client, resolve_fn): resolves the
mirrored playlist's tracks to their stored service track IDs (id_key='service_track_id'),
guards "nothing matched", pushes via the injected write client, and stores the returned
playlist id as the export target so a re-export updates in place (idempotent, like LB #903).
Deps injected → unit-testable without a DB or live service.
- _run_playlist_export dispatches mode in {spotify, deezer} to it (builds the real client +
service resolver); the existing download/push (ListenBrainz/JSPF) flow is untouched.
- POST /api/playlists/<id>/export/service/<service> — distinct path so it can't collide with
the existing /export/listenbrainz route; validates the target, starts the background job,
returns {job_id} polled via the shared status endpoint.
5 orchestration tests (fake db/client/resolve_fn): success stores target + passes ids in order,
no-match → error with no push, client None → not-connected error, push failure surfaces the
client's error and stores nothing, re-export passes the existing target id. ruff clean.
Last piece: the modal options (Sync to Spotify / Deezer, gated on auth, unmatched count surfaced).
The Deezer write half of "export a mirrored playlist back to Deezer". Rides the private
gw-light gateway with the ARL session already authenticated for downloads (Deezer shut their
public developer API, so this is the only write path — unofficial and fragile by nature).
DeezerDownloadClient.create_or_update_playlist(name, track_ids, existing_id=None): playlist.create
with the songs (new), or playlist.addSongs to the stored target (re-export). track_ids are the
stored deezer_id per library track. Returns the same {success, playlist_id, url, added, error}
shape as the Spotify writer so the export job can treat both uniformly.
5 tests (mocked gateway): create sends playlist.create with positional songs, update sends
playlist.addSongs (no create), empty → error with no gw call, not-authed → error, gw rejection →
error. ruff clean. Additive — download paths untouched.
Both write clients done. Next: the export-job branch + endpoint (reusing get/set_playlist_export_
target for idempotency), then the modal options.
For exporting a mirrored playlist back to Spotify:
- The OAuth scope string was duplicated verbatim in 5 places (spotify_client, the per-profile
registry, and 3 web_server callbacks) — a drift hazard where the authorize URL and token
exchange could request different scopes and silently re-prompt/deny. Extracted ONE
SPOTIFY_OAUTH_SCOPE constant and pointed all 5 at it, and added playlist-modify-public/private
there. Existing users re-auth once to grant write; reads are unaffected.
- SpotifyClient.create_or_update_playlist(name, track_ids, existing_id=None): creates a playlist
owned by the authed user, or replaces an existing one's tracks in place (idempotent re-export).
Chunks at Spotify's 100-track cap. A pre-scope token gets a clear "reconnect Spotify" message
instead of a raw 403. Returns {success, playlist_id, url, added, error}.
6 tests: create-new adds tracks, update replaces (no create), >100 chunking, empty → error (no API
calls), not-authed → error, insufficient-scope → reconnect message. 268 spotify/oauth tests green,
ruff clean. Additive — read paths and existing tokens unchanged.
Next: Deezer write via the ARL gw-light gateway, then the export-job branch + endpoint + modal.
First piece of "export a mirrored playlist to Spotify/Deezer" (diegocade1). Reuses the exact
machinery the ListenBrainz/JSPF export already proves out, additively:
- resolve_playlist_tracks gains an `id_key` param (default "recording_mbid" → LB/JSPF callers
byte-for-byte unchanged). The dedup/stats/order logic is ID-agnostic; only the output field
name differs, so service export plugs in with id_key="service_track_id".
- export_sources gains db_service_track_id(artist, title, service) + build_service_resolve_fn —
text-matches a library track (same pattern as the MBID resolver) and returns its stored
spotify_track_id / deezer_id. Enrichment already pinned those IDs, so export is a lookup, not a
re-search — which is what makes the reverse direction reliable (no fuzzy guessing).
No schema change needed: get/set_playlist_export_target already key by service name, so Spotify/
Deezer targets store for free (idempotent re-export, like the LB #903 fix).
7 tests: LB default id_key unchanged, service id_key carries the id + unmatched handling, service→
column mapping, unknown-service/no-title guards, resolve_fn id+source. 38 export tests green,
ruff clean.
Remaining increments: Spotify write client (+ playlist-modify scope / re-auth), Deezer write via
the ARL gw-light gateway, the export-job branch + endpoint, the modal options.
The Wing It pool "Fix Match" search returned "no results" for everything (even obvious
tracks). Root cause: /api/spotify/search_tracks built a Spotify field-filtered query
(track:X artist:Y) and handed it to spotify_client.search_tracks, which falls back to the
user's configured source when official Spotify isn't serving the request. The fallback
(Deezer here) got the raw Spotify `track:…artist:…` syntax it can't parse and aborted the
connection (RemoteDisconnected) — so the user's perfectly working Deezer failed ONLY on
this path, on this query format. The iTunes and Deezer search endpoints already dropped
field syntax for exactly this reason; the Spotify one was the lone holdout.
fix:
- new pure helper relevance.build_combined_search_query(track, artist, legacy) — plain,
source-agnostic query; documents WHY field syntax is wrong here. the endpoint already
reranks by expected title/artist, so precision is recovered without the brittle syntax.
- the Spotify endpoint uses it (now consistent with iTunes/Deezer).
- frontend (searchPoolFix): surface the real error (auth / 500 / upstream abort) instead
of masking everything as "No results found" — which is what made this undiagnosable.
5 helper tests incl. the regression (output must contain no 'track:'/'artist:' syntax).
654 metadata/search tests green, 64 script-integrity green, ruff clean.
Adds an "Action" selector to the reorganize modal — "Full reorganize (default)" vs
"Rename only (skip post-processing)" — with a hint explaining rename-only skips
re-tagging/quality/AcoustID, only touches files whose name changes, and that renaming
can reset media-server play counts / date-added. executeReorganize sends rename_only in
the apply POST. Default is full → existing behaviour unchanged. Static file, no rebuild.
Threads the rename_only flag from the apply endpoint to the executor, additively (default
False everywhere → existing full-flow behaviour byte-for-byte unchanged):
- /api/library/album/<id>/reorganize-files reads `rename_only` from the body → enqueue.
- QueueItem gains rename_only (+ surfaced in to_dict for the status panel).
- reorganize_runner.build_runner takes build_final_path_fn and branches: a rename_only item
routes to reorganize_album_rename_only (no staging dir, no copy, no post-process); everything
else falls through to the full reorganize_album. Staging is only created for the full path now.
- web_server injects build_final_path_fn (= _build_final_path_for_track, the same builder the
preview uses) so apply matches the preview exactly.
Fixed a test landmine: _make_item returns a MagicMock, whose .rename_only is a truthy mock that
wrongly took the new branch — set it to False to match the real QueueItem default. +2 runner tests
(rename_only routes to the rename executor + creates no staging; missing path-builder → clean
setup_failed). 209 reorganize tests green, ruff clean.
Left: the modal (Full vs Rename-only) + optional post-rename server scan + the issue reply.
#875 (tsoulard/Tacobell444): the reorganize job runs the FULL download post-processing on every
track — copy to staging, re-tag, quality + AcoustID checks, then move. So it fails on the same
checks as downloads, is slow (a full copy per file on a NAS, not a rename), and re-touches EVERY
file even when only its name changes (Tacobell's "2 of 14 previewed but all 14 modified").
This adds the rename-only path users actually want for "just fix the filenames": move each file to
the path the current naming scheme dictates and nothing else — no copy, no re-tag, no checks. The
tags are already correct; only the on-disk filename/folder layout changes (their hardware DAP sorts
by filename).
Design (additive — the full flow is byte-for-byte untouched):
- preview_album_reorganize gains current_path_abs / new_path_abs (additive fields; existing
trimmed display paths unchanged) so the executor acts on EXACTLY what the preview computed —
apply can never disagree with what the user saw.
- reorganize_album_rename_only: consumes the preview (injected preview_fn for testability), and for
each track that's matched + actually changing + non-colliding, renames in place and updates the
SoulSync DB directly (authoritative — we just did the move, no need to round-trip a server scan).
unchanged tracks are SKIPPED — that's the fix for "every file got modified".
- _rename_track_in_place: os.rename with a cross-device (EXDEV) fallback to shutil.move, creates
the dest dir, carries sibling-format files (.flac+.opus) along, and refuses to overwrite a
different existing file (never silent data loss).
11 new tests incl. the headline regression (changed → moved + DB updated, unchanged → untouched),
collision/unmatched skip, overwrite-refusal, sibling carry, cross-device, stop, cleanup. 207
reorganize/library tests green, ruff clean. Endpoint flag + modal + post-rename server scan next.
Pairs with the previous commit (orbs now run under reduce-effects). When the user has asked
for performance (reduce-effects on) we don't need the orbs at 60fps — the slow drift and sparks
are indistinguishable at 30, and dropping every other render roughly halves the per-frame canvas
cost, keeping the "orbs under reduce-effects" experiment cheap.
The canvas still ticks at 60fps and frameCount still increments every tick, so `time` stays
real-time and the drift speed is unchanged — we just draw it half as often. Precedence: the
existing fully-asleep ~20fps throttle still wins; the 30fps cap only applies awake + reduce-effects.
Chrome users with full effects keep 60fps — no reason to dim them.
Reduce-effects used to force-kill the worker orbs (isEnabled() had && !_reduceEffectsActive),
which also made the orb toggle a dead setting whenever reduce-effects was on.
The assumption was "the orbs ARE the expensive thing." On inspection that looks wrong: the
dashboard orb glow is drawn with canvas radial gradients, not a CSS blur(28px). The genuinely
expensive blur is the SIDEBAR aura orbs + frosted glass (CSS filters), which reduce-effects
still kills via filter:none regardless. So the orb canvas's per-frame cost should be moderate,
not the blur-rasterize lag.
So decouple them: the worker-orbs toggle controls the orbs on its own; reduce-effects keeps
killing the expensive CSS rendering but no longer gates the orbs. This also fixes the dead-toggle
conflict (the orb toggle now works under reduce-effects instead of being silently overridden).
Empirical: try it and watch the dashboard CPU. If the orb canvas under reduce-effects pushes it
back up, revert is one token — re-add `&& !window._reduceEffectsActive` to isEnabled().
The blurred 60fps worker-orb canvas is the main remaining Firefox lag source after the
#935 sweep (multiple Discord lag reports). So for a FIRST-TIME user with no saved
preference, default the orbs OFF on Firefox (smooth first impression where it's needed)
and ON everywhere else (full polish where the browser handles it). An explicit saved
choice ALWAYS wins — this only picks the default when the user hasn't chosen.
Done kettui-style with a SINGLE source of truth, not the dual browser-detection I first
floated (server UA + client _isFirefox would be the same fact in two places that can
drift — exactly the server/client class #943's green-flash fix just cleaned up):
- core/ui_appearance.py (new, pure + importable): is_firefox_user_agent +
resolve_worker_orbs_default(explicit, is_firefox) — explicit wins, unset → !firefox.
- web_server: the SERVER decides (UA via _request_is_firefox, request-context-safe) and
injects initial_worker_orbs_enabled; config default flipped None so "unset" is
distinguishable from an explicit False. The client just consumes the injected value
(init.js unchanged) — no client-side re-derivation of "is Firefox".
- settings.js: the orb checkbox default now reflects the server value when unset, so
saving Settings can't silently flip a first-time Firefox user's orbs back on.
No regression: Chrome users unchanged; users with an explicit setting unchanged (it
wins regardless of browser); /api/settings returns raw config so it can't clobber the
default for an unset value. Verified end-to-end through a real Flask request context
(Firefox→off, Chrome→on, explicit wins both ways, no crash outside a request). 8 pure
seam tests pin the contract; ruff clean.
"Reduce visual effects" was a sledgehammer: body.reduce-effects * forced
animation:none + transition-duration:0s on every element. That froze every CSS loading
spinner mid-rotation — including the dash-header worker-service spinners (musicbrainz /
spotify / deezer / … .active .<svc>-spinner) — which read as BROKEN rather than "off".
It also killed cheap hover feedback like the Quick Actions buttons.
The actual lag (esp. Firefox, see the #935 sweep) is backdrop-filter / box-shadow /
filter re-rasterizing every frame — NOT the animations themselves. Transform- and
opacity-only motion (the spinners) composites for ~free.
So: keep forcing the expensive properties to none (unchanged — that's the real fix),
but drop the blanket animation/transition kills. !important author declarations outrank
animation + transition declarations in the cascade, so any keyframe/transition that
tries to set blur/shadow/filter is still neutralized even while it runs — the spinner
spins, just without the glow. Net: functional spinners stay alive, Quick Actions hover
(transform + border-colour) returns, box-shadow transitions are no-ops (shadow forced
none), and the GPU-heavy rendering that caused the lag stays gone. The worker-orb CANVAS
is unaffected (JS-gated separately) and stays off under reduce-effects, as intended.
Static guard test pins the contract: the global rule must keep the expensive-property
kills and must NOT reintroduce blanket animation:none / transition:0s.
Discord (Shdjfgatdif, standalone): some downloads complete on disk but get marked failed with
"File not found on disk after 5 search attempts. Expected: <basename>" — which tells the user
nothing about where we looked or what to check.
This is deliberately a DIAGNOSTIC fix, not a behavior change. The finder + path handling are sound
(verified: docker_resolve_path no-ops in standalone, the finder walks the configured
soulseek.download_path and resolves a present file). When it still misses after slskd reported the
transfer Succeeded, the cause is environmental — either the file is still landing (timing) or, the
classic standalone gotcha, SoulSync's download_path doesn't point at slskd's actual download dir.
Neither is something our code can "fix"; the user fixes the config, or the file arrives.
So: name the folder we actually searched and spell out the two real causes, turning an opaque
failure into self-diagnosis ("oh, my download folder's wrong"). Retry/wait behavior is left
untouched on purpose — widening the window does nothing for a path mismatch and I can't justify it
for this user. Also normalizes the slskd backslash path so the reported filename is the leaf, not
the whole "@@@user\folder\file" string.
Updated the existing not-found test to pin the new actionable message (searched path + config
hint + filename). 588 downloads tests green, ruff clean.
Discord (Shdjfgatdif): the import-singles Identify search prefilled "artist title" (space), so
"Sub Focus Last Jungle" returned junk while "Sub Focus - Last Jungle" found the track. The
placeholder already hints "Search artist - title..."; the prefill just did not match it. Join with
" - " instead of " ". filter(Boolean) keeps a lone title (no artist) dash-free.
Discord (Shdjfgatdif): "if a track isn't imported it should remain there, not be deleted, so we
can retry." He was seeing failed downloads disappear and having to re-download.
Normally a rejected file is QUARANTINED (moved to ss_quarantine, preserved + retryable), not
deleted. But all four quarantine blocks (integrity / silence / quality / acoustid) had the same
fallback: if move_to_quarantine itself raised, os.remove(file_path). On a NAS that move can fail
(cross-device / permissions), so the except fired and the user's download was DELETED — the worst
outcome, and exactly the re-download pain he reported.
fix: on quarantine failure, log and LEAVE the file in place — never delete. The task is still
marked failed and the batch still notified (that code runs after the try/except and never touched
the deleted file), so the only behaviour change is "preserved instead of destroyed". Reviewed
every os.remove in the pipeline: the remaining ones are success-path cleanups (replacing an
existing destination, or removing a redundant download when the track is already in the library at
equal/better quality) — left untouched.
regression test drives the REAL pipeline through integrity-rejection with quarantine forced to
raise, and asserts the source file is preserved while the task is still failed + notified.
1311 imports/downloads/quality tests green, ruff clean.
Discord (Shdjfgatdif): a downloaded .flac sat right there in the download folder but the import
flow reported "File not found on disk after 5 search attempts" and failed it.
root cause: slskd REPORTS the name as "[34 - You & Me (Flume Remix).flac" but SAVES it as
"34 - You & Me (Flume Remix).flac" (it strips the leading '['). The finder's fuzzy-match
normaliser used one combined bracket-strip — r'[\[\(].*?[\]\)]' — which allows MISMATCHED
delimiters, so the lone '[' matched all the way to the next ')', ate the whole title, and
collapsed the search target to just "flac". That scored 0.40 against the real filename (below the
0.85 floor) → "not found", despite the file being on disk. Confirmed by running the real code on
his exact filename.
fix: strip only BALANCED pairs (\[...\] and (...) separately). A stray unbalanced bracket now
survives to the alphanumeric strip instead of devouring the title. '[34 - You & Me (Flume Remix)'
→ matches at 1.00. Balanced tags like "[FLAC]" / "(Remastered 2016)" are still stripped (no
regression). Only used internally by the finder's fuzzy scorer — contained blast radius.
3 tests: his exact unbalanced-'[' filename, a stray-']' variant, and a balanced-tag no-regression
guard. 1311 imports/downloads/quality tests green, ruff clean.
Discord (DXP4800 NAS, 7148 tracks): library updates kept dying with "Update appears stuck — no
progress for 300s (last phase: Fetching all tracks in bulk...)". not actually hung.
root cause: the bulk track/album fetch used a single 10000-item page, so a whole library came
back in ONE request that emitted NO progress while in flight. the watchdog (database_update_health)
kills a job with no progress for 300s — so on a slow server that one silent request tripped it even
though it was alive, not stuck. raising the timeout cap only buys the silent request more rope; a
bigger library or slower disk just needs a higher number. the per-batch progress line also only ran
when there was a NEXT page, so a sub-page-size library reported nothing at all.
fix: extract a pure paginate_all_items seam (core/library/bulk_paginate.py) that pages in 1000s and
reports progress after EVERY page — so the watchdog is fed on a cadence set by page size, not library
size, and can't starve mid-fetch no matter how big the library. both Jellyfin bulk loops (tracks +
albums, same defect) now route through it. preserves the failure-shrink resilience (halve to a floor,
then give up). does NOT change what's fetched — same query, fields, items.
note: changes nothing about WHICH tracks come back; only how they're paged + that every page reports.
keep the raised cap on dev as a margin — this is the actual fix. Plex/Navidrome don't share the
pattern (checked). 9 seam tests incl. the watchdog-feed invariant (progress count scales with
N/page_size, never one call for the whole library) + the sub-page regression + failure-shrink.
467 jellyfin/library tests green, ruff clean.
#942's normalize_spotify_oauth_config trimmed whitespace/quotes (good — those can't be part of a
real credential) but ALSO rstrip("/")'d the redirect_uri. that's unsafe: Spotify matches the
redirect URI EXACTLY against the app's dashboard registration, and a trailing slash is a
legitimate part of a URI. stripping it would silently break anyone who registered '…/callback/'
(we'd send '…/callback' → INVALID_CLIENT: Invalid redirect URI) — trading one failure mode for a
sneakier one the user can't diagnose (SoulSync no longer sends what they typed).
drop the rstrip; keep the whitespace/quote trim. the value is now preserved verbatim apart from
unambiguous paste garbage. flipped the test that asserted the strip to assert the slash is kept
(and that whitespace/quotes around it are still trimmed), + a dedicated regression guard.
the #942 integration test mocks normalize, so it's unaffected. 262 spotify/oauth tests green.
credit: builds on HellRa1SeR's #942.
reopened by diegocade1: pasting a Qobuz track link still showed unrelated results. the earlier
fix (b1f061a) only BUBBLED the linked track to the top — but a pasted link is resolved to an
"artist title" text query and searched, and for an obscure track ("foreign lavennew" by colacola)
that text search returns broad lookalikes ("Foreign Bird", "Foreign Spies", …) and never the
actual track. nothing to bubble → user sees junk.
fix: since the link is already resolved via get_track(id), fetch that exact track AS a downloadable
result and inject it at the top (Qobuz downloads by id, so the result is fully usable). the text
search still runs for alternatives.
- QobuzClient.get_track_result(id): get_track + _qobuz_to_track_result; None on any failure.
- _qobuz_to_track_result gains require_streamable (default True for bulk search). the link fetch
passes False: track/get may OMIT the streamable flag, which would default-False and wrongly drop
the exact track the user explicitly asked for. (this closes the one shape assumption that
couldn't be verified against a live Qobuz API — the track is no longer gated on it.)
- track_link.inject_linked_track_first(tracks, linked_result, id): pure seam — prepend the fetched
result + drop any search duplicate; falls back to the bubble when no result was fetched.
- manual-search endpoint fetches linked_result defensively (getattr 'get_track_result') and calls
the seam. Tidal/HiFi (get_track returns a dict but the converter wants an object — shape
mismatch) have no get_track_result, so they keep the existing bubble path: NO regression.
14 tests: inject puts the fetched track first when search missed it / dedups a search copy / falls
back to bubble / str-safe id / noop; get_track_result convert/none/exception; and the REAL
converter builds a valid downloadable result from a track/get dict that OMITS streamable (search
path still rejects it). 85 track-link/qobuz tests green, ruff clean.
the frontend keeps its own copy of the lossless set (settings.js RT_LOSSLESS_FORMATS + the
index.html quality-profile dropdown) — runtime-fetching a yearly-changing list from the backend
isn't worth the coupling. but that duplication IS the exact root cause of #941 (a format added
to one place, not another). so instead of unifying, pin it: two tests parse the frontend lists
and assert they match the backend LOSSLESS_FORMATS. adding a new lossless format now fails CI
until it's added everywhere, instead of silently shipping a half-wired feature.
verified the guard catches drift (not a tautology): a simulated backend-only 'ape' addition
makes the equality fail. 18 lossless tests green, ruff clean.
radoslav-orlov: "create lossy copies of lossless tracks" only recognized FLAC, even though ALAC/
WAV/AIFF/DSD are now quality-profile formats. the FLAC knowledge was hardcoded in 3 separate
places (the import path, the Lossy Converter scan, and the fix executor) — exactly how a format
gets added in one spot but not another.
kettui-style fix — one canonical seam both sites route through, instead of 3 more string edits:
- new core/quality/lossless.py: is_lossless_format / is_lossless_audio_path (pure; injects a
codec probe for the ambiguous .m4a/.mp4 — ALAC vs AAC — so the decision stays testable with no
I/O), LOSSLESS_FORMATS (single source of truth, derived-consistent with model.tier_score), and
the lossy_output_would_overwrite_source safety invariant.
- create_lossy_copy + the Lossy Converter scan + repair_worker._fix_missing_lossy_copy all route
through it. SQL pre-filters by candidate extensions, then each file is confirmed (probing .m4a).
- SAFETY: a lossy copy must never be written over its own source — an .m4a ALAC source + AAC
target lands on the same .m4a path, and ffmpeg runs with -y. all three sites now bail on the
overwrite case BEFORE ffmpeg (the existing delete-original guard was too late — the source was
already clobbered). dropped a vestigial mutagen FLAC import; updated FLAC-only UI strings.
19 tests: full seam coverage (formats, the .m4a ALAC/AAC probe branch, candidate extensions, the
overwrite guard), a tier-model consistency test that fails if the lossless set drifts, and import-
site wiring tests — WAV now converts (was rejected), and the .m4a-ALAC+AAC overwrite case proves
ffmpeg NEVER runs. 286 quality/import/repair tests green, ruff clean.
diegocade1: DSD files (.dsf, ~500MB DSD64) were labeled "Low Quality" and nagged to upgrade.
two independent causes, both fixed (additive — no existing format/behaviour changed):
1) DSF was an unrecognized format -> bottom 'unknown' tier -> "Low Quality":
- source_map: map .dsf/.dff -> 'dsf' (also lights it up in AUDIO_EXTENSIONS, so Soulseek can
match a DSF if one exists)
- model.tier_score: 'dsf' base 102 (just above FLAC) — lands in the lossless range
- probe_audio_quality: add a DSD branch returning format='dsf' (mutagen.dsf for .dsf detail;
.dff classifies lossless without measured detail) instead of None
- settings UI: DSD in RT_LOSSLESS_FORMATS + a "DSD (DSF / DFF)" option in the profile dropdown
2) the actual cause of the screenshot's findings — the truncation guard falsely called DSF
"broken (only ~12% decodes)": ffmpeg decodes DSD to PCM at a different rate than the DSD
container's 2.8 MHz, so astats samples ÷ container-rate massively under-counts. now
detect_broken_audio skips the truncation check for DSD (silence detection still applies).
8 seam tests: dsf/dff -> 'dsf'; dsf tier in lossless range (with + without measured bitrate);
is_dsd_path; and a contrast pair proving the same 12%-decode numbers flag a .flac but skip a
.dsf. 230 quality/import/silence tests green, ruff + JS integrity clean.
ramonskie's thread finding: opening the Import page fires staging files + groups + hints
together, and each one independently os.walk'd the whole staging folder AND mutagen-read every
file's tags — 3x the directory walk and 3x the per-file tag I/O on every page open (the import
scan storm + memory spike on large staging folders).
they all need the same per-file tag data, so scan ONCE: _scan_staging_records walks staging and
reads each file's metadata a single time, returning per-file records that files/groups/hints all
derive from in-memory. a short TTL (6s) + a lock means the three near-simultaneous page-open
requests share one scan instead of each kicking off a full re-read; the lock also prevents
concurrent full scans. hints now derives from the same read_staging_file_metadata the other two
use (same underlying tags) instead of a separate read_tags pass. album/singles process drop the
cache on completion so the list updates immediately after files leave staging.
net: 1 walk + 1 tag-read-per-file on page open instead of 3. 2 tests (shared-scan: 3 endpoints =
1 read per file, not 3; hints updated to the shared reader) + autouse cache-clear fixture; 695
import/staging tests green.
reported (radoslav-orlov): the Stats page hangs ~20s on a 16GB/HDD box, GET /api/stats/cached
?range=7d -> 200 in ~20000ms, while it's instant on Boulder's SSD. the endpoint is documented
"instant" — it reads 3 small precomputed metadata blobs — but it then ran every top
artist/album/track image through normalize_image_url ON THE READ. that fixer calls
cache_url_for, which does a SQLite INSERT/UPDATE + commit under a global lock PER image. on an
HDD each commit fsyncs and contends with the background image fetcher -> ~20s for ~50 images.
(this is exactly the "image caching when we open a page" ramonskie flagged in the thread.)
fix: do the normalization in the background ListeningStatsWorker when it builds the cache
(_enrich_stats_items), so the cache stores browser-ready /api/image-cache/... URLs and the read
does ZERO per-image work. the read-path fixer stays as a cheap no-op (normalize_image_url early-
returns on already-proxied URLs), so an old raw-URL cache self-heals on the next rebuild with no
broken art in between. the registration writes now happen once per rebuild, off the hot path.
2 tests (existing enrich test relaxed to truthy since the value is normalized now; new
deterministic test stubs the fixer to prove every section's url is fixed at build time).
136 stats/listening/image tests green.
the CI ruff gate flagged 4 S110s in code added this cycle: the /api/debug/memory/objects
endpoint (len() on an exotic object; optional psutil rss) and the GC sweeper (malloc_trim
resolution + the trim call — absent on musl/non-Linux). all are genuinely best-effort, so add
'# noqa: S110' with a one-line reason on each. ruff check . is clean.
Docker report (HellRa1SeR, 2.7.8): pasting YouTube cookies threw
`ERROR: unsupported browser: "custom"`. the 'Paste cookies.txt' dropdown value is the sentinel
'custom', and youtube_client built `cookiesfrombrowser=('custom',)` from it — yt-dlp rejects
that since 'custom' isn't a browser. (server/Docker users have no local browser, so pasted
cookies.txt is the ONLY way to authenticate yt-dlp — e.g. for private 'Liked Music'.)
the correct precedence already existed in core.youtube_cookies.build_youtube_cookie_opts
('custom' -> cookiefile, browser name -> cookiesfrombrowser, falsy -> anonymous) and the web
layer used it — but youtube_client never got migrated and kept 5 inline cookiesfrombrowser
sites. route all of them through a shared _resolve_cookie_opts() that delegates to the tested
helper (reads youtube.cookies_browser + youtube.cookies_file, checks the file exists). also:
the no-cookies download retry now drops cookiefile too (not just cookiesfrombrowser), and
removed 3 now-dead config_manager imports.
3 regression tests (custom->cookiefile, browser unchanged, custom+missing-file->anonymous);
97 youtube tests green.
since 9a0e3b40 persisted completed downloads in the Downloads view, the Clear Completed button
was hidden for those rows and clear-completed only pruned live session tasks. after a restart
the page filled with persisted completed downloads with no way to clear them.
now Clear Completed clears BOTH:
- live session completed/failed tasks (clear_completed_local, unchanged), AND
- the persisted download-history tail: new clear_completed_download_history() deletes every
library_history event_type='download' row, so the list actually empties and stays empty.
this includes unverified rows (the verification review queue) by design: on a library where
verification never confirmed the imports, ALL completed downloads are 'unverified', so preserving
them made the button a no-op. it only removes HISTORY rows — the actual files and their tracks
entries are untouched, so nothing in the library is lost, only the 'needs verification' flags.
the action confirms first (showConfirmDialog, destructive) and the button now shows whenever any
completed/failed row is present.
3 seam tests (clears all incl unverified; leaves non-download history; empty=0); reconcile +
orphan + JS integrity suites green.
four fixes from the review (and a self-correction):
1) close the connection. reconcile_unverified_history_from_tracks opened a connection with no
finally/close. runs once per boot so GC reclaimed it, but now it's consistent + robust.
2) scope the tracks scan to the review queue. it built lookup dicts from EVERY verified/
human_verified track (~350k on a large library) on every boot while anything is unverified
(the normal state). now it loads the stuck rows first and skips verified tracks whose path
AND basename can't match any queued row, so dicts stay proportional to the queue, not the
library. behaviour identical (all 13 PR reconcile tests still pass).
3) close the title-less basename collision. a title-less history row fell back to filename-only
matching with no ambiguity check, so a generic name like "01 - Intro.flac" could heal a
DIFFERENT song to verified. now a title-less basename heal only fires when that basename is
unique among verified tracks; unique-basename rows still heal (recall preserved).
4) "Clean orphaned" protects force_imported rows (deliberate user decision, keep for human
approval) without weakening the mount-down safety gate. CRUCIAL self-correction: filtering
them out BEFORE the orphan check (my first cut) shrank the checked count below the threshold
and would have let a few unverified orphans be deleted during a mount outage. instead,
find_orphan_history_ids now takes a deletable predicate: protected rows still count toward
checked / all-missing (gate stays strong) but never enter the orphan_ids delete set.
3 new regression tests (title-less collision; deletable protects from delete; protected rows
still count toward the gate). 936 verification/acoustid/history/downloads tests green. builds
on nick2000713's #938.
clicking Download Discography → Add all to wishlist added ~1 track every 15-30s. trace: the
endpoint's per-track library-ownership check (track_already_owned → check_track_exists) ran the
LEGACY path — firing search_tracks for every title-variation × artist-variation, per track. on
a large library and an artist you own NOTHING of, STRATEGY-1 (indexed LIKE) always missed and
fell through to the fuzzy fallback (full-table scan), ~10-15 scans/track = the 15-30s. metadata
fetch was never the bottleneck (deezer returns each album in ~1s).
fix: pre-fetch the artist's owned tracks ONCE (get_candidate_albums_for_artist →
get_candidate_tracks_for_albums) and pass candidate_tracks to check_track_exists's batched
in-memory path — the same path the discography backfill job + completion-stream already use.
pass an EMPTY list (not None) when nothing is owned so the owns-nothing case still takes the
fast path → instant. per-track cost drops from ~20s to ~1ms.
safe: track_already_owned's only real caller is this endpoint; the new param is optional
(None = unchanged legacy behaviour for any other caller). normal ownership still detected (same
artist-variation breadth); the one divergence is a track owned ONLY via a compilation → a
harmless redundant wishlist add, which is the endpoint's explicitly-accepted failure mode and
already how the backfill job behaves. 4 new tests; 1134 discog/metadata/wishlist tests green.
the dashboard orb canvas kept falling back to ~1fps on firefox after the page settled. root
cause: firefox throttles requestAnimationFrame to ~1fps for a canvas it heuristically deems
occluded. the WAA keepalive only delayed the heuristic; it re-fired over time.
real fix: on firefox, drive tick() with a setInterval(~60fps) instead of self-scheduling rAF —
setInterval is not subject to the canvas-occlusion rAF throttle, so the orbs stay at full
framerate indefinitely. chrome is untouched (keeps vsync-aligned rAF). same render workload
(idle still drops to 20fps via the existing sleep skip); background tabs still parked by the
visibilitychange handler → stopLoop clears the interval. kept the keepalive (harmless).
#936 added fragmented-row grouping. two follow-ups found in review:
1) BUG: an excluded canonical sibling (a row pinned to the same canonical edition whose tracks
fail the strict fragment match) was emitted with canonical_items but no owned set, so
_build_missing_tracks flagged the ENTIRE tracklist as missing — including tracks the row
already owns (e.g. a 3-track fragment of a 12-track edition reported 12 missing, not 9).
now compute that sibling's own owned slots from its local tracks (shared
_owned_reference_for_tracks helper, same logic as the anchor) so it reports only what it's
actually missing and the count stays internally consistent.
2) PERF: _build_candidate_groups rescanned all albums for every canonical group — O(G*N), which
degrades badly once many editions are pinned (fine today at G=1, latent at scale). invert to
a single O(N) pass that assigns each row to its unique group; identical 'exactly one match'
semantics. also added a Stop/Pause check in _prepare_work_items, which now front-loads the
canonical lookups + matching.
3 new tests (excluded-sibling reports only its missing tracks; ambiguous candidate stays
independent; unambiguous candidate joins) — 17 completeness + 123 repair tests green.
root cause: the library stores album/artist art as media-server RELATIVE paths (Plex
/library/metadata/.., Jellyfin /Items/.., Navidrome /rest/..), which don't render in a browser
<img>. normal wishlist items carry Spotify CDN urls so they show fine, but LIBRARY-sourced
items — dead-file re-downloads and preview-clip re-fetches — carry the raw relative path, so
their album art came up blank. and the nebula only had artist photos for WATCHLISTED artists,
so non-watchlist orbs showed initials.
fix on READ in the wishlist tracks endpoint (so it also repairs items already in the wishlist,
no re-run needed), using the library data we already have:
- normalize each track's album.images url that needs it — relative/internal only, via the
canonical normalize_image_url; CDN urls are left untouched so already-rendering items can't
regress.
- build an artist-name -> normalized library-photo map and return it; the nebula seeds its
artist-image map from it (every wishlist artist), with curated watchlist photos overriding.
8 tests (predicate: relative/internal fixed, CDN untouched; album normalize in-place; artist
map build/skip-empty/idempotent/graceful). 237 wishlist+repair+JS tests green, ruff clean.
two field reports:
1) FIX-ALL skipped these findings — bulk_fix_findings() has a hardcoded fixable_types
allowlist that didn't include 'short_preview_track'. added it, so select-all/fix-all and
the per-page bulk-fix now cover this tool.
2) RE-WISHLISTED ITEM WAS ART-LESS — the payload pulled album art from the library thumb,
which is empty for un-enriched HiFi previews, so the wishlist orb showed initials (no album
OR artist image, since the orb falls back to album art). now the duration lookup also
captures the metadata source's CDN art from raw_data (spotify album.images / itunes
artworkUrl, upscaled) and stores it on the finding; the fix prefers that over the empty thumb.
3 new tests (art capture from spotify raw_data, itunes artwork upscale, fix uses finding art);
8 job tests + repair suite green.
The reconcile heals rows whose file is still in the library; it deliberately
leaves ORPHANS — history rows whose file is gone (deleted / replaced /
re-downloaded elsewhere). Those can never be healed (no file left to confirm)
and linger in the Unverified list forever. This adds an explicit, user-initiated
cleanup for them.
- core/downloads/orphan_history.py: pure, tested rule. A row is an orphan when
its file resolves nowhere; flags `suspicious` when EVERY reviewed file is
unreachable (the mount-down signature) so the caller refuses rather than
mass-delete a healthy log during an outage.
- POST /api/verification/clean-orphans (admin-only): runs it against
_resolve_history_audio_path (raw path -> prefix-swap resolver -> tracks-table
title fallback), refuses on the suspicious signature, and deletes only history
ROWS — never a file (the files are already gone).
- UI: "🧹 Clean orphaned" button in the Unverified bulk-actions row, with a
confirm dialog spelling out that it removes log rows only and refuses if the
library looks offline.
NEVER automatic / never at boot — a filesystem check during a mount outage would
otherwise wipe good history. 5 pure-rule tests + safety-gate coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWJk7EuM7YktQeNyqQwTZY
Second-pass audit of the startup reconcile found a real correctness bug in it:
the basename fallback had NO title guard, so a shared track-number filename
("01 - Intro.flac" in different albums) would heal the WRONG song — marking an
actually-unverified file 'verified' and silently dropping it from the review
queue. This is the exact collision the scan-time matcher (history_match.py)
guards against; the reconcile now mirrors it.
- basename match now requires the history row's title and the candidate track's
title to agree (alphanumeric-lowercase), only when BOTH are present (legacy
rows without a title still fall back to filename-only, like the matcher).
- exact-path matches stay unguarded (same path = same file, unambiguous).
- cheap early-out: skip the tracks scan entirely when no 'unverified' rows exist
(keeps the every-boot cost ~nil on healthy libraries).
3 new tests (collision must-not-heal, titles-agree heals, missing-title falls
back). 8 reconcile tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWJk7EuM7YktQeNyqQwTZY
each preview-clip finding now renders a dedicated detail card: File Length vs Real Length
(e.g. 28s vs 200s) and a Play button so the user can listen to the clip and confirm it's a
busted ~30s preview before approving the delete + re-download. reuses the existing
_renderPlayButton/playFindingTrack path that dead_file/orphan findings already use (the
finding carries file_path + title/artist/album, everything it needs).
two field-reported issues: clicking the job sat at 'Starting…' with Scanned: 0 forever and
never showed the current track.
1) HANG — spotify get_track_details() defaults to allow_fallback=True, which scrapes the
configured metadata source when the official API isn't authed (HiFi users). that scrape is
slow and blocked the scan loop on the first track. now pass allow_fallback=False (official
only — fast, returns None cleanly) and fall through to iTunes/MusicBrainz.
2) NO LIVE UPDATE — progress was only pushed every 5 tracks via update_progress, never the
current item. now report_progress() every track (phase + 'artist — title' + scanned/total)
plus a start phase, so the UI moves and shows what it's checking.
also made the test track ids INTEGER to match production (tracks.id is INTEGER PRIMARY KEY),
exercising the real str(id) finding -> WHERE id=? round-trip. 5 tests green.
HiFi (and occasionally other) downloads sometimes deliver a ~30s preview clip instead of the
full song; it lands in the library looking real. new repair job scans short tracks (duration
<= 30s, configurable), looks up the EXPECTED length from the track's metadata source
(spotify/itunes/mb get_track_details), and flags any whose real length is much longer than the
file (default: >= 30s longer) as a preview clip.
approving the finding (repair_worker._fix_short_preview_track) deletes the preview file (path
resolved via _resolve_file_path like the other delete tools), drops the DB row so the track
goes missing, and re-adds it to the wishlist with the full payload (mirrors _fix_dead_file)
so the real version downloads. scan ONLY creates findings — nothing destructive without user
approval, like every other tool.
conservative: genuine short tracks (source agrees they're short) and tracks whose length can't
be verified are skipped, never flagged. registered the job + finding-type label/fix-button in
the UI. 5 tests (scan flag/skip/scope + fix delete+remove+wishlist); 89 repair tests green.
the duration-agreement check used abs() drift, so a file running LONGER than the metadata
(a remaster with a longer outro, an extended cut) was rejected the same as a truncated one —
e.g. A-Ha 'Take on Me' remaster at 228.5s vs 225.0s expected, quarantined for +3.5s.
but a longer file is the OPPOSITE of truncated. make the auto tolerance asymmetric: keep the
tight 3s/5s bound for SHORTER files (the truncation case the check exists for), allow up to
15s in the LONGER direction for version/master differences. a wrong song still trips it (off
by far more than 15s), and a user-pinned tolerance is honoured symmetrically. direction-aware
rejection message too. 4 new tests; 274 integrity/import tests green.
the growth-triggered collects were firing but RSS still climbed to 2.2GB before snapping to
1.2GB — because gc.collect() freed the python objects but glibc hoarded the memory rather than
returning it to the OS, so RSS stayed at the high-water mark. add malloc_trim(0) after each
collect to hand freed arenas back to the OS, so incremental collects genuinely lower RSS and
the sawtooth caps near floor+200MB instead of overshooting. best-effort (skipped on musl/non-
linux). also tightened the growth trigger 250->200MB.
the 60s timer overshot: browsing piled plexapi cyclic garbage faster than once-a-minute caught
it, so RSS hit ~2.2GB before a sweep (then dropped to 1.2GB). switch to polling RSS cheaply
(every 8s) and collecting as soon as it grows +250MB since the last sweep — so it fires DURING
a heavy browse and caps the peak near floor+250MB instead of running to 2GB+. keeps a 120s
backstop for slow idle accumulation.
measured the 'resource hungry' / lockup issue: browsing every page grows RSS ~300MB -> 1.8GB
and it stays. it's not a leak — it's deferred cyclic collection. plexapi parses Plex responses
into XML Element trees whose nodes reference each other in cycles; Python's generational GC
leaves them in gen2 and sweeps it rarely, so ~227k Element objects pile up. forcing gc.collect()
reclaimed ~700MB instantly (1.8GB -> 1.1GB live), confirming.
add a daemon that runs a full gc.collect() every 60s so the cyclic garbage is reclaimed on a
cadence instead of climbing into lock-up. full collect is ~tens of ms; once a minute is
negligible. this is the root of the reporter's 2GB + ramonskie's spike too.
tracemalloc's continuous tracing locks up a loaded app, so add a one-shot gc-based memory
breakdown: top object types by total size AND by count, plus the biggest individual containers
(>1MB). a runaway 'count' points at an unbounded cache; a big bytes/str total points at blob
retention. lets us pinpoint the RSS growth (300MB -> 1.8GB after browsing) without tracing.
the Memory Usage stat showed only global system memory (psutil.virtual_memory().percent).
add the process's own resident set size (RSS) — the real 'how much RAM SoulSync uses' number —
formatted MB under 1GB, GB above. headline stays the system %, subtitle now reads 'SoulSync ·
612 MB' instead of the generic 'Current usage'. graceful fallback if psutil errors / older
backend. useful context after the recent RAM-footprint discussions.
Firefox re-rasterizes blur()/backdrop-filter every composite where Chrome caches it, so the
always-visible shell glass (sidebar header + aura orbs, hero/header buttons) was ~half of
Firefox's idle GPU. gate behind @supports(-moz-appearance:none) so it's Firefox-only: hide the
two blur(28px) sidebar orbs + the dash-card blobs, and drop backdrop-filter on the sidebar
header and hero/header buttons (each keeps its tint, just unfrosted). measured ~20-25% -> ~10-13%
on Firefox, every page (sidebar is always visible). chrome is untouched — the block doesn't
exist there, full frost intact.
removing the always-on dash-card blob animation (for the Chrome GPU win) incidentally let
Firefox start throttling the worker-orb canvas's compositing to ~1fps after a header hover
re-layerizes the dashboard — Chrome never throttles it. re-add the 'keep the compositor warm'
effect cheaply: a 2px, ~invisible element running an infinite transform-only animation (zero
paint). gated behind CSS.supports('-moz-appearance') so it's Firefox-only; Chrome never gets
it. confirmed fix in Firefox/Zen.
the .dash-card cursor blobs are 16 large blur(48px)/blur(18px) layers. chrome caches them
once; firefox re-rasterizes blur on every composite, so they're a big chunk of idle dashboard
GPU on firefox. they're purely decorative and reduce-effects already hides them. gate behind
@supports(-moz-appearance:none) so it's firefox-only — chrome keeps the full cursor glow,
this block doesn't exist there.
same antipattern as the dash-card blobs: .sidebar::before/::after are blur(28px) and the
orb keyframes animated transform: scale() infinitely → the GPU re-blurred them every frame,
on every page (the sidebar is always visible). keep the translate drift + opacity (both
compositor-only, the blur layer just moves), remove the scale. same look, no per-frame reblur.
each .dash-card renders two accent-blob pseudo-elements — ::before is 1280x1280 blur(48px),
::after 540x540 blur(18px) + mix-blend-mode:screen — and both ran an INFINITE scale-pulse
animation. scaling a blurred element re-rasterizes the blur every frame; with 8 cards × 2
blobs that's 16 huge blurred layers re-blurring at 60fps whether or not the user touches
anything. that's the dashboard's whole-screen repaint / ~36% idle GPU.
remove the infinite pulse (the dashBlob*Pulse animations). the blob still follows the cursor
via --blob-x/y; it just no longer 'breathes' at idle, so when nothing's moving there's nothing
to repaint. trimmed will-change to the props that actually change (left/top).
the full-page particle canvas runs a continuous requestAnimationFrame loop behind every
page — real GPU cost, and multiple users hit GPU strain until they found the toggle. flip
the default to off; the eye candy is opt-in now.
- init.js: runtime flag defaults false unless localStorage is explicitly 'true'
- settings.js: config read is now '=== true' (default off) instead of '!== false'
- index.html: checkbox no longer 'checked' by default; hint reworded
existing users who explicitly enabled it (localStorage/config 'true') keep it on; the
existing '!== false' runtime guards still work since the flag is now always set explicitly.
The Quarantine sub-view was reworked into rich cards (artwork, source line,
row-click to expand an inline detail panel, consistent action cluster) but the
Unverified sub-view was left on the generic download-row layout that opened a
modal on click. Bring it to parity:
- dedicated _verifUnverifiedRowHtml / _verifUnverifiedRows renderer, used via a
sibling branch in _adlRender (mirrors the quarantine sub-view branch).
- row click toggles an inline details panel (why flagged, download source,
quality, file, downloaded-at), open-state keyed by stable id so it survives
the 2 s poll re-render — same pattern as verifQuarInspect.
- reuses the existing verif-quar-* / verif-actions / adl-row CSS (no new styles)
and the existing play/compare/audit/approve/delete handlers.
- NO grouping: each unverified import is its own track (grouping only makes
sense for the quarantine alternates), per design intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWJk7EuM7YktQeNyqQwTZY
Complements the AcoustID-scan-time heal: re-links library_history rows still
showing 'unverified' to the verified/human_verified status their file already
carries in the tracks table — matching exact path AND basename, so a file that
moved (media-server import / reorganize) heals even though the stored history
path is frozen. Upgrade-only and non-destructive (no deletes, no bulk
migration).
Why this is needed on top of the scan-time fix:
- It clears the EXISTING backlog (e.g. 5551 rows) on the next restart with NO
re-fingerprinting and no AcoustID API calls — the file's status is already in
tracks from the prior scan; this just propagates it to the frozen history row.
- It covers human_verified files, which the AcoustID scan skips entirely
(file_verif_status == 'human_verified' returns early), so their stale history
rows would otherwise never heal.
Runs once on DB init (cheap, idempotent). 5 real-sqlite tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWJk7EuM7YktQeNyqQwTZY
the AcoustID scanner matched library_history rows by EXACT file_path, but that path is
frozen at import time while the file moves afterward (media-server import / reorganize) —
so tracks.file_path (what the scan reads) no longer equals it. two failures resulted, both
introduced in 37ea6604: verified status never reached the history row (verified tracks kept
showing 'unverified'), and a fresh acoustid_scan row was INSERTed every run (5551 rows for
3675 songs).
- new pure, tested matcher (core/downloads/history_match.py): exact path → filename guarded
by title; prefers a real download row over a synthetic scan row.
- _persist_status now HEALS the matched row's path + status (so future scans match cleanly),
DELETES synthetic acoustid_scan duplicates by exact path (collision-free, never a real row),
and inserts only when the file genuinely has no row.
- a full AcoustID job now self-cleans existing duplicates — no destructive bulk migration.
8 matcher + 4 real-DB heal/dedup/insert tests; existing scanner tests updated to the new
seam (heal vs insert). 1076 acoustid/verification/download tests green.
save_watchlist_scan_run had a single caller — the manual scan endpoint. the automatic/
scheduled path (process_watchlist_scan_automatically) ran the full scan but never wrote a
history row, so nightly scans never showed up in the History modal — only manual ones.
- new shared helper persist_scan_run(database, state, ...) extracts the run from the
finished watchlist_scan_state and writes one history row
- the automatic path now stamps scan_run_id/scan_track_events and calls it
- the manual path is refactored onto the same helper so the two can't drift apart again
- history is global (no profile filter), so the all-profiles nightly scan records one
aggregate row (profile_id None → 1, never NULL)
tests: 4 new persist_scan_run seam tests (real DB) + 2 new auto-scan integration tests
proving the auto path actually records (completed + cancelled, exactly once). 420
watchlist/automation tests green.
a pasted track link IS resolved + searched, but the 'bubble the exact track to the top'
step read getattr(t,'id') — and TrackResult has no top-level id (the source id lives in
_source_metadata['track_id']). so the bubble was a silent no-op: the linked track sat buried
among fuzzy text-search lookalikes and the user saw unrelated tracks. qobuz made it worse —
_qobuz_to_track_result never stamped _source_metadata at all, so the track had no id to match.
- stamp _source_metadata={'source':'qobuz','track_id':...} on qobuz TrackResults (mirrors tidal)
- extract the bubble into pure, tested helpers (linked_track_id / bubble_linked_track_first)
that read _source_metadata['track_id'] — fixes it for tidal too, str/int-safe, stable no-op
19 track-link tests (+6 new) + 87 qobuz/download tests green.
_normalize_album_for_match stripped ANY trailing '- clause', so a real distinguishing
subtitle ('Clair Obscur: Expedition 33 - Nos vies en Lumière (Bonus Edition)') collapsed to
the same name as the OST → _albums_likely_match treated them as one album → the watchlist
marked unowned tracks of one edition as owned via the other and under-wishlisted.
- strip a trailing '- ...' clause ONLY when every token in it is an edition/format
qualifier (+ connectors / year-ordinal): '- Single', '- Acoustic Version', '- 2011
Remaster' still collapse, but real subtitles ('- Nos vies en Lumière', '- Volume 2',
'- Live in Berlin') are kept. Avoids the inverse regression (a same-album pair splitting
into a redownload loop), which a naive narrow strip would have caused.
- drop the loose substring shortcut + raise the fuzzy floor 0.6→0.85; genuine drift already
collapses to an EXACT match, so the looseness only ever produced false fuses.
blast radius: _albums_likely_match has exactly one caller (the allow-duplicates skip).
48 album-match tests pass (qualifier-suffix merges + edition-subtitle splits) + 219 watchlist.
the file faked spotipy + config.settings in sys.modules at import time with no teardown.
the fake config.settings had no ConfigManager, so depending on collection order it leaked
into tests/test_config_save_retry and intermittently failed the full suite. the real
modules import fine in the test env (spotipy is installed, config.settings has both
ConfigManager + config_manager), so the stubs were pure liability — removed them. album
tests still pass (10), the album+config combo that errored now passes (17), 573 repair/
config/canonical tests green.
#929 added 'musicbrainz' to library_reorganize._ALBUM_ID_COLUMNS but not to the
canonical layer, breaking the equality invariant test (canonical reads exactly what
reorganize reads). add musicbrainz to CANONICAL_ALBUM_SOURCES, and move it from the
'can't pin' param group to the 'pins' group in the manual-lock tests — now consistent,
and forward-compatible with pinning a deliberately-matched MB edition. inert at runtime
today (mb isn't in the manual source selector, so should_pin is never called with it).
540 canonical/reorganize tests green.
test_personalized_playlists_id_gate asserted the OLD (wrong) deezer thresholds (>=100000, raw-rank assumption) — the same bug fixed in c033656f. The discovery pool synthesizes deezer popularity to 0-100, so the test now asserts (60, 50). This is the CI failure from running the full suite (my -k subset missed it).
results comes from asyncio.gather over to_search, so they're always equal length — strict=True asserts the invariant and satisfies ruff B905. Carried in with #896.
The discovery pool synthesizes deezer popularity onto a 0-100 score (base 45 + bonuses, capped at 100), but _get_popularity_thresholds had deezer on the raw-rank scale (500000/100000). So Popular Picks' 'popularity >= 500000' matched nothing — empty for every deezer-primary user — while Hidden Gems' '< 100000' caught the whole pool. Deezer thresholds now sit on the 0-100 scale (60/50, like Spotify's 60/40). Tested.
Every library track was stored with disc_number=1 because the Jellyfin/Plex/Navidrome scan parsed the track number but never the disc field. Multi-disc albums collapsed onto disc 1, so disc-2+ tracks were mis-filed (shown under disc 1) and flagged 'missing' — the frontend title-fallback band-aid couldn't recover it (breaks on iTunes title mismatches).
Now the shared insert_or_update_media_track reads the disc number (Jellyfin .discNumber=ParentIndexNumber, Navidrome .discNumber, Plex .parentIndex), floors to >=1, and stores it in the INSERT + UPDATE. The disc_number column is ensured on init (it was only added by a migration that doesn't run on fresh installs, so the new INSERT would have hard-failed for new users). The enhanced album view already carries disc_number through (SELECT * -> dict), so the display fixes itself once the column is populated — a re-scan backfills existing libraries. Seam-tested across Jellyfin/Navidrome/Plex shapes + the floor-to-1 + re-scan-update cases.
Opens to the same category-card landing the Discovery Pool uses, with two cards: 'guesses to review' (unverified wing-it) and 'resolved manually' (ones you've Fixed) — click to drill in, Back to return. Previously it jumped straight to a single list.
To populate the resolved list, the /fix endpoint now stamps was_wing_it on the rewritten extra_data (the wing_it_fallback flag is otherwise lost on fix), and get_wing_it_pool gained a resolved flag + the stats return both counts. Fixing/re-matching from either card refreshes in place. Seam test updated for both states.
Wing It auto-matches tracks to the server library on a best-effort guess; those tracks are flagged wing_it_fallback in extra_data and count as 'discovered', so the Discovery Pool hides them — there was no way to see or audit the guesses. New 'Wing It Pool' button (next to Discovery Pool on the Mirrored Playlists tab) opens a modal listing them with a per-playlist filter + search; 'Fix Match' reuses the Discovery Pool's fix flow (/api/discovery-pool/fix), and a manual match drops the track from the pool on refresh.
No new table or provider hooks needed — the wing-it flag is already persisted, so this is a pure query (get_wing_it_pool / get_wing_it_pool_stats, cloning the failed-pool LIKE pattern) + a /api/wing-it-pool endpoint + a cloned modal. Found 81 wing-it tracks on a real library. Seam-tested (include unverified / exclude manual-matched / scope by playlist+profile).
The card is an <a> link and the shell's capture-phase link handler navigated to artist-detail before the grid's bubble-phase badge handler could preventDefault — so clicking the watchlist eye or a source badge opened the detail page (and the badge's own link too). The shell handler now bails when the click lands on an in-card control (.source-card-icon or [data-no-card-nav]), letting the badge do only its own thing.
Each streaming source (Tidal, Qobuz, HiFi, Deezer, Amazon) carried a
"X Download Quality: Quality is set globally in Quality Profile…" note. The
ranked-target profile already drives every source's tier via
quality_tier_for_source, so these were pure noise. Removed all five; the auth /
status / token fields in each container are untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the side-scrolling column board with vertically-stacked interval lanes (hourly) and day lanes Mon-Sun (weekly). Empty intervals/days collapse to thin dashed strips, busy ones grow; scheduled playlists flow as cards within a lane. Kills the horizontal scroll + the wasted whitespace of the old kanban columns, and the two boards now share one cohesive design.
Polish: accent gradient wash + gradient interval numerals + count badge on filled lanes, drag-over glow/lift, card pop-in animation, hover states. Also preserves the board's scroll position across the full re-render so dropping/removing a playlist no longer snaps it back to the top. Same drag-and-drop handlers + scheduled-card content reused; old column CSS is now unused (harmless).
Quality-profile settings UI cleanup:
- Add the "Rank-based download order" toggle (priority mode). It's hidden when
Best quality is active, since that mode always ranks by quality.
- Plain-language search-strategy options ("fast" / "thorough"); load + save the
new rank_candidates_by_quality flag.
- Move the long help texts behind a dim ⓘ icon that sits on the (fixed) label
row and toggles a collapsible body below — the trigger no longer moves on
open. Applied to: search strategy, rank-based order, off-list fallback,
AcoustID-verified, and the "How it works" ranked-targets explainer.
toggleSettingHelp walks to the next .setting-help-body sibling so it works
regardless of wrapper or an in-between control.
- Fix the "Search strategy" label: zero the flex-row margin so it aligns with
the ⓘ, and bump it to 12px/brighter so it doesn't read as dim/undersized.
- Remove the duplicate "🎵 Quality Profile" heading inside the tile body.
- Replace the inline "Reset to defaults" link with a proper ↺ button.
- Restore the gap between the "Quality priority" label and the target list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an opt-in `rank_candidates_by_quality` profile flag. When on, the
priority-mode download walk orders candidates by the ranked-target quality
(confidence/speed only break ties) instead of confidence-first. Default off
keeps the byte-for-byte old behaviour, so existing installs are unaffected.
Best-quality search mode is always quality-first regardless of the flag; the
toggle only affects priority mode. Search-time source selection is unchanged —
nothing is skipped, so a track can never go missing, only the order in which
copies are tried changes.
The version-mismatch force-import follows automatically: it accepts the
first-tried (= best-ordered) quarantined candidate, which is the highest-quality
one once the walk is quality-first. No change to its selection logic needed.
- core/quality/selection.py: load_rank_candidates_by_quality() (fail-closed).
- core/downloads/task_worker.py: _best_quality_ordering -> _candidate_ordering;
quality-first when best_quality mode OR the toggle is on.
- database/music_database.py: default profile carries the flag (False).
- web_server.py: flag is preserved globally across preset apply/reset, like
search_mode.
- core/imports/version_mismatch_fallback.py: comment clarified (no behaviour
change).
Tests (TDD): load_rank_candidates_by_quality default/enabled/disabled/error;
_candidate_ordering across all mode+toggle combinations + fail-closed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two subsystems post-process the same completed transfer: the browser-poll
status endpoint (web_server) and the background download monitor. Both watch
the same slskd/streaming transfers and each launches the verification
pipeline. When one path quarantines + requeues the next-best candidate
(clearing username/filename, status -> 'searching'), the monitor's
already-submitted run_post_processing_worker then runs, finds no source info,
and falsely marks the task 'failed' ("missing file or source information") —
clobbering the in-flight retry while a parallel attempt imports the song.
Fix: a single atomic claim (downloading/queued -> post_processing under
tasks_lock) so exactly one path processes each download.
- runtime_state: new claim_for_post_processing() helper
- post_processing: race guard — worker bails (no fail/notify) if the task is
no longer 'post_processing' when it runs
- web_server: both poll paths (Soulseek + streaming) claim before launching;
claim is released on thread-launch failure
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The tab reads the v2 personalized framework (personalized_playlists), but the Discover page generates through the legacy path and nothing seeded those v2 rows -> the tab was empty. Fixes:
- New 'listening_mix' v2 generator: hands the scan's stored 'listening_recs_tracks_full' tracks to the personalized manager so the Listening Mix can mirror + Auto-Sync like every other kind (no pool hydration; can't shrink on rotation). Registered + tested.
- Sync tab now lists every registered SINGLETON kind (Listening Mix, Fresh Tape, Archives, Hidden Gems, Discovery Shuffle, Popular Picks) as a card, not just already-generated rows. Clicking 'Refresh & Mirror' runs the generator + mirrors. Variant kinds (decade/genre/daily) need a picker, so they're not auto-listed; existing variant rows still show.
Additive: new generator + frontend merge, no backend endpoint changes. End-to-end verified (refresh -> generate -> persist -> syncable tracks).
The mix is (artist, title) pairs acquired via Soulseek, so the recommendation fetch needn't match the user's active source. When the active source can't fetch top tracks (iTunes/Discogs/MusicBrainz — or Spotify when unauthed), fall back to Deezer's public artist/{id}/top (no auth, available to everyone). All five active sources now build a full mix without switching; the name-search + names_match guard still prevents wrong-artist results. New pure helper choose_mix_fetch_source + tests.
#913 was silently producing 0 recs: similar_artists.source_artist_id is a SOURCE id (Spotify/etc.), but the scan keyed id->name by internal artists.id (resolved nothing), and the consensus ranker was fed the name-collapsed get_top_similar_artists (consensus could never fire). Fixed + elevated:
- id->name keyed by source-id columns; raw per-seed edges (real consensus); similarity_rank threaded into the score; recency-weighted seeds (recent plays boost lifetime favs)
- new 'Based On Your Listening' artist row (/api/discover/listening-recommendations) with 'because you listen to X' explanations
- new 'Your Listening Mix' track row: each rec's top tracks via a guarded, name-resolved Spotify/Deezer fetch (falls back to the discovery pool), stored as full render dicts so the row can't shrink on pool rotation
- pure tested core: similarity_from_rank, build_recency_weighted_seeds, to_mix_track, names_match (+ rank-aware grouping)
Fresh Tape (5-10 tracks): future-dated albums sorted to the top of get_discovery_recent_albums and ate the 50-album budget before the is_future_release skip ran. Add exclude_future_years + fetch a generous budget; downstream caps unchanged. Regression tested.
Also drop the per-track block 'X' from the compact playlist rows (wrong spot). Plan/audit in DISCOVER_BEST_IN_CLASS_PLAN.md.
Video-side automations (owned_by='video') live in the shared automation-engine DB and were rendering on the music automations page across branches. Filter them out client-side — the /api/automations endpoint is shared with the video page + auto-sync board, so it can't filter server-side. Pure no-op for anyone without the video side (they have no such rows); auto_sync rows untouched.
The SoundCloud/Amazon/Tidal/Qobuz/Deezer/HiFi/Lidarr clients did an UNGUARDED
mkdir(parents=True) on the configured download path in __init__. With a Docker
'/app' path (or any unmounted/misconfigured volume), that raises Permission
Denied, the plugin registry nulls the whole client, and the source vanishes —
SoulseekClient already guards the identical mkdir and just warns. Outside the
container this also failed every test_download_orchestrator_soundcloud.py test
(10) by leaving client('soundcloud') = None for the patch targets.
Fix: wrap the mkdir in try/except OSError + warn (matching soulseek) across all
seven clients and the orchestrator's runtime path-update; the dir is created
lazily at download time. Real robustness win: a slow/unmounted volume at boot no
longer silently drops download sources. Regression test forces an uncreatable
path and asserts init doesn't raise — pinned in any environment.
Full suite green: 6713 passed, 0 failed (was 10 failed).
A wing-it fallback track shows download_status='wishlist' (the sync stamps that on
every unmatched track) but was never actually added — the sync skips wing_it_* for
the wishlist. Showing '→ Wishlist' implied it was wishlisted. Now those rows read a
muted, non-actionable 'Unmatched' instead. Real wishlisted tracks keep the amber
'→ Wishlist' re-add button.
'sami matar' was a wing-it FALLBACK stub — a placeholder the discovery pipeline
makes when it can't resolve a track to real metadata (no album, no cover). The
live sync explicitly skips wing_it_* ids for the wishlist (no metadata to act on),
but my re-add didn't — so it stored a coverless, single-classified placeholder.
That's why: sync didn't add it, no images, marked single.
Fix (parity): reconstruct refuses ids starting 'wing_it_'. Frontend renders the
'-> Wishlist' status as plain, non-clickable text for wing-it rows (with a tooltip)
since they were never actually wishlisted. Real tracks keep the working button +
the byte-identical-payload re-add from the prior fix.
Root cause (the real one): the auto-add passes original_tracks_map[id] — tracks_json
run through a specific normalization (album->dict with images/album_type/total_tracks/
release_date, artists->dicts). My re-add hand-rolled a different shape, so the stored
spotify_data didn't match and the wishlist's nebula (which reads spotify_data.album.
images[0].url) had no cover, plus album/single classification could differ.
Fix: extract that normalization into one shared build_original_tracks_map() and use it
in BOTH the live sync (core.discovery.sync) and the re-add. The re-add now resolves the
track by source_track_id through the same map — byte-identical payload. Verified on a
real sync row: re-add payload == live-sync payload, album.images present. (The shared
normalizer is also copy-safe, fixing a latent tracks_json mutation in the old inline
version.)
Fallback (track absent from tracks_json) rebuilds through the same normalizer with the
cover seeded from the row's image_url. 10 tests incl. a direct parity assertion.
The re-add showed no album/single art. Cause: reconstruct returned the full track
from tracks_json AS-IS — and some syncs store tracks_json lean (no album.images),
so the re-added wishlist entry had an empty album.images even though the track's
cover was sitting right there in the track_result's image_url.
Fix: always backfill album.images from the track_result's image_url when the album
has none (and copy the dict so tracks_json isn't mutated). Real album art is kept
when present; the 250px thumb only fills a gap. Verified against a real sync row in
all three cases (full / lean tracks_json / no tracks_json) — album.images now
populated in every one. The wishlist card reads album.images, so the cover shows.
In the dashboard Recent Syncs detail modal, the '→ Wishlist' status on unmatched
tracks is now a button. Clicking it re-adds that exact track to the wishlist with
the SAME context the sync used (source_type='playlist' + the playlist's name/id +
failure_reason), so it's indistinguishable from the original auto-add.
- reconstruct_sync_track_data() (pure, tested): prefers the full cached track from
tracks_json (by source_track_id, then index) so album art/full data carry over;
falls back to the track_result fields; refuses non-'wishlist' rows and rows with
no id (can't re-wishlist a matched/unidentifiable track).
- POST /api/sync/history/<id>/track/<i>/wishlist resolves the entry server-side and
calls the wishlist service; idempotent (reports added vs already-on-wishlist).
- button shows a busy state then '✓ Re-added' / '✓ On wishlist'.
7 pure tests (full-track preference, id-vs-index match, fallback rebuild, non-
wishlist + out-of-range refusal). JS/PY/ruff clean.
Quarantine rows now display the download service (HiFi / Soulseek / Tidal …)
on a third line, matching the Completed view's source line. Derived from the
entry's source_username (a streaming service name passes through; a Soulseek
uploader/peer collapses to "Soulseek") and rendered with the same
adl-row-batch styling + _adlSourceLabel mapping the Completed rows use.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Keeps the reference presets (96/128/192/256/320) but adds "Custom…", which
reveals a number input so you can type any minimum bitrate. addRankedTarget
reads the manual value when the dropdown is on Custom.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Convenience: pick a group + constraints (e.g. All lossless, ≥24-bit/≥96kHz) and
it expands into one concrete per-format target each (FLAC/ALAC/WAV, or the five
lossy formats) at that slot — so you don't add them one by one. Purely UI; the
backend still ranks concrete per-format targets. Re-adding a group skips formats
that already have an identical target, and the expanded entries can be
reordered/pruned individually afterwards.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- dark-style the format <optgroup>/<option> so the dropdown no longer shows
light "bars" over the dark theme (mirrors the existing
.library-source-filter-select optgroup treatment)
- replace the fiddly tiny kbps number input with a dropdown of reference
bitrates (Any / ≥96 / ≥128 / ≥192 / ≥256 / ≥320, default 320) — no typing,
consistent with the lossless bit-depth/sample-rate selects
- bump control font 11→13px, larger padding + min-height, format select
min-width so the row is comfortable to use
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ranked-target list is now the single source of truth for which formats
download, in the user's exact priority order, for ALL sources — no hardcoded
format hierarchy decides anything. A candidate passes only if it matches a
ranked target; if nothing matches, the existing Use-Fallback toggle decides.
- source_map: new shared format_from_extension() + AUDIO_EXTENSIONS — one
source of truth for extension→format used by every extension-based source, so
adding a format lights it up everywhere. Soulseek now classifies through it
(opus/wav/aiff were previously dropped as 'unknown').
- file_ops.probe_audio_quality (generic import-time guard, all sources): add
WMA; detect ALAC from the real codec (an .m4a is AAC or ALAC).
- soulseek: drop the AAC-specific opt-in gate — AAC now follows the same
universal rule as every format.
- model.tier_score: documented as ONLY a same-format tiebreak + fallback order,
never cross-format priority (the list owns that); add opus/alac bases.
- UI: ranked-target editor offers all formats (FLAC/ALAC/WAV·AIFF lossless with
bit-depth+sample-rate; MP3/AAC/OGG/Opus/WMA lossy with min-bitrate).
- tests: AAC retargeted to the universal model; new coverage for
format_from_extension and matches_target across all formats.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes align across all three servers. Jellyfin reorders in place: DELETE the
extra entries (Mirror) then POST /Playlists/{id}/Items/{entryId}/Move/{index} for
each desired track in ascending order — so the playlist's poster/name/Id survive
(no delete-recreate), same as Plex/Navidrome. Mirrors the existing reconcile path's
entry-id handling (PlaylistItemId via /Playlists/{id}/Items).
- jellyfin reorder_playlist() + get_playlist_track_ids(); reuses the shared, tested
plan_align_rewrite planner (no new pure logic).
- /align endpoint + frontend gate now cover navidrome|plex|jellyfin.
UNTESTED LIVE: no Jellyfin instance to verify against (same status as the Navidrome
path). Plex is the only one confirmed working end-to-end so far.
The align buttons were gated to Navidrome, so Plex users (the actual tester) never
saw them. Plex reorders in place via plexapi moveItem/removeItems — preserves the
playlist's poster/summary/ratingKey (no delete-recreate), same spirit as Navidrome's
overwrite.
- plex_client.reorder_playlist(): moves each desired track into sequence, removes
any current item not in the ordered list (Mirror drops extras; Keep includes them).
get_playlist_track_ids() feeds the shared tested plan_align_rewrite.
- /align endpoint dispatches navidrome + plex; reuses the pure planner for both.
- frontend gate opened to navidrome|plex.
- modal redesigned: cover art per row, gradient header, pop/fade animation, hover
rows, real polish (was a plain numbered list).
plexapi moveItem/removeItems signatures verified against the installed version.
The server-order list wasn't flex-shrinking, so a long tracklist pushed the
align footer past the dialog's 80vh cap and overflow:hidden clipped it. Make the
list flex:1/min-height:0 (scrolls) and the footer flex:0 0 auto (always visible).
Adds the 'Align playlists' action to the out-of-order modal — a dedicated,
order-only write path that does NOT touch the normal sync. Subsonic has no
per-track move, so it overwrites the song list in source order via createPlaylist
+ playlistId (same primitive replace-mode uses; identity/id preserved).
- plan_align_rewrite() (pure, tested): matched server ids in source order; every
one must already be in the playlist (never injects a track); extras either
dropped ('Mirror source') or parked at the end ('Keep extras'); returns None on
stale data so a vanished track can't be written.
- navidrome rewrite_playlist_order() primitive (raw ordered ids).
- /api/server/playlist/<id>/align: validates ids are in the live playlist, then
rewrites. Navidrome-only for now (Plex/Jellyfin reorder = follow-up).
- modal gets two explained options; missing tracks are NOT added (normal sync's
job) and that's stated. Metadata-free by design — it only reshuffles existing
server ids, so there's no sync-parity surface.
Open: confirm createPlaylist+playlistId preserves the playlist comment/image on a
live Navidrome (same risk as replace mode); add a re-apply step if it doesn't.
- file_ops.probe_audio_quality: .aiff/.aif were opened with mutagen.wave.WAVE,
which can't parse AIFF — it raised, failed open, and let AIFF silently bypass
the quality filter. Route aiff/aif to mutagen.aiff.AIFF (still the 'wav'
lossless tier).
- test_hifi_preview_guard: _get_hls_manifest gained an expected_duration_s kwarg
and the start tier now comes from quality_tier_for_source (default profile ->
'hires'); accept the kwarg and pin the tier so the chain is deterministic.
- test_quarantine_management: quarantine_group_key intentionally no longer uses
source-specific ids/uri (they break cross-batch sibling matching); assert the
isrc -> normalized-name contract instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
repair_worker had TWO _fix_quality_upgrade methods; the legacy one (expecting
expected_title/_fix_action) shadowed the new findings-based one (matched_track_
data), so applying a Quality Upgrade finding failed live with "No title/artist".
The "remove dead functions" refactor missed it. Merge into a single handler:
matched_track_data -> wishlist (safe pattern, no auto-delete on redownload) plus
_fix_action='delete' -> remove file + row. Also drops the duplicate dispatch key.
test_quality_upgrade: the pure-decision tests called deleted helpers
(meets_preferred_quality / classify_track_quality / preferred_quality_floor /
RANK_*). Rewire them to the shared v3 API (targets_from_profile +
quality_meets_profile); drop the few that only pinned deleted internals; update
the scan stubs for the new resolve_library_file_path/_read_file_ids signatures.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PR #896 removed the per-source quality dropdowns; streaming sources now derive
their request tier from the global profile via quality_tier_for_source. Without
a migration, a user who had tidal_download/qobuz/hifi_download.quality on
'hires'/'hires_max' silently dropped to lossless (their migrated v2 'flac (any)'
top target resolves to the lossless tier).
_migrate_v2_to_v3 now seeds the 24-bit FLAC ladder at the top of ranked_targets
when such a Hi-Res source preference is detected — but only if the profile
doesn't already express 24-bit, so it never duplicates the ladder.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 2.7.4 merge replaced the bucket-based quality filter with core.quality.model
and silently dropped #886's AAC handling. Restore it in the new model:
- v2_qualities_to_ranked_targets maps an enabled 'aac' tier to a format-only
target (slskd AAC rarely carries a bitrate, so no min_bitrate gate); priority
order (above MP3, below FLAC) comes from the caller's sort.
- soulseek filter_results_by_quality_preference drops AAC/.m4a candidates when
the profile has no AAC target, so AAC stays OFF-by-default instead of slipping
through via fallback.
- the three factory presets carry the legacy qualities dict again with the
disabled aac tier (used by the settings UI + the #886 opt-in toggle).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#3 priority mode is quality-agnostic again: search_with_fallback (the
priority/hybrid path — best_quality has its own search_all_sources) returned
the first source whose results met a target, so an mp3 with bitrate=None (slskd
omits it often) was deemed unsatisfied and deprioritised, changing which source
wins for users who never opted in. Restore "first source with tracks wins",
byte-for-byte; cross-source quality pooling stays in best_quality mode.
#4 metadata-less FLAC no longer over-claims a hi-res target: matches_target let
a FLAC with no sample_rate/bit_depth satisfy a 24-bit/192k target while a real
16/44 FLAC failed it, so unknown-spec files outranked and discarded genuine CD
FLAC under audiophile/hi-res profiles. An unconfirmable spec now fails the strict
tier and falls to the plain-flac bucket.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#1 DB init crash: the idx_lh_verification_status index was created before the
ALTER TABLE that adds the column, so a fresh library_history (every existing
install + clean checkouts) died on startup with "no such column:
verification_status". Move the index after the column migration.
#2#652 quarantine loop returns: the rewritten
filter_results_by_quality_preference dropped the _drop_quarantined_sources()
pre-filter, letting a previously-quarantined (user, file) win the picker again
and re-quarantine forever. Re-wire it at the top of the method.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The editor renders the server column in SOURCE order (reconcile_playlist pairs
each server track to its source row), so a reordered-but-same-membership playlist
read as '5 matched / in sync' when Navidrome's real order actually differed — the
reorder never reaching the server was invisible.
- compute_order_status() (pure, tested): matched tracks' server positions must be
strictly ascending in source order; uses RELATIVE order so missing/extra tracks
never false-flag. reconcile entries now carry server_index (additive).
- endpoint returns order_status + server_order (the server's actual sequence).
- editor shows an amber 'out of order' badge on the server column when membership
matches but sequence differs, opening a read-only modal of the real server order.
One-way: source order stays the source of truth; no server-side editing.
Tests reproduce the reported 'Real Love Baby moved to #2' case + guard against
false-flagging on missing/extra. The actual 'sync order' WRITE is a separate
follow-up (membership/extra semantics + live identity-preservation test pending).
The limit=200 fix only helped FRESH fetches. The metadata cache is persistent
(SQLite, 30-day TTL), so any album whose tracklist was cached at 50 BEFORE the
fix keeps returning 50 from cache in every window that loads it (line returned
the cached entry without revalidating) — which is why the Standard-view add-album
modal still showed 50 while a freshly-fetched album in the download window showed
the full list. Same album, different cache state.
Fix: get_album_tracks now marks freshly-fetched entries '_complete'. On a cache
hit, a legacy entry without that flag is revalidated against the album's known
trackCount (from collection metadata, unaffected by the bug) and re-fetched if
short. The '_complete' flag makes the heal one-time and avoids a re-fetch loop on
region-restricted albums where available tracks < trackCount.
Tests: stale-truncated -> refetch+heal; _complete -> trusted; legacy-complete and
unknown-trackCount -> trusted (no regression). Fresh fetch carries _complete.
video_library.db wasn't covered, so it showed up as untracked and could be
committed by accident (live user data). Generalize the database patterns to a
glob so every current/future app DB is auto-ignored. database/*.py sources are
unaffected. (config/youtube_cookies.txt was already ignored — it only slipped
through earlier because it had been force-staged.)
A Spotify Free (no-auth) user saw 'Showing Discogs results - not from your
primary source (Deezer)' on the manual album-import search. Root cause:
get_primary_source() deliberately downgrades an unauthenticated Spotify to the
working fallback (deezer) so client routing always yields a usable client - and
the import payload reused that FUNCTIONAL value for the LABEL. The free source
has no album-name search (SpotifyFreeMetadataClient.search_albums() returns []),
so falling back for results is correct; only the label was wrong.
Fix: get_primary_source_label() preserves the user's configured intent (Spotify
Free reads as 'spotify') without touching client routing or the search chain.
The import album/track/suggestions payloads now return the label; the functional
source still drives the hydrabase-enqueue + fallback chain. Banner now reads
'not from your primary source (Spotify)'.
Tests: seam tests for get_primary_source_label + route regression pinning the
label/functional decoupling; updated 4 existing import-route tests.
RANK_LOSSLESS/320/256/192/BELOW, _PROFILE_KEY_RANK, classify_track_quality,
preferred_quality_floor, meets_preferred_quality, _rank_label — none of these
were called from scan(). The job already uses rank_candidate() + targets_from_profile
from the quality model, which is fully profile-driven (no hardcoded thresholds).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
HTML <select> options can only store string values, so setting_options booleans
([True, False]) were serialised as 'true'/'false' strings and sent to the API.
Python's `x is True` check returned False for the string, making require_top_target
and deep_audio_verify permanently read as False regardless of what the user saved.
Fix JS: convert 'true'/'false' strings to real booleans before POSTing.
Fix Python: _to_bool() in quality_upgrade + inline coercion in scanner to handle
both existing string values in config and correct future booleans.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
With require_top_target=True the old code used check_targets = targets[:1]
plus quality_meets_profile — which fell back to ALL targets when only one
target was configured, so single-target profiles (FLAC (any) or FLAC 24-bit
alone) never flagged anything.
Replace with a direct rank_candidate(measured_aq, targets) call: skip only
when idx==0 (file already at rank 0, the top tier). Any lower rank (or no
matching rank) is flagged for upgrade search. This is fully profile-driven:
a library of 16-bit FLACs against a [24-bit/192, 24-bit/96, 24-bit/44, 16-bit]
ranking will flag the 16-bit files; a library already at 24-bit/192 won't.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move the "▾ N more" toggle out of .verif-actions into a dedicated
.verif-quar-alt-slot div (min-width: 68px) so every row reserves
the same horizontal space — action buttons now stay aligned whether
a row is a single track or the head of a group.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Quarantine grouping:
- First candidate shown as normal row; others hidden under a "▾ N more"
button inline in the actions bar — no separate header row
- Group open state tracked in _verifQuarOpenGroups (Set), survives
periodic re-renders so the list no longer auto-collapses
Quality Upgrade Finder:
- Default scope changed from 'watchlist' to 'all' so it scans the whole
library when no scope is explicitly configured
- _get_settings rewritten to read the full settings dict at once
(same pattern as QualityUpgradeScannerJob) to fix silent read failures
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
resolve_library_file_path() was called without config_manager, so
_collect_base_dirs() returned an empty list and every track stored
with a relative path (Artist/Album/track.flac) resolved to None.
probe_audio_quality was never called → 0 files checked.
Also threads resolved_path through _read_file_ids to avoid a second
redundant resolution pass on the same file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes the duplicate Quarantine tab from the Library History modal and
brings the same-song grouping feature into the ⚠ Unverified/Quarantine
filter on the Downloads page.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When approving from the quarantine manager (no task_id in request), the
re-import ran through the simple pipeline path with no task to update, so
the downloads list stayed on 'failed' until the batch drained.
Scan download_tasks for the task whose quarantine_entry_id matches the
approved entry. If found, re-run through the verification wrapper with that
task_id so the task is marked completed immediately in the live downloads list.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Race condition: cancelling the retry task didn't stop an already-in-flight
download from completing and creating a new quarantine entry via the pipeline.
- Approve endpoint now sets _quarantine_approved_alternative=True on the
cancelled task alongside status='cancelled'
- Pipeline completion handler checks this flag when _acoustid_quarantined
is set: immediately deletes the freshly-created quarantine entry and
returns, so no stale entry accumulates from the race
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Source-specific IDs (Spotify id, Qobuz id, URI) break sibling detection
when the same song is imported from different playlists or sources, since
each batch produces a different id: key. Siblings from a second batch were
never recognised and stayed in quarantine after approving the first.
Drop id:/uri: fallbacks; keep only ISRC (truly universal) and nm:artist|track
(stable across all sources). This correctly groups all quarantine entries for
the same intended target regardless of which batch or source produced them.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Multiple quarantine entries accumulate per track (one per retry attempt)
and approving one left the others behind and kept the retry running.
- Approve now sends remove_siblings=true — backend sibling deletion was
already implemented but the flag was never passed from the UI
- Toast message now reports how many duplicate candidates were removed
- After approve, the backend cancels any in-flight quarantine-retry task
for the same track (matched by title) so the engine stops fetching new
candidates once the user has already accepted one
- Approve All also sends remove_siblings=true
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Quarantine list was only fetched once on page load — new entries created
during a running batch never appeared without a manual tab click.
- _adlFetch (2 s poller) now also calls _verifLoadQuarantine(true) every
7th poll (~14 s) so quarantine entries appear shortly after they land.
- Retry badge now shows 🛡 and a clearer tooltip when retry_trigger is
'acoustid' or 'acoustid_unverified', making it visible during the
quarantine-retry cycle that a previous candidate was quarantined.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
safe_move_file used shutil.move, which for a CROSS-filesystem move (downloads volume ->
library volume, common in Docker/NAS) copies the file to the FINAL path incrementally. A
media-server real-time watcher (Jellyfin) can catch that partial file mid-write and cache it
with null/incomplete metadata — tracks landing with no disc. Inconsistent because it only bites
cross-fs and races the scan tick; 're-add library' fixes it (rescan reads the now-complete file)
and the on-disk tags are fine — exactly the reported symptoms.
Fix: same-fs uses an atomic os.replace (also overwrites dst); cross-fs copies to a HIDDEN temp
sibling, fsyncs, then atomic os.replace into place (+ temp cleanup on failure). A watcher only
ever sees the COMPLETE file. EXDEV/EPERM/EACCES + the old string check route here, so detection
is strictly broader than before.
Tests: same-fs move, simulated EXDEV routes to the atomic path and leaves no partial temp, helper
completes+cleans, helper cleans temp + preserves source on failure. Existing replace-destination
test still green; 574 imports+relocate tests pass.
Wires the pure recommendation core into curate_discovery_playlists via a new self-contained,
double-guarded method _build_listening_recommendations: gathers inputs already in the DB (top 30
played artists as seeds + play_count weight, the similar_artists graph, a library id->name map +
owned set, discovery-pool tracks) — NO new network — runs group_similars_by_seed -> rank ->
aggregate, and stores results under NEW keys (metadata 'listening_recs_artists', curated playlist
'listening_recs_tracks'). Additive: touches no existing BYLT/curation logic, writes no existing
key, and both the call site and the method body are try/except-wrapped so it can never disturb the
scan.
Phase-1 candidate tracks come from the discovery pool (like BYLT); a later phase swaps in a direct
top-tracks fetch for pool-independent coverage. py_compile + ruff clean; 51 watchlist tests green.
The stored similar_artists rows key the similar artist by the SEED's source/db id, not its name,
so rank_recommended_artists can't consume them directly. group_similars_by_seed resolves each
row's source id to a seed name via a caller-supplied id_to_name map and reshapes to the
{seed_name: [{'name': similar}]} the ranker wants — the fragile id->name join, now pure + tested
(dataclass + dict rows, unknown-id drop, non-seed drop, group->rank end-to-end). 15 tests total.
New, fully-additive module — the heart of the 'expand Because You Listen To into a real
listening-driven block' plan. Two pure functions, no DB/network/config:
- rank_recommended_artists(seeds, similars_by_seed, owned): consensus-ranked artists you'd love
but don't own. Score = Σ over endorsing seeds of (play_weight × similarity) — rewards consensus,
play weight and similarity strength in one sum. Excludes owned + seeds; min_seed_count is the
adventurousness dial's lever; exposes seed_count + which seeds ('because you like A, B, C').
- aggregate_candidate_tracks(recs, top_tracks_by_artist, owned): per-artist-capped, deduped,
rank-ordered candidate list for the generated playlist; exclude_owned toggles discovery vs replay.
11 tests (consensus vs single, play-weight, similarity, owned/seed exclusion, min_seed_count,
case-insensitive dedup, per-artist cap, owned exclusion, total limit, empty-artist skip). Nothing
existing touched — wiring into the watchlist scan + playlist sync comes next.
Second leak of the same class: redownload_start built full album_data (release_date/album_type/
total_tracks) only in the Spotify branch. The iTunes and Deezer branches set just track/disc number
and left album_data lean ({'name': ...}), so single-track redownloads on those sources dropped the
$year — same symptom as #915 in the add/download path.
Fix: both branches now fetch the album via get_album_for_source (cached, source-aware) and build
album_data through the shared _album_data_from_source helper, mirroring the Spotify branch. Falls
back to the lean default if the fetch returns nothing (no regression). get_album is cached on both
iTunes and Deezer, so no extra API cost.
Tests: _album_data_from_source (full build, image-url fallback, defaults). 694 library+downloads
tests green.
Root cause: the only album-context backfill in the download path (hydrate_download_metadata) goes
through spotify_client.get_track_details — Spotify-only. An iTunes/Deezer-primary user's download
kept a lean context (no release_date), so the path dropped $year and the date defaulted to
YYYY-01-01 — until they ran a Reorganize, which reads the full album from the PRIMARY source. That
asymmetry IS the bug.
Fix: when the context is lean and the primary source isn't Spotify, hydrate it from that source via
get_album_for_source — the exact path Reorganize/Enrich use. Verified the primary source returns the
real data (live iTunes get_album for the reporter's album: release_date 2024-04-17, not 2024-01-01).
backfill_album_context_from_source is a pure, injected-fn seam: 6 tests (hydrate, no-op when
complete / spotify-primary / sentinel-id, stays-lean on None, swallows source errors). 552 downloads
tests green.
iTunes get_album_tracks called _lookup(id, entity='song') with no limit. The iTunes Lookup API
returns only 50 related entities unless limit is passed (max 200), so albums over 50 tracks showed
only the first 50 in the download window. Pass limit=200 on the main lookup AND the fallback-
storefront request.
Proven against the live iTunes API on the reporter's exact album (Frieren OST, id 1739445636,
70 tracks): no limit -> 50 songs, limit=200 -> 70 songs. Spotify already paginates; Deezer uses
limit=500 — iTunes was the only truncating source. Regression test asserts limit=200 is requested.
The import rebuilds the destination path from album metadata. When the albums row has no year,
release_date is empty, the path template drops $year, and the copied file lands in a NEW yearless
directory instead of the album's existing 'Album (YYYY)' folder. (The code logically forces this:
the year only drops when album.year is empty.)
Fix: when album.year is empty, recover it from a sibling track — its own year column, else a
(YYYY)/[YYYY] in the album folder name — so the rebuilt path matches the existing directory.
No-op when album.year is already set.
Tests: _existing_album_year_from_sibling covers year-column, paren folder, bracket folder, no-signal,
and target-slot exclusion.
Reporter's image 3 shows Reorganize maps all 62 multi-disc tracks correctly ('62 unchanged') —
it matches by title, proving the titles DO align on this album. My first normalizer DELETED
bracket content, so 'X - Main Theme' (file) vs 'X (Main Theme)' (canonical) would mismatch.
Reorganize treats brackets as separators (keeps the words); now _normTitleForMatch does the
same — drop only the (feat. Y) credit, turn every other separator into whitespace.
Verified: dash<->bracket, curly<->straight apostrophe, special<->regular hyphen, and feat all
normalize equal; distinct titles stay distinct.
Adds an opt-in setting (default off) to both Quality Upgrade jobs.
When enabled, a file only counts as "good enough" if it meets the
highest-priority target in the ranked profile — not just any target.
Example: with [FLAC 24-bit, FLAC 16-bit], a 16-bit file is flagged as
a candidate for upgrade even though it satisfies the profile's fallback
target. Finding titles say "Upgradeable" (not "Below quality") and the
description names the preferred target explicitly.
- quality_upgrade.py (Finder): reads require_top_target from settings,
builds check_targets = targets[:1] when on, improves finding description
- quality_upgrade_scanner.py (flag-only): same option + matching finding
title/description change
Pairs naturally with the best_quality search mode on this branch: the
scanner surfaces the candidates, the wishlist download picks the best
available version across all sources.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- pipeline: use trigger='acoustid_unverified' (not 'acoustid') when
require_verified=ON rejects an unconfirmed track — quarantine badge now
shows "ACOUSTID UNVERIFIED" instead of "ACOUSTID MISMATCH"
- web_server: /api/verification/config now also returns require_verified
- pages-extra: collapse the Unverified sub-view to quarantine-only when
require_verified=true (same path as acoustid_enabled=false); new trigger
entry in _VERIF_QUAR_TRIGGERS for acoustid_unverified
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Multi-disc albums store disc_number=1 for EVERY track in the library (verified across the live
DB — even a 175-track OST shows disc[1..1]; the scanner doesn't split discs). The enhanced view
matched owned<->canonical tracks strictly by disc:track_number slot, so every canonical disc-2+
track (slot 2:N) found no owned counterpart and was flagged missing ('62/72 · 36 missing').
_deriveEnhancedMissingTracks now matches each canonical track by slot first, then falls back to
title against any UNUSED owned track (consuming each owned track once, so genuine missings and
duplicate titles still count right). Display-only — no scanner/data change.
Verified by simulation (62 owned all disc-1, 72 canonical across 2 discs): old logic flags 36
missing, new flags 10 (the genuinely-absent tracks). Frontend-only; repo has no JS test runner.
reconcile_playlist read the existing playlist's track ids with str(t.id), but NavidromeTrack
exposes the Subsonic song id as .ratingKey and has NO .id attribute (append_to_playlist already
reads ratingKey — reconcile was the straggler). So current_ids came back EMPTY every time:
plan_playlist_reconcile saw an 'empty' playlist, re-added the entire matched set, and removed
nothing. Result: the playlist grew by the full track count on every sync (warl0ck: 5 songs, one
removed -> 9), in reconcile mode and whenever reconcile is the active mode.
Fix: read current ids via ratingKey, matching append_to_playlist.
Verified: tests/test_navidrome_reconcile.py drives the real reconcile_playlist with a stubbed
server — reverting the one-char change flips 3 tests red (they show it re-adding all 4 tracks),
the fix flips them green. Covers no-op resync, a removed track (remove, don't re-add all), and an
added track (append once).
sync_playlist computed deduped_tracks but the dispatch (append/reconcile/replace) sent the raw
valid_tracks — so a library track matched by more than one source entry was pushed multiple
times each sync. Extracted _dedupe_by_rating_key (tested) and routed all three modes through it.
This fixes the WITHIN-sync duplication. The cross-sync growth reporters describe (Navidrome
playlist doubling every resync) is a separate server-push issue still under diagnosis.
iTunes appends featured-artist credits to track titles ('The Chase (feat. Y)') while the user's
file is often just 'The Chase'. _normalize_title only stripped the parens, keeping 'feat y' as
words, so the title-match ratio fell below the 0.6 substring floor — and with no track-number
rescue the track was reported 'no matching track in the iTunes tracklist' even though it was the
right song.
Strip feat/ft/featuring credits (parenthesised anywhere, or a bare trailing 'feat. X') before
normalizing, so both sides reduce to the same title and match exactly. Guarded so 'The Feat',
'Defeat', 'Lift' aren't touched, and version differentiators (Remix) still hard-reject.
Tests: 8 new (strip variants + the exact no-tn failure + cross-match/remix regressions); 63
existing reorganize tests still green.
YouTube Music 'Liked Music' (and any large playlist) only returned ~104 tracks. Diagnosed it
to a YouTube/yt-dlp regression (upstream #16943): the webpage-based playlist path stops at the
first ~100-item continuation page. Not a SoulSync cap (no limit in the parse path) and not the
user's cookies/IP — reproduced on a fully-served public playlist.
Fix: pass extractor_args youtubetab:skip=webpage so yt-dlp pages via the InnerTube API instead.
Verified live on the reporter's setup: 100 -> 200 entries on a large playlist (the workaround is
itself partial upstream, but a major improvement until yt-dlp PR #16948 lands). Single touch point
— parse_youtube_playlist is the only place that lists a YouTube playlist.
YouTube's flat playlist extraction returns ONLY the title (verified: no artist/channel/uploader
field at all), so a track starts as 'Unknown Artist' and only gains a name if per-video recovery
succeeds. When recovery comes up empty (no cookies / age-gated / bot-checked) but the track still
matched confidently, the worker threw the match's artist away and left the column 'Unknown Artist'
— the #909 symptom.
Now the displayed yt_artist falls back to the matched artist when it's still Unknown. Display-only:
the match itself, track['artists'], cache, and download flow are untouched, so a real recovered
name always wins and an unmatched/error row honestly stays Unknown. Extracted resolve_display_artist
as a pure, tested seam; applied in the cache-hit and fresh-match result paths (the error path has
no match to draw from).
The #891 'also remove image/sidecar-only folders' toggle never worked. Job settings are
persisted as a nested dict under repair.jobs.<id>.settings (RepairWorker.set_job_settings),
but the scan read flat keys — repair.jobs.empty_folder_cleaner.remove_residual_files — which
never matched, so it always fell back to the False default and skipped every image/.lrc-only
folder. (remove_junk_files had the same mismatch but its default is True, which is why only the
truly-empty 'deleted' folder kept showing up.) Now reads from .settings like get_job_config /
lossy_converter do.
The pure dir_is_removable logic was already correct + tested; the bug was purely the config
read in scan(), which had no test. Added two scan-level regression tests driving the real
JobContext + a config that stores the toggle the way the UI does.
The first pass only checked spotify then itunes. But ~67% of a real library (46k/69k albums)
carry BOTH a spotify and itunes id, and the canonical priority is spotify>deezer>itunes>mb>…,
so a spotify-first guess diverges from what the Enhanced view actually tags/displays the album
as (e.g. a deezer-canonical album with an itunes id too).
Now redownload reuses _getEnhancedAlbumCanonicalSource (the view's single source of truth) and
fetches via the same /api/album/<id>/tracks?source= endpoint the view uses for its canonical
tracklist — so a redownload is always the exact edition on screen, across every source. The
stored spotify/iTunes id + a last-resort search remain as fallbacks. Frontend-only; the album
endpoints and canonical resolver are unchanged.
The Enhanced-view album Redownload only honoured album.spotify_album_id. For an iTunes-matched
album (no spotify id) it fell through to a fresh /api/enhanced-search and grabbed the FIRST hit
— which can be a different edition than the one you have (issue: matched the 66-track 'Original
Soundtrack Collection', got the 19-track 'Volume 1').
Now it prefers the album row's stored source id (spotify, then iTunes — the iTunes endpoint
already returns a Spotify-shaped payload) and only searches when neither exists. Also fixed the
search fallback to fetch from the MATCHING source endpoint instead of always hitting Spotify
(latent bug for iTunes search hits).
Frontend-only orchestration fix; no JS test runner in the repo, the album endpoints are unchanged.
Full Refresh INSERTs a per-track year (from file tags) into tracks.year, but that column
was only ever in the live INSERT — never in CREATE TABLE and never in a migration. So on
EVERY db (old and current — verified the shipped music_library.db lacks it too) every Full
Refresh track insert hard-failed with 'table tracks has no column named year', importing 0
tracks while artists/albums succeeded.
Fix (additive + nullable, nothing reads it but the writer):
- add year INTEGER to the tracks CREATE TABLE (new DBs)
- ALTER it onto existing tracks tables in _ensure_core_media_schema_columns (the repair
backstop that already runs every init), right beside the file_size repair
Tests (tests/test_tracks_year_migration.py): fresh-DB has it, nullable, idempotent, ALTERs
onto an old year-less table, and a regression that the exact Full Refresh insert fails
before the repair and succeeds after.
AcoustID 'could not confirm' (SKIP) is common for legitimate tracks not in its
fingerprint DB (new/obscure releases, classical, remixes, live, remasters,
non-Latin-script titles). Make the require_verified help text explicit that
turning it on quarantines all of those, so users expect manual review/approval
rather than discovering a flooded quarantine folder. Reinforces that the safer
default (off → import with the unverified badge) loses nothing and blocks nothing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New setting acoustid.require_verified (default off), shown under
Settings → Quality Profile only when AcoustID is enabled.
When on, an AcoustID SKIP (ran but couldn't confirm — no fingerprint match
or cross-script metadata, the ⚠ "unverified" case) is treated like a FAIL:
the file is quarantined and the next-best candidate is tried, instead of
importing an unverified file. Only a clean AcoustID PASS is kept.
Transient ERROR results (rate-limit / outage) are deliberately NOT blocked —
that would stall the whole pipeline during an AcoustID outage. Those still
import with their existing flag.
- pipeline.py: SKIP routes through the existing FAIL quarantine + retry path
(trigger 'acoustid') when require_verified is on.
- UI: checkbox under Quality Profile, visibility tied to acoustid-enabled via
syncAcoustidRequireVerifiedVisibility(); load/save wired in settings.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The two library quality jobs overlapped confusingly. Keep both, but make
each one's role obvious and put them on the same v3 quality definition.
Quality Upgrade Finder (quality_upgrade) — the ACTIVE job:
- Quality decision moved from v2 (extension + DB bitrate) to v3: probes the
REAL file with mutagen (measured bit depth / sample rate / bitrate) and
checks it against the profile's ranked targets — same as the import guard.
- New optional `deep_audio_verify` setting (default OFF): also run the ffmpeg
decode guard (truncation + silence); a broken file is proposed for replacement.
- Renamed to "Quality Upgrade Finder (active — proposes a replacement)" + help
text spells out it actively searches a better version and queues it.
- v3 helpers imported at module level so they stay monkeypatchable in tests.
Quality Check (quality_upgrade_scanner) — the FLAG-ONLY job:
- `deep_audio_verify` default flipped ON->OFF (the ffmpeg decode is the
CPU-heavy step; matches the download pipeline's default).
- Renamed to "Quality Check (flag only — you decide per finding)" + help text
contrasts it with the active Finder.
UI: deep_audio_verify setting label now shows "(ffmpeg decode — CPU heavy)".
Tests: scan() tests stub the v3 probe path (probe_audio_quality /
quality_meets_profile / resolve_library_file_path) since they use fake paths.
The v2 pure-function helpers stay (still unit-tested).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The audio-completeness guard (detect_broken_audio) is the only post-processing
step that fully DECODES the file with ffmpeg, making it the most CPU-heavy step.
Two changes reduce and gate that cost:
1. Single ffmpeg pass: astats (truncation) + silencedetect (silence) now run in
one chained -af filter over a single decode, instead of two full decodes.
~50% less CPU, no detection lost. Pure parsers unchanged.
2. Opt-in toggle: new post_processing.audio_completeness_check (default False).
The decode now only runs when the user enables it under
Settings → Post-processing → Core Features. Most preview/truncation cases are
already caught at the source (HiFi/Qobuz have their own guards), so the
expensive whole-file decode stays off unless explicitly turned on.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-running an export created a new LB playlist every time (LB keys on MBID, not name, and
create always mints a new one). Now remember which LB playlist a mirror was pushed to and
update it in place:
- listenbrainz_client: refactor batched-add into _add_tracks_in_batches; add
get_playlist_track_count, delete_playlist, update_playlist (verify exists -> clear items via
item/delete -> re-add -> edit title; reports gone=True if deleted on LB), and
create_or_update_playlist (update when we have a prior MBID, else create; falls back to
create if the remembered one was deleted). Stable URL/MBID across re-syncs.
- playlist_export_targets table + get/set_playlist_export_target: remember (mirror, target) -> LB MBID.
- export job consults/stores the target so push updates in place.
+6 mocked tests (clear+re-add same mbid, gone-fallback, create-or-update branches, delete). API
endpoints (item/delete, playlist/edit, playlist/delete, GET count) confirmed against LB docs;
live round-trip pending explicit auth.
The live export status was a separate flex child with flex-basis:100%, which became a
greedy item in the card's flex row and squished the info column to min-content (text
wrapping vertically). Inject the status into the card's existing .card-meta line instead
(same approach as the pipeline phase indicator) so it sits inline and leaves the row intact.
Removes the offending div + CSS.
Phase 6 (UI). Adds an export button to the mirrored-playlist card's hover action row (next
to rename/link/delete). Click -> a small on-brand modal to pick a destination (Sync to
ListenBrainz directly, or Download .jspf). Starts the background export, then polls status
and shows live progress on the card ('Matching 340/1000 · 312 matched' -> 'Synced · 947/1000
matched · view'). Reuses the tested backend job/endpoints; additive (new button + CSS + JS
functions, existing card render untouched apart from the inserted button).
Phase 5. Three additive routes + an in-memory job registry (new globals, no existing code
touched):
- POST /api/playlists/<id>/export/listenbrainz {mode: download|push} — spawns a background
thread that loads the mirrored playlist's tracks, resolves each to a recording MBID via
the waterfall, builds the JSPF, and (push) creates the playlist on ListenBrainz. Returns job_id.
- GET /api/playlists/export/status/<job_id> — live status (phase/done/total/coverage) for the
card to poll; omits the heavy JSPF blob.
- GET /api/playlists/export/download/<job_id> — downloads the built .jspf.
Reuses the tested cores (build_resolve_fn, resolve_playlist_tracks, build_jspf, create_playlist).
Phase 4. core/exports/export_sources.py supplies the real I/O behind each waterfall source
and assembles resolve_fn: cache -> DB (tracks.musicbrainz_recording_id via text match) ->
file tag (UFID/musicbrainz_trackid) -> live MusicBrainz match_recording. Every source is
fail-safe (any error -> None -> fall through, export never breaks). A fresh non-cache hit is
written back to the persistent cache so the same song is free next export. Sources are
injectable; build_resolve_fn wiring (cache short-circuit + write-back) is unit-tested. 4 tests.
Phase 3. Additive backbone for the export job:
- mb_recording_cache table (IF NOT EXISTS) + core/exports/recording_mbid_cache.py: persistent
(artist,title)->recording_mbid cache, mirrors album_mbid_cache (lazy DB, error-degrades to
miss). The MusicBrainz tail is ~1 req/s, so a resolved MBID is remembered once and reused
across every export/playlist.
- core/exports/playlist_export.py: resolve_playlist_tracks(tracks, resolve_fn) — walks tracks,
dedups repeated songs within a run (resolve once), builds the ordered pseudo-playlist, tallies
live stats (resolved/unmatched/deduped/by_source). Pure (I/O injected via resolve_fn + progress
callback), so dedup + accounting are unit-tested with no DB/network. 5 tests.
No wiring into runtime yet; nothing existing touched except the additive table.
Phase 2. Add create_playlist(title, tracks, public) to the LB client: POST /playlist/create
for the MBID, then add tracks in batches of 100 (MAX_RECORDINGS_PER_ADD) via the item/add
endpoint so 1k-track playlists don't hit a single-request cap. Returns a result dict
{success, playlist_mbid, playlist_url, added, requested, error} and never raises — partial
add failures are reported honestly (playlist created, added count accurate). Extends the
existing token-auth client; additive. 4 mocked-network tests (batching, auth, failure).
Phase 1 of exporting mirrored playlists to ListenBrainz. Two pure, fully-tested seams,
zero runtime wiring yet (additive, no regression):
- core/exports/jspf_export.py: build_jspf(title, tracks) -> ({"playlist": {...}}, summary).
LB's POST /1/playlist/create requires every track to carry a string identifier
'https://musicbrainz.org/recording/<mbid>' (text-only tracks are rejected), so tracks
without a valid recording-MBID UUID are dropped and counted in the coverage summary.
- core/exports/mbid_resolver.py: resolve_recording_mbid(artist, title, sources) — the
cheapest-first waterfall (cache -> DB -> file tag -> MusicBrainz) as a pure function over
injected (label, fn) sources. Short-circuits expensive lookups, treats a raising source
as a miss (one flaky MB call can't fail the export), reports the resolving source label.
API spec confirmed against LB docs: POST /1/playlist/create, 'Authorization: Token <t>',
{"playlist": {"title", "track": [{"identifier": "<mb recording url>", title, creator, album}]}}.
13 tests.
Standalone _run_soulsync_deep_scan did a path-only diff (untracked = transfer files
not in the soulsync DB) and shutil.move'd EVERY untracked file to Staging — no guard.
When the DB is empty/out of sync with disk (volume swap, DB reset, external Picard
tag edits) but Transfer holds the real library, that flags the whole library as
untracked and relocates all of it; Phase 5 then deletes the rows, and with Staging
cleanup on the files are gone for good. Reporter lost ~1,500 tracks into Staging.
The stale_guard the orphan detector + media-server deep scan already use (#828, #908)
was never wired into this path. Fix:
- core/library/standalone_scan.py (pure, tested): plan_standalone_deep_scan() diffs
untracked (separator-normalized) and decides whether the move is safe. Blocks when
the untracked share is implausibly large (>20 files AND >50% of Transfer — the
desync signature, via is_implausible_orphan_flood) or when the user marked Transfer
permanent. A normal batch of new arrivals still moves.
- web_server: consult the planner before Phase 4; on block, move NOTHING, leave files
in place, and surface a loud warning + activity item. Guard Phase 5 deletes too
(skip on desync-block or implausible stale share).
- 'Transfer is my permanent library — never move files out' toggle
(import.transfer_is_permanent) in Settings.
- tests/library/test_standalone_scan.py: seam coverage + the #904 regression
(empty DB + 1,500 files -> blocked, nothing moved).
No behavior change for in-sync libraries; the guard only trips on the desync pattern.
Profiling the actually-painted dashboard found two pure-waste GPU costs (no visual
payoff), reclaimable with zero degradation:
- backdrop-filter on cards whose backgrounds are already 90-99% opaque, so the blur
is invisible: service-card (x3), stat-card-dashboard (x3), activity-feed-container.
Dropped the filter, nudged opacity to ~0.97 so the unblurred sliver is imperceptible.
- redundant/near-invisible box-shadow layers on the two biggest elements: page-shell
(near-fullscreen — collapsed two stacked outer shadows to one) and sidebar (dropped
a duplicate layer + a 0 0 60px accent glow at 6% opacity that's barely visible but a
costly 60px-blur pass).
Targets the DURING-USE cost, not idle. sidebar-header keeps its blur (genuinely
translucent), and the cursor blob is untouched (that one's a real visual tradeoff).
The dashboard particle preset built a fresh createRadialGradient + arc-fill for all
50 glows every frame. But each glow's gradient is a fixed size/colour for the
particle's life — only the pulse ALPHA changes per frame, and canvas multiplies
image alpha by ctx.globalAlpha. So bake the gradient once into a per-particle
offscreen canvas (full alpha) and drawImage it each frame with globalAlpha = pulse
(times the incoming globalAlpha, so transition fades stay identical). Rebuilt only
when the accent colour changes.
Pixel-identical output: same colour, same linear falloff, same source-over; sprite
rendered at ceil(glowSize) then downscaled to exact glowSize. Drops 50 gradient
allocations + arc-fills per frame to 50 cached blits. Scoped to the dashboard
preset only (smallest blast radius); other presets untouched.
People report SoulSync working their machine hard at idle. On likely-weak devices
(<=2 cores, or <=2GB, or low on both: <=4 cores AND <=4GB) auto-enable reduce-effects
once and toast why ('lower-power device — turn effects back on in Settings').
Device-scoped via localStorage on purpose: a weak laptop must not flip the server
setting for the user's other machines. Acts only when this device has no stored
preference (null), so it runs at most once and never overrides an explicit choice.
Conservative thresholds avoid flagging capable boxes (a 4-core/8GB laptop isn't
touched; Firefox/Safari, which don't expose deviceMemory, only trip on <=2 cores).
Settings-load now prefers the device-level localStorage value over the server
default, so opening Settings no longer clobbers the per-device (auto or manual) choice.
GPU fill/blur cost scales with radius. The sidebar aura orbs already fade to
transparent at 70% of their gradient, so dropping their blur 40->28px shrinks the
composited bounding box with no perceptible softness loss. The frosted header's
backdrop-filter is re-blurred whenever the orbs drift behind it; 28->18px cuts
that per-frame work ~a third while keeping the frosted look. Relief is biggest on
weak GPUs (the machines people complain about).
The .sidebar-header::after ambient sweep animated `left` (-100% -> 140%) on an
8s infinite loop — forcing a layout recalc every frame it's on screen, on the
sidebar that's present on every page. Convert to transform: translateX() with a
pixel-identical travel path (element is 60% of header width, so translateX(400%)
== the old 240%-of-header sweep) + will-change. Compositor-only now; no per-frame
layout. Zero visual change.
Private YT Music playlists (a user's Liked Music, list=LM) need auth, but the
only cookie option was cookiesfrombrowser — a browser on the same machine as
SoulSync, useless on a headless/Docker box (and locked to whatever account that
browser happens to be signed into). Add a 'Paste cookies.txt' mode so users can
supply the exact session they want from any machine.
- core/youtube_cookies.py: pure seam — build_youtube_cookie_opts (cookiefile vs
cookiesfrombrowser precedence, mutually exclusive, fail-safe on a missing file),
looks_like_cookiefile (needs a real cookie row; rejects junk/header-only),
write_pasted_cookiefile (validate + 0600 write; blank/junk never clobbers a saved file).
- _youtube_cookie_opts() delegates to the seam, so every yt-dlp call site gets it.
- /api/settings pops cookies_paste before the generic persist, validates (400 on
junk), writes config/youtube_cookies.txt, stores only the path (blob never hits config.json).
- Settings dropdown gains 'Paste cookies.txt'; selecting it reveals a textarea.
- tests/test_youtube_cookies.py: precedence, validation, fail-safe write (11 tests).
Tracks auto-downloaded from the playlist pipeline / wishlist / watchlist landed as
01/1 even though they belong to multi-track albums (wolf/Sokhi; verified live —
Deezer says 'Obelisk' is track 9 of The Grand Mirage, Olives is 3/4, etc.).
Root cause, located in code: discovery doesn't carry a per-track position for
sources whose search/track endpoint omits it (Deezer search, MusicBrainz
recordings — only their ALBUM endpoint has it). detect_album_info_web then set
'track_number': track_info.get('track_number') (= None) and never looked it up
from the album it HAD identified (context.py); the pipeline floored it to 1. The
one helper that does an album lookup only ran for the no-album-context branch and
is gated off by default. Not isolated to Deezer — the gap is source-agnostic.
Fix: when the album is known (album_id present) but the position is missing,
resolve the REAL (track_number, disc_number) from the album's own track list via
the source-agnostic get_album_tracks_for_source — using the album id discovery
already picked (no re-search, no edition guessing). Matches by ISRC -> source
track id -> title. Fail-safe: any miss/error leaves the number untouched, so it
still falls through to the filename exactly as before — never worse than today.
kettui: pure seam core/imports/album_position.resolve_track_position_in_album
(I/O-free, ISRC>id>title priority, skips position-less entries) + a fail-safe
integration wrapper, both covered — 11 tests incl. the 'Obelisk = 9/12' case,
priority resolution, and never-raises-on-fetch-error. 788 import/context/pipeline
tests green, ruff clean.
Confirmed from Sokhi's FLAC tags + screenshot: disc-2/3 tracks land in the 'Disc 1'
folder, collapsing every disc's track 3/4/5/6 into one folder. Root cause: the
import pipeline syncs the resolved TRACK number into album_info (so the folder
matches the tag — pipeline.py '[FIX] Updated album_info track_number') but never
did the same for DISC. So the 'Disc N' folder (built from album_info.disc_number,
often 1) used a different disc than the embedded tag (resolved per-track in
source.py — e.g. 2/3 from a MusicBrainz multi-medium release).
Fix: one SHARED resolver, resolve_disc_for_track(original_search, album_info),
used by BOTH source.py (the tag) and the pipeline (which now writes it back into
album_info before building the path). Same function + same inputs (the pipeline
pulls the identical get_import_original_search(context)), so folder and tag can
never disagree. Returns the first valid positive disc (per-track, then album),
else 1 — a falsy/unknown per-track disc falls through to the album instead of
flooring early.
Tests: resolver preference/fallback/floor + an explicit folder==tag lockstep check
incl. Sokhi's per-track-2/album-1 case. 2122 import/pipeline/metadata tests green.
Sokhi: some tracks in a multi-disc album showed up with a null disc in Jellyfin
and floated ungrouped above the disc sections (tracks 3/9/15). Mechanism: the tag
writer only wrote the disc tag when disc_number was truthy, and enrichment CLEARS
all tags before rewriting — so a track whose disc came back 0 / None / '' lost its
disc entirely. Those falsy values slipped through because source.py defaulted with
'is not None' (a literal 0 passed) and context.py's or-chain can yield None; this
happens especially when a track resolves to a different edition than its siblings.
Fix: normalize_disc_number() floors any value to >=1, and enrichment now writes the
disc tag UNCONDITIONALLY (like the track number) so a track is never disc-less.
source.py uses the same floor so the metadata dict (and the 'Disc N' folder org)
stays consistent. Valid multi-disc values are preserved untouched.
Tests: normalize floors 0/None/''/negatives/non-numeric -> 1, preserves 1..4 and
tolerates '2.0'. 1406 enrich/metadata/track-number tests green, ruff clean.
NOTE: this fixes the SYMPTOM (never ungrouped). The deeper cause — a track matching
a DIFFERENT edition/release than its album siblings (the Persona-box-set mismatch in
the sample file; the canonical-version problem) — is separate and still open.
The mirror_playlist fix only assigns stable ids to newly-imported playlists, so a
user with an existing file-import playlist would still have empty-id rows (and dead
Find & Add matches) until a manual re-import. Add an idempotent startup backfill that
assigns the SAME stable id a fresh import would to any mirrored track missing one —
so existing matches start sticking with no re-import. Runs once per db/process (the
init is guarded), only touches empty-id rows (no-op afterward), native ids untouched.
Tests: backfill fills empty ids with the exact fresh-import id, is idempotent (2nd
run = 0), and leaves native ids alone.
A Find & Add on a file-import (CSV/M3U/TXT) playlist track was silently dropped and
the track re-appeared as 'extra' (radoslav-orlov). Root cause: unlike Spotify/YouTube
(native ids), file-import + iTunes-only tracks arrive with an EMPTY source_track_id —
and the whole manual-match system keys on it. _persist_find_and_add_match is a no-op
on an empty id, and find_manual_library_match_by_source_track_id returns None for one,
so the match can be neither recorded nor looked up. That's the youtube-vs-file
difference the reporter noticed.
Fix: stable_source_track_id() derives a DETERMINISTIC 'file:<hash>' id from the track
identity (artist|title|album, normalized) when there's no native id; mirror_playlist
assigns it so the SAME song gets the SAME id across re-imports/discovery — exactly
what the match lookup needs. Native ids are used verbatim; bonus: discovery extra_data
now survives a re-import for these tracks too.
Tests: helper (native passthrough, deterministic + case/field-insensitive, distinct
per song, empty-on-no-title, file: prefix); mirror_playlist integration (file tracks
get stable distinct ids, stable across re-import, native ids untouched). 319 playlist/
sync/discovery/mirrored tests green.
wolf39us: a Find & Add manual match got dropped on auto-sync (60->57) with a 404
fetching the stored Plex ratingKey. Root cause: Plex re-keys tracks on a metadata
refresh, and the SoulSync DB id IS that ratingKey — so until a SoulSync rescan BOTH
the durable match's library_track_id AND the file-path self-heal (which reads the
same DB) land on the dead key, fetchItem 404s, the track falls through to fuzzy
(also the dead key) and is dropped.
Fix: when both DB-side lookups miss on Plex, re-resolve the manual match against
LIVE Plex by the matched track's metadata, disambiguated by the stored file path
so the user's EXACT chosen track wins among multiple versions; return the current
live track and heal the stored id + sync cache. A manual match is now never dropped
or silently re-matched — it 'always overrides' as asked. Scoped to Plex (DB-backed
servers materialize off the DB and don't have this re-key problem).
Seam tests: file-path picks the right version over a live alternate, basename
fallback for server-vs-local paths, no-file-match falls back to top hit (never
drop), no results/empty title -> None, and a broken client never raises.
Bumps _SOULSYNC_BASE_VERSION to 2.7.5, defaults the docker-publish workflow's
version_tag to 2.7.5, and refreshes the release notes for the fixes/features
since 2.7.4: deezer real track numbers, special-edition cover art (release-scope),
the leading-'The' dedup, HiFi preview rejection (#895), M3U/M3U8 import (#893),
organize-by-playlist file naming, durable Find & Add match, ignore-list management
+ manual-add unblock (#897), and the Unraid template fixes (#899). WHATS_NEW +
VERSION_MODAL_SECTIONS rolled to 2.7.5 with 2.7.4 folded into the earlier-versions recap.
The album_tracks cache store (deezer_client) and the mutagen audio-length probe
(hifi_client) swallowed errors with try/except/pass. ruff S110 (selected by the
project) flagged them — they were the only 2 lint errors app-wide. Log at debug
instead. ruff check . now passes clean.
A MusicBrainz album resolves its art at RELEASE-GROUP scope even for a concrete
release (musicbrainz_search _release_to_album -> _cached_art prefers the rg mbid).
On the Cover Art Archive a release-group front is a single REPRESENTATIVE cover
(CAA picks one release to stand for the group, ~always the standard edition), so
a special edition like "Clair Obscur: Expedition 33 (Gustave Edition)" got the
standard art baked into cover.jpg + embedded tags at download time.
Add core/metadata/caa_art.fetch_release_preferred_art: try the specific release
own /release/<mbid>/front first, fall back to the existing release-group/provider
URL only when the release has no art of its own (404 -> None). Wire it into both
download_cover_art (cover.jpg) and embed_album_art_metadata (tags) so they stay
in sync. min_bytes defaults to 0 so the fallback keeps its prior behavior — this
can only ever ADD a better edition match, never strip a cover that showed before.
Caveat (documented, not fixed here): this only helps when the resolved release IS
the right edition. If the upstream match picked the standard release/group, that
is the separate canonical-version matching problem.
Tests: pure helper (prefers release art, falls back on 404/tiny/exception, no-mbid
passes through, nothing-available -> None). 667 metadata/artwork/deezer/hifi tests pass.
Files inside an Organize-by-Playlist folder were stuck with the library filename
(materializer hardcoded os.path.basename) — users wanted control over the naming,
e.g. a playlist-order prefix so a DAP plays them in order.
Add an opt-in FILENAME template "Playlist File Naming" (file_organization.templates
.playlist_item), tokens $position/$artist/$album/$track/$title. It is a filename,
not a path: validated to forbid "/" or "\" and to require $title, both in the
Settings UI (blocks save with a reason) and in core/playlists/item_naming.py, which
also fails safe at apply time — a bad/empty template falls back to the library
filename, so it can never produce a broken name. Default empty = current behavior.
Works for symlink AND copy modes (a symlink name is independent of its target).
Applied in _rebuild_one_from_db (the live reconcile/rebuild path), which has the
per-track metadata + playlist order; the pure FS materializer just gained an
optional dest_names override and is otherwise untouched. $position is zero-padded
to playlist width for correct sorting.
Tests: pure validate/render (slash + missing-title rejected, fallback, sanitize,
no-separator guarantee), FS-layer dest_names + collision disambiguation + back-
compat, and end-to-end through the DB rebuild (07->01 rename + empty-template
keeps library filename).
Two issues behind #897:
1) Discoverability — the "Ignored" management modal (view/un-ignore/clear-all,
shipped with #874) was only reachable from the wishlist *overview modal*
footer, which most users never open. Add the same button to the wishlist
page toolbar next to Cleanup / Clear All, wired to openWishlistIgnoreModal().
2) Manual re-add silently blocked (carlosjfcasero) — the album-modal "add to
wishlist" endpoint passes source_type=album, but the ignore gate only
bypasses+clears for source_type=manual, so re-adding a previously-cancelled
track failed. We cannot just send manual: source_type drives Albums/Singles
categorisation and repair_worker legitimately uses album too. Thread an
explicit user_initiated flag (db.add_to_wishlist -> service -> album route)
that bypasses+clears the ignore while preserving the real source_type.
Regression test pins both: an automatic source_type=album add stays blocked,
the user_initiated add goes through, clears the ignore, and keeps source_type=album.
The import-from-file tool only took CSV/TSV/TXT. Add M3U/M3U8 — the most common
file-playlist format, and the one SoulSync itself exports. Parses extended M3U
(#EXTINF artist/title/duration, #PLAYLIST name) and simple M3U (derives
artist/title from the file name), and keeps "# MISSING:" entries from our own
export (those are exactly the tracks a user imports to go match/download).
Frontend-only: the parser runs client-side and feeds the existing import path.
The image declares /app/MusicVideos as a VOLUME but the template never mapped it,
so music-video downloads landed in an anonymous Docker volume. Add an (advanced,
optional) Music Videos path mount so users can point it at a share.
They referenced a third-party repo (snuffomega/SoulSync_unraid). Point them at
the canonical files in this repo. Use raw.githubusercontent.com (not /blob/,
which serves an HTML page) and the templates/ path where the files actually live.
_get_artist_variations only widened the candidate fetch by diacritics, so a
request for "The Black Eyed Peas" never pulled a library track filed under
"Black Eyed Peas" (or vice-versa) — it "failed to match" and re-downloaded a
duplicate. Toggle the leading "The" in both directions when widening the fetch;
the confidence scorer (50/50 title/artist, 0.882 across the "The" gap) still has
the final say, so this can only widen what gets fetched, never merge genuinely
different artists. Mid-word "The" (e.g. "Theory of a Deadman") is untouched.
Closes the gap from cf2b4d7d: that commit unit-tested the DB-only matcher twin but
not services.sync_service._find_track_in_media_server — the actual path a connected
Plex/Jellyfin user hits. Drive it directly (Jellyfin server-type, so no Plex fetchItem
mocking): durable manual match is honored when the volatile cache is wiped, and a
stale library id self-heals via the stored file path.
Find & Add was being forgotten on the next auto-sync. It persists two ways — a fast
sync_match_cache override AND a durable manual_library_match (#787) that survives a
rescan — but BOTH sync matchers (services.sync_service._find_track_in_media_server and
the DB-only fallback) only consulted the volatile cache. A library rescan wipes that
cache, so the next 'replace' auto-sync re-matched the track from scratch and the user
had to Find & Add it again.
Both matchers now fall back to the durable manual match when the cache misses
(self-healing a stale library id via the stored file path), exactly like the compare
view already does via resolve_durable_match_server_id. So a Find & Add pairing sticks
across rescans + auto-syncs. Seam tests: cache-wiped→durable hit, stale-id self-heal,
no-match→fuzzy fall-through.
The log from a fresh run showed detection working but a second bug: rejecting the
lossless preview ('decoded 30s of 180s — rejecting') just dropped to the SAME track's
'high' (lossy m4a) tier — the same 30s clip — which the bitrate check can't see (m4a,
not FLAC), so it was accepted. A preview at any tier means the SOURCE only has a
preview of the track; lower tiers are the same clip. So on preview detection (manifest
OR post-download) we now return None to FAIL HiFi entirely, letting the orchestrator's
hybrid fallback try the next SOURCE (soulseek/youtube) instead of landing a lower-tier
preview. (A genuinely-missing tier still falls through to the next tier as before.)
Integration test drives the post-download abort end-to-end and pins 'no tier cascade'.
The first fix (#895) only caught manifests that HONESTLY declared a short length. The
real-world fakes are nastier: every length header is faked to FULL — HLS #EXTINF, the
m4a moov, AND the demuxed FLAC's STREAMINFO total_samples — while the file holds only
~30s of real audio. So the manifest-sum and mutagen-length checks both read 'full' and
passed it through. Measured 23 issue files: every one decoded to ~30s with a claimed
full length; size/claimed gave an impossible 55-362 kbps 'lossless', size/30s gave a
plausible ~600-1100 kbps.
Detect it two independent ways (post-download), so it fires even when every header lies:
- decoded real length via ffmpeg (-f null) vs the largest claimed length — the ground
truth when a decoder is present (production demuxes with ffmpeg, so it is);
- for lossless, an impossibly-low implied bitrate (< 30% of raw PCM) — needs no decoder,
catches all 23 on its own.
Pure is_fake_lossless_bitrate / is_preview_download / parse_ffmpeg_time helpers with seam
tests pinned to the real #895 file numbers. Reference is max(expected, container-claimed)
so the file's own faked claim becomes the bar its real audio must clear.
Fixes#895 — https://github.com/Nezreka/SoulSync/issues/895
HiFi (Tidal via hifi-api) sometimes serves a PREVIEW HLS manifest — only ~30s of
segments — for a full-length track. SoulSync downloaded exactly what the manifest
contained, and the only validation was a 100KB size floor (a 30s lossless preview is
~3-4MB, so it passed as 'complete') → a junk file that shows full length but plays ~30s.
Add two duration guards, both keyed off the track's real length (from get_track_info):
- Pre-download: sum the manifest's #EXTINF segment durations; if far short of the
expected track length, it's a preview → skip that tier, fall through to the next
source without wasting the download. (The parser discarded #EXTINF before.)
- Post-download: probe the finished file's real length (mutagen) and reject if short —
backs up legacy/direct (no-EXTINF) downloads and catches any truncation.
Conservative: unknown/zero durations never reject. Pure sum_hls_segment_seconds /
is_short_audio helpers with seam tests.
A downloaded track was getting the wrong track number (e.g. 'Apologize' from Shock
Value tagged track 1 instead of 16). Root cause: Deezer PLAYLIST track objects don't
carry `track_position` (only `/track/<id>` and `/album/<id>/tracks` do — even the
album object's embedded tracks omit it, verified against the live API). Both Deezer
playlist builders numbered tracks by their enumerate INDEX, which poisoned the real
album track number — and that value rides into the wishlist and onto the file tag.
Resolve the authoritative position from each album's `/album/<id>/tracks` (cache-first,
handles multi-disc) via a shared resolve_album_track_positions() helper, used by both
deezer_client.get_playlist and deezer_download_client.get_playlist_tracks. Falls back
to the index only if the album lookup fails (no regression). Seam tests for the helper.
Remove PLAN.md and the design specs — internal planning notes, not part
of the shipped feature. Keeps them out of the PR diff.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Switching presets now restores the user's prior edits to that preset
instead of factory defaults. Edits are stashed per preset name under
the quality_profile_presets preference; 'custom'/unknown names are not
stashed. Adds a /reset endpoint + "Reset to defaults" UI link to drop a
preset's saved edits.
- DB: set_quality_profile stashes per-preset; get_quality_preset returns
the customized form by default, _factory_quality_preset for the raw
defaults; reset_quality_preset forgets a preset's edits.
- web_server: apply-preset carries the global search_mode across switches;
new preset/<name>/reset endpoint.
- UI: target edits now save via debouncedSaveQualityProfile (profile-only,
no full settings re-init/flicker); preset switch suppresses the global
auto-save listener; help text + reset link.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Quality fallback is now a global setting in the Quality Profile
(ranked targets + fallback_enabled). The per-source allow_fallback
checkboxes on Tidal, Qobuz, HiFi, Deezer and Amazon were misleading —
they implied quality is still controlled per-source. Removed from HTML
and settings.js read/save. Backend defaults to allow_fallback=True
which is the correct behaviour.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bumps _SOULSYNC_BASE_VERSION to 2.7.4 and refreshes the What's New panel +
version-modal highlight reel to the 2.7.4 set (re-identify #889 headline; #890
title-strip; #891 residual-folder cleanup; #886 AAC tier; #887 Spotify Free
status; #884 NZBGet; #885 tz; Sokhi import-cleanup batch), with 2.7.3 rolled into
the brief 'earlier versions' summary per the current-release-only convention.
Reorganize leaves a cover.jpg (and other leftovers) behind when it moves an album,
and the Empty Folder Cleaner is too conservative to sweep them. Two complementary
fixes over one shared 'residual file' predicate (core/library/residual_files.py:
junk + cover/scan images + lyric/metadata sidecars), so both features agree on what
a dead folder is.
1. Reorganize (proactive): _delete_album_sidecars now sweeps ALL residual files when
a source dir has no audio left — previously only a fixed list of cover NAMES, so
back.jpg / disc.png / .webp survived and kept the folder un-prunable.
2. Empty Folder Cleaner (the request): new opt-in 'Remove Residual Files' setting
(default off) treats a folder holding only images/sidecars/junk as removable —
cleans the existing backlog + arbitrary names. Auto-renders as a UI toggle.
Safe by construction: whitelist-only (a booklet.pdf / video / .txt is real content
and kept), reorganize sweep gated on no-audio, cleaner re-checks at apply time.
20 new tests; 272 reorganize/repair/empty-folder green.
Files named '01 - Sun It Rises.flac' with no embedded title tag leaked the stem,
number and all, into tracks.title as '01 - Sun It Rises' — which never matches the
canonical 'Sun It Rises', so the real track reads as a false 'missing' and albums
sort wrong.
New conservative strip_leading_track_number (paths.py): removes a clear track-number
prefix (zero-padded number, OR a number followed by a real separator+space) while
leaving titles that merely start with a number untouched — '7 Rings', '99 Luftballons',
'50 Ways to Leave Your Lover', '1-800-273-8255', '1979' all preserved. Never reduces
to empty/bare-number/punctuation.
Applied at:
- get_import_clean_title (context.py) — the universal resolver every import path funnels
through, so the DB title AND the re-written embedded tag come out clean.
- album_matching scorer — so '01 - Sun It Rises' scores against 'Sun It Rises' and the
file matches its real track (inheriting the clean canonical name).
27 targeted tests + 772 imports/matching green.
Picking the release a track is ALREADY in deleted the file: the re-import lands at
the same path, record_soulsync_library_entry skips the insert (row exists), so no
new row is created — then delete_replaced_track removed that very row AND unlinked
the file (the freshly-imported one). This was the 'assumption' I documented but
never enforced.
Bulletproof guard: the pipeline writes its landing path into context['_final_processed_path'];
_process_matches captures it onto the candidate, and _finalize_rematch_hint passes
those new_paths to delete_replaced_track. If the old file canonically equals where
the import landed, it's a NO-OP — the row and file are kept (that file IS the
re-imported track). Canonical compare folds symlinks/case/sep.
So same-release re-identify is now a harmless re-tag-in-place; only a genuine re-home
(different path) deletes the old. 114 auto-import + rematch tests green.
The hero's overflow:hidden was clipping the header content, but removing it let the
blurred background + overlay cover (and steal clicks from) the source tabs below.
Move the decoration into .reid-hero-decor — an absolutely-positioned clip layer
that contains the blur and is pointer-events:none — so the header content (a sibling,
never clipped) shows in full AND the tabs stay clickable.
Two real bugs surfaced in testing:
1. Original file not deleted on replace: delete_replaced_track checked os.path.exists
on the RAW stored DB path (a Docker/media-server view), so it removed the row but
orphaned the file. Now takes a resolve_fn (wired to resolve_library_file_path in
the worker) and unlinks the RESOLVED real path.
2. No year / wrong album context: build_identification_from_hint set is_single=True,
routing re-identify through _match_tracks' singles fast-path — which never fetches
the chosen album, so the re-import got a bare stub (no release_date, total_tracks=1).
Added force_album_match so the matcher FETCHES the chosen release even for a lone
staged file → the track inherits the real album's year, in-album track number, and
art. Holds for single-type releases too (they have a year as well).
Normal single-import behavior unchanged (force_album_match absent → same path).
112 auto-import + rematch tests green.
The apply endpoint silently fell back to the raw stored DB path when the resolver
missed, producing a confusing 'Source file not found: <raw path>'. Now: resolve via
the app's strong _resolve_library_file_path (keeps #833 confusable folding), and on
a miss run the diagnostic resolver to log + report exactly which transfer/download/
library/Plex dirs were searched and whether the raw path existed — so a media-server-
only or stale-path track gives a clear 'SoulSync can't read this file' instead of a
dead end. No mutation happens on this path (fail-safe; nothing staged/deleted).
Adds a per-track ⇄ action (admin-only, alongside source-info/redownload/delete)
that opens the Re-identify modal seeded with the track's title/artist/album/art.
The loop is now live: click ⇄ → pick a release → file stages + hint writes →
auto-import re-files it under the chosen single/EP/album (and replaces the old
entry on success when 'replace' is ticked).
Double-gated: the button only renders for admins, and /api/reidentify/apply
re-checks is_admin server-side.
The showpiece: a focused 'which release does this track belong to?' chooser.
Source tabs (default active), pre-seeded search, the same song surfaced across
single/EP/album with color-coded type badges, ISRC-ranked, replace-original
toggle (on by default). Glassy panel, blurred hero art, shimmer/spinner states,
hover-lift result cards — matched to the app's modal language.
Backend:
- core/imports/rematch_apply.py: pure staged_destination + build_reidentify_hint,
injectable stage_file_for_reidentify (COPIES the file, never moves — original
safe until re-import succeeds). 6 tests.
- POST /api/reidentify/apply (admin-only): resolve_hint_fields → stage file →
create_hint → nudge the worker. Replace deletes the old row only on success.
Frontend: modal markup (index.html), full stylesheet (style.css), and the
openReidentifyModal/search/select/confirm flow (library.js). Not yet reachable
from a button — Phase 5 wires it.
Search any configured source (tabs, default active) and surface the SAME song
across its collections (single/EP/album) so the user can pick which release a
track should be filed under.
- core/imports/rematch_search.py: pure normalize + injected client factory.
search_release_candidates() → lightweight display rows from typed search_tracks
(title/artist/release/type badge/year/count/art/isrc/track_id); resolve_hint_fields()
runs ONCE on the picked row via get_track_details to pull the album_id (+ isrc/
track#/disc) the hint needs. infer_release_type() handles Spotify's missing 'EP'
(multi-track 'single' → EP badge); filing is driven by real album_id, not the label.
- GET /api/reidentify/sources (tabs) + GET /api/reidentify/search (rows). Graceful
empty on dead source / blank query / client error — never raises.
14 tests. Inert until the modal (Phase 4) calls it.
When a staged single-file candidate carries a re-identify hint, the worker builds
the identification straight from the user-chosen release (album_id/source) and
skips the guessing tiers — so the ambiguity that mis-filed the track is gone. No
hint → byte-identical to before (the lookup returns (None, None), fail-safe on any
DB error). A hinted import auto-processes (explicit user choice), still gated on the
global auto_process pref.
After the re-import lands, _finalize_rematch_hint consumes the hint and (if replace
was chosen) deletes the old row + file via delete_replaced_track — deferred to
success so a failed import never loses the original. Safe by construction: unlink
only when no surviving row references the file, and the modal never offers the
track's current release so old path != new path.
All hint logic lives in auto_import_worker.py + the pure rematch_hints helpers —
pipeline.py / side_effects.py untouched. 18 tests; full auto-import suite green.
A single-use, user-designated 'which release does this track belong to' answer.
Written when the user picks a release in the Re-identify modal and the file is
staged; the import flow will read it at the top of matching and consume it.
- rematch_hints table (additive, IF NOT EXISTS + indexes) keyed on staged_path
with content_hash as a rename-proof fallback.
- core/imports/rematch_hints.py: pure DB seam over an injected cursor
(create/find/consume/list) + a cheap size+head+tail file fingerprint.
- exempt_dedup baked into the hint (a re-identify must bypass dedup-skip);
replace_track_id carried for deferred post-success cleanup.
Inert until wired (Phase 5) — nothing calls it yet. 9 seam tests.
paksenkin: TZ=Australia/Sydney made the Cache Maintenance job (and any repair
job) run every ~5s. Root cause: finished_at is written by SQLite CURRENT_TIMESTAMP
(always UTC) but the scheduler compared it against datetime.now() (naive LOCAL),
so the local↔UTC offset leaked into the elapsed time. Sydney (+11) made every job
look ~11h stale -> always due -> fired every poll; the Americas (behind UTC)
deflated it and masked the bug (why New_York 'worked').
Fix: compare in UTC. now = datetime.now(timezone.utc), and a new _hours_since()
helper parses the naive CURRENT_TIMESTAMP string AS UTC before subtracting — so
the machine timezone never affects scheduling. 5 tests incl. the literal repro
(a just-run job must not be due under Australia/Sydney) and a due-detection
sanity check; 41 repair-worker tests pass, ruff clean.
radoslav-orlov: add AAC as a download quality option. AAC is more efficient than
MP3, so it's useful for Soulseek/torrents (streaming sources pick their own
codec; Amazon — the AAC-heavy one — is down).
Additive by construction: every quality tier already defaults enabled=false and
the waterfall is built only from enabled tiers, so AAC ships OFF and the bucketer
routes a not-enabled AAC file to the 'other' bucket EXACTLY as today (where it was
silently dropped). Only a user who turns AAC on makes it a first-class tier,
ranked above MP3 / below FLAC (priority 1.5, min-kbps gate so junk AAC can't beat
a good MP3).
- music_database: aac tier (disabled) in the default profile + all 3 presets.
- soulseek_client: map .m4a -> 'aac' in both result parsers (was 'unknown' ->
dropped); add the 'aac' bucket + a gated branch + a fallback size limit.
- settings UI: an 'AAC' tier toggle (unchecked) between FLAC and MP3; save
defaults its priority to 1.5 so upgraded profiles rank it right on first save.
7 seam tests pinning the additive guarantee (aac absent/disabled -> dropped as
before; FLAC/MP3 selection unchanged; aac on -> selectable, below FLAC, above
MP3); 81 quality/soulseek tests pass, ruff clean. quality_upgrade left untouched
(its AAC handling is unchanged).
radoslav-orlov: with no Spotify auth, enrichment runs on the no-creds Spotify Free
source (prefer-free is on by default) and IS working — pending drains, the modal
shows RUNNING — but the dashboard header tooltip said 'Not Authenticated /
Connect Spotify in Settings'. Two causes:
- get_stats() only set using_free for the rate-limit / spent-budget bridges, not
the plain no-auth-default-free case. The loop already computes the right signal
(free_serving = _free_active(), True here) but it was a local var. Cache it on
self each iteration and report it as using_free (no auth API call in the 2s
status loop).
- The dashboard's Spotify updater checked notAuthenticated BEFORE bridgingFree, so
even with using_free it showed Not Authenticated. notAuthenticated now excludes
the bridging-free case; the LastFM/Genius/Tidal/Qobuz updaters (no free path)
are unchanged.
5 seam tests for get_stats free/auth reporting; 67 enrichment/free tests pass.
Swigs: 'No audio files found in /data/usenet/incomplete/….#2141' — SoulSync
imported a usenet album from NZBGet's intermediate working dir, which is emptied
after the move (files were in /data/soulseek/…). Two causes, both fixed to match
the already-correct SAB adapter:
- _parse_history mapped save_path=DestDir, but the authoritative final location
after a post-processing move is FinalDir. Prefer FinalDir, fall back to DestDir,
empty/whitespace -> None.
- _parse_group exposed the queue group's DestDir (the in-progress '….#NZBID' dir)
as save_path, so a PP_FINISHED group (which maps to 'completed') could finalize
on the incomplete folder before the move. A queue group now reports no save_path
-> finalisation always comes from the history entry (real FinalDir/DestDir),
bridged by the existing 120s completed-no-path window.
6 regression tests (FinalDir preferred, DestDir fallback, empty->None, queue/
PP_FINISHED never offer the incomplete path).
The Deezer missing-column fallthrough in find_existing_soulsync_album_id used a
bare 'except: pass', which ruff flags as S110. Log it at debug instead — same
fail-safe behaviour, no swallowed-exception lint warning.
Surfaces metadata_enhancement.single_to_album as a checkbox in the Post-Processing
> Core Features section, next to the cover-art settings (it's about getting the
right album cover). Default OFF, wired like the replaygain toggle (load '=== true',
save raw .checked) since the generic data-config binding defaults a missing key to
ON. Registered the default in settings.py DEFAULT_CONFIG + config.example.json.
detect_album_info_web gains a last-resort step: when a track matched a SINGLE
with no usable album context, look up the parent ALBUM that contains it (via
get_artist_albums_for_source + get_artist_album_tracks) and promote to it, so it
groups with its album-mates and gets the album's cover instead of the single's.
GATED behind metadata_enhancement.single_to_album (default OFF) — it's a
per-import metadata lookup, so it's opt-in, matching the canonical-version
pattern. Fully fail-safe: flag off, no source, or any client error/miss -> None,
so the track stays exactly as matched (never worse than today). The promoted
album name is forced past get_import_clean_album (which otherwise pins the
single's name) so grouping + tags use the album. 4 glue seam tests added
(promote-when-enabled, disabled-by-default, no-match, client-raises); 462
import-suite tests pass.
When a track matches a SINGLE release it carries the single's name/id and the
canonical grouping files it apart from its album-mates -> mixed cover art
(Sokhi). This re-homes it onto the album that actually contains it.
The selection is a pure, CONSERVATIVE function and the lookup loop takes injected
fetchers, so both are unit-testable without a live client. It only re-homes a
track when a real 'album'-type release's tracklist contains that EXACT track
(qualifier-tolerant) — never promotes a genuine standalone single, never guesses
(a wrong promotion would mis-home a real single, the inverse bug). Fail-safe: any
miss/error -> None (track stays as matched). 13 seam tests. Wiring next.
Sokhi: songs in one album get mismatched cover art. Root cause is upstream of
the repair jobs (which correctly apply one cover per album_id): the standalone
import grouped albums by the album NAME hash (artist::album_name), so the SAME
release split into multiple album rows whenever the name string drifted, and the
cover-art/re-tag jobs then dressed each split row in its own art.
Foundation (new imports only; existing rows untouched): a pure, seam-testable
helper find_existing_soulsync_album_id() resolves the album row by precedence
name-hash id -> source RELEASE id -> (title, artist). When an import carries a
metadata-source album id, a differently-named import of the SAME release now
unifies into one row instead of splitting. Source-column lookup is allow-listed
(it's spliced into SQL) and guarded so a source without a dedicated album column
(Deezer) falls through to the name match instead of breaking the import.
Deliberate scope: this does NOT merge a track that genuinely matched a SINGLE
(a different release id) into its parent album — that needs single->album
resolution upstream and is the next step; this is the grouping substrate it will
feed. 10 seam tests (canonical unify, single-vs-album stays separate, precedence,
allowlist, server-source scope, missing-column fallthrough).
Sokhi: tracks occasionally land in Rockbox's 'untagged' bucket after a
'processing failed'. enhance_file_metadata saves the file with tags CLEARED up
front (so stale tags never linger), then runs the failure-prone external steps
(source-id embed, cover-art fetch). The core tags (album/artist/title/track from
the matched context) are written to the in-memory object BEFORE those steps, but
the on-disk file is still the cleared one until the final save.
The #764 fix made the error handler restore ART — but gated the re-save on there
being original art to restore. So a file with NO embedded art that hit a
mid-enrichment crash threw away its in-memory core tags and was left on disk as
the up-front clear saved it: untagged. Now the handler always persists the
in-memory tags (restoring art when present), so a crash leaves a correctly-tagged
file (album tag intact -> right bucket) instead of an empty one. Regression test
drives the real enhance_file_metadata against an art-less FLAC.
Sokhi (again): downloading the base 'Mushoku Tensei S2 Original Soundtrack' embedded
the cour-2 '…サウンドトラック2' cover. numeric_tokens_differ stripped titles to
[a-z0-9], turning CJK into spaces — so the trailing '2' collapsed to a bare '2'
that '第2期' (season 2) already supplied on BOTH sides, leaving the digit sets equal
and the guard blind. Tokenise on \W (Unicode word-aware) instead, so a digit stays
attached to its word ('サウンドトラック2' is its own digit-bearing token). Latin
behaviour is byte-identical (Vol.4 vs Vol.4.5 etc.). Shared guard, so the art picker
AND the MusicBrainz->CAA path are both fixed. Regression tests added.
ruff F821 caught a real NameError: the three /api/wishlist/ignore-list*
endpoints called get_wishlist_service() without the local import every
other call site in web_server.py uses, so they'd crash the moment the
Ignored modal queried them. Add the import; ruff check now clean.
Single tracks (esp. Deezer-sourced) imported as "01 - Title" regardless
of their real album position — e.g. Fly Away (track 2 of Greatest Hits)
landed as 01, littering album folders with duplicate "01" files.
Root cause: a Deezer single track is matched via /search/track, which
omits track_position, so the context never carried the real number; then
service.py + context.py fabricated a confident track_number=1 from that
gap. Because the resolver puts that first, the fake 1 beat the source.
It is source-agnostic (slskd-with-Deezer-metadata hits it too) — albums
work because /album/<id>/tracks DOES include positions.
Fix (at the shared import funnel, strictly additive):
- track_number.py: new read_embedded_track_number() (mutagen, local, no
network) + an optional embedded_track_number arg on resolve_track_number.
The downloaded file already carries the source-written position (deemix
wrote it); consult it LAST — only when metadata AND the "NN - Title"
filename both come up empty — so it can only fill the gap that would
otherwise hit the default-1 floor. Never overrides a value the pre-fix
resolver produced (no regression for correctly-named/mistagged files).
- pipeline.py: read the file tag at the resolve step and pass it in.
- De-poison: service.py:217 + context.py default to 0 (the existing
"unknown" sentinel, like total_tracks), NOT 1 — so the fake 1 no longer
blocks recovery. Frontend already treats falsy track_number as unknown
(omits it), so this also drops the bogus "1." in the UI.
13 new resolver tests incl. the no-regression precedence guards; full
imports + wishlist suites green (583), no behavior change for albums.
A user who removes a wishlist track, or cancels an in-flight wishlist
download, would have it re-added on the next auto cycle (watchlist scan,
failed-track capture, or the cancel handler's own re-add), so the same
release downloaded -> failed/cancelled -> re-queued forever.
Adds a TTL'd skip-gate (30 days), softer than the blocklist: it expires
so the track is reconsidered later, and never blocks a manual
force-download — only the automatic re-queue.
- core/wishlist/ignore.py: pure TTL/normalization/display logic + a
best-effort orchestrator (no DB handle, caller passes now).
- database/music_database.py: migration-safe wishlist_ignore table +
add/check/remove/list(+purge)/clear methods, and the gate in
add_to_wishlist beside the blocklist guard. Fail-open throughout — an
ignore error can never block a legitimate add; a manual add bypasses
the gate AND clears the ignore.
- routes.py: user remove (single/album/batch) records an ignore. Hooked
at the route layer, NOT the DB remove, so success-cleanup never
ignores (regression-tested).
- web_server.py: cancel now ignores + removes from the wishlist instead
of re-adding for endless retry; three /api/wishlist/ignore-list*
endpoints.
- downloads.js: 'Ignored' modal (view / un-ignore / clear all).
- 13 tests: pure logic, DB seam, gate (block/bypass/fail-open),
route wiring, and the success-cleanup-does-not-ignore regression.
Multiple failed source attempts at one song each land in quarantine as
separate entries. Group them by the *intended* target (sidecar context
track_info isrc -> id -> uri, falling back to normalized artist|title for
legacy thin sidecars) — an exact relationship across siblings, since the
bad files' own tags differ but the target track is constant.
- core: quarantine_group_key() + find_quarantine_siblings() seams; list
entries now carry group_key.
- approve endpoint: remove_siblings flag auto-deletes the other attempts
once one is accepted (captured BEFORE approve restores the file out of
quarantine, or the id lookup would resolve nothing). Scoped to the
quarantine manager; download-modal chooser + version-mismatch fallback
pass no flag and are unaffected.
- UI: multi-member groups render as a collapsible parent row (album art +
'N alternatives'); singletons unchanged. Toast reports removed count.
- 11 tests incl. ordering regression for capture-before-approve.
The Quarantine tab badge was only populated by loadQuarantineList(), which runs
when the tab is clicked — so opening Library History showed a stale 0 until then.
Refresh the count on modal open via the existing /api/quarantine/list endpoint.
The Download Discography modal exposed only Albums/EPs/Singles, its EPs toggle did
nothing, and Live/Compilations/Featured were missing — so you couldn't fine-filter
a bulk download the way Artist Detail lets you browse.
Root cause: the modal's endpoint (/api/artist/<id>/discography) used the base
get_artist_discography, which lumps EPs into singles, and the modal only read
{albums, singles} — so the EPs bucket was always empty (dead toggle). It also had
no content-type (Live/Compilation/Featured) classification at all.
- Backend: the endpoint now uses get_artist_detail_discography — the SAME split
Artist Detail uses — and returns a separate `eps` list.
- Frontend: read `eps`; tag each card with data-is-live/compilation/featured via a
new shared _classifyReleaseContent() (also adopted by the Artist Detail cards so
the two can't drift); add Live/Compilations/Featured filter buttons; combined
category+content filtering. The download payload is built from VISIBLE checked
cards, so every toggle now actually changes what downloads.
- Regression test: get_artist_detail_discography splits an EP into the eps bucket.
- .sidebar-header: real frosted-glass blur of content scrolling behind it —
made the background translucent (was an opaque base layer), added
backdrop-filter blur, and raised the header above the nav (z-index) so nav
items actually sit in its backdrop.
- .dl-nav-badge: vertically centered on the right (top:50% + translateY) instead
of pinned to the top-right corner.
- Removed border-top-right-radius from .sidebar and .sidebar-header (square top).
- Hide the "My Accounts" + "My Settings" header buttons for admin profiles —
both are inert for admin (every service is "Managed in Settings", and My
Settings is an empty pointer note); kept for non-admins who get real UI.
Reported by @Lysticity: opening Settings reset the whole config to defaults. The
chain: GET /api/settings 500s (their env: ConfigManager missing redacted_config)
-> loadSettingsData() called response.json() WITHOUT checking response.ok, so the
error body {"error": ...} was treated as settings -> every field populated as
`settings.x?.y || ''` blanked to defaults -> autosave then wrote those defaults
over the real config.
Fix (settings.js): bail BEFORE touching any field when the response isn't ok / is
an error body, set window._settingsLoadFailed, and guard BOTH save paths
(debouncedAutoSaveSettings + saveSettings) on it. The flag clears on the next
successful load. So any load failure (500, lock, network) now leaves the saved
config untouched instead of wiping it.
The redacted_config method exists in all 2.7.x source + on dev (their 500 looks
like a stale/mismatched build), but the UI must not destroy config on ANY failed
load. Regression test pins redacted_config stays a callable method on the class
(its removal is exactly what 500s the endpoint).
The Favorites collection walker (_iter_collection_resource_ids) broke on ANY
non-200 — including a transient 429. So a rate-limit mid-pagination silently
truncated the collection: the log shows `status=429` then `Retrieved 98/100`,
and the mirror saved 98 of a 524-track favorites list. The auto-sync cycle only
"worked" because it dodged the 429. The regular-playlist paginator already
retries 429; the collection walker didn't.
Fix: retry the same cursor page with backoff (5/10/15/20s, 4 attempts) on 429,
mirroring the playlist paginator; 401/403 still bail (+ reconnect flag), other
non-200 still break. Regression tests: 429 mid-walk completes the full chain;
exhausted retries return partial without hanging; 429 doesn't set reconnect.
Adds a dedicated `get_library_history_unverified()` DB query that fetches
every library_history row with verification_status IN ('unverified',
'force_imported') with no recency cap. This is loaded unconditionally in
`build_unified_downloads_response` — not gated on `len(items) < limit` —
so historical unverified entries are never buried by a busy batch filling
the 200-row general limit, and entries from weeks/months ago aren't lost
in the 50-row recency-ordered history tail. Adds idx_lh_verification_status
for query performance and two regression tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The two import behaviour toggles previously lived only in Settings → Import.
Mirror them onto the React Import page (above the processing queue) so they're
visible and adjustable right where you import.
- New ImportOptions component: two Switches ("Quality check on import",
"Use folder as artist") with optimistic update + immediate save.
- API: fetchImportOptions / saveImportOptions / importOptionsQueryOptions —
read the whole settings blob, POST a partial {import: {...}} (the settings
endpoint partial-merges, so the rest of config is untouched). Both default ON
when absent, matching the backend defaults.
- Same import.quality_filter_enabled / import.folder_artist_override config
keys as the Settings page, so the two stay in sync.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The scan was only doing the header-based quality gate (mutagen) — fast but
shallow. The download/import pipeline ALSO runs detect_broken_audio first,
which uses ffmpeg to actually DECODE the file (astats truncation check +
silencedetect) to verify the REAL audio, not just the metadata. That's the
whole point of unifying onto the download quality pipeline.
- Each file now runs both stages: (1) ffmpeg AudioGuard (detect_broken_audio),
(2) header quality gate (probe_audio_quality + quality_meets_profile).
A finding is created for broken/incomplete audio OR below-profile quality,
with quality_issue + broken_audio_reason in details and a 'warning' severity
for broken audio vs 'info' for below-profile.
- New setting deep_audio_verify (default True) toggles the ffmpeg decode pass;
off = fast header-only. Slower full scan is expected — it decodes every file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
library_tracks_only defaulted ON, which skipped every file when the DB (reset
by the user) no longer matched the files on disk → scanned=0, nothing tested.
Default it OFF: check every audio file in the Music Library output folder, which
is what users expect. DB matching is still used opportunistically for better
finding metadata, just no longer required. Power users can re-enable the filter.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The scan was walking soulseek.download_path (/app/downloads) too, which is the
raw download/staging area full of pre-import leftovers — not the library. Walk
only the "Output Folder (Music Library)" (soulseek.transfer_path) plus any
custom library.music_paths. A user's custom output-folder path is respected
since it's read live from config.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The folder walk found 403 files in transfer/downloads when the user's library
is only ~18 tracks — the rest are pre-import leftovers (residue after a DB
reset). Those are orphans, not library tracks, and belong to the Orphan File
Detector, not a quality scan.
- New setting library_tracks_only (default True): match each walked file to a
DB track via the suffix index BEFORE probing; skip anything with no DB row.
So the scan reflects the real library, not download junk, and avoids probing
hundreds of orphan files.
- Split _lookup_meta into _match_db (cheap DB suffix match) + _read_file_tags
(only used when library_tracks_only is off, for loose files).
- Log how many files were skipped as not-in-library.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The quality job kept resolving 0/N because DB-path resolution failed in the
deployed environment for reasons the logs wouldn't surface. Switch to the
mechanism the WORKING file tools use: os.walk the real music folders
(transfer + download + configured library paths, abspath'd) exactly like
orphan_file_detector and fake_lossless_detector — those reliably see files
because they never touch the DB's stored relative paths.
- Walk all existing music dirs, collect audio files (dedup by realpath),
probe each with the same probe_audio_quality the import guard uses, check
quality_meets_profile (strict). Below-profile files become findings.
- Match each walked file back to its DB track via a path-suffix index (last
1-3 components) for real title/artist/album + track id; fall back to the
file's own tags when no DB row matches (finding filed as 'file').
- Loud diagnostics: logs the folders walked and the audio-file count, and
warns clearly when no music folder exists to walk.
The fix handler already works with the now-absolute file_path and an optional
entity_id (deletes the file by real path; DB row only when known).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Answers "does import respect quality?": yes — the pipeline already runs the
quality gate (check_quality_target) BEFORE AcoustID and quarantines files that
don't meet the profile (unless fallback/downsample is on). This adds an explicit
user switch over that behaviour.
- New config import.quality_filter_enabled (default True). When False,
check_quality_target returns None early so EVERY file imports regardless of
quality; the file is still probed and the library Quality Upgrade Scanner
still flags below-profile tracks. Default preserves current behaviour.
- Settings → Library: the Import Settings group is now a collapsible tile
(same pattern as Post-Processing) and gains the "Only import tracks that meet
your quality profile" toggle at the top, alongside replace-lower-quality and
folder-artist-override.
- settings.js populate/collect the new key; config schema default added.
- Tests: key-aware config stub (a blanket-False mock would wrongly disable the
filter) + a new test pinning toggle-OFF = accept below-target file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The quality job still resolved 0/18 because the shared resolver kept relative
config paths ("./Transfer") as-is and gated them behind os.path.isdir("./Transfer"),
which only holds when the calling thread's CWD is the app root. The repair
worker thread's CWD isn't guaranteed to be /app, so base_dirs came back empty
and every track was "unresolved".
- _collect_base_dirs now also adds os.path.abspath() of every relative
candidate, so "./Transfer" → "/app/Transfer" regardless of CWD.
- quality_upgrade_scanner logs a one-shot [QualityResolve] diagnostic on the
first unresolved track (cwd, transfer_folder + abspath + isdir, base dirs
tried, abs-join existence) so any remaining mount mismatch is pinpointable
instead of a silent "all skipped".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the wishlist-only Quality Scanner with a proper Library Maintenance
job that produces actionable findings — same model as the AcoustID/orphan
tools, per user request ("mach ein finding wie jedes anderes Tool").
- New core/repair_jobs/quality_upgrade_scanner.py: iterates DB library
tracks, resolves each path via the shared resolver (now index-0 correct for
relative library paths), probes REAL audio quality with the same
probe_audio_quality the download import guard uses, and checks it against the
user's v3 ranked targets via quality_meets_profile (strict — no extension
guessing, no fallback). Below-profile tracks become 'quality_upgrade'
findings with current vs target quality in details.
- repair_worker._fix_quality_upgrade: redownload (wishlist + delete file/row),
delete (file + row), or ignore (dismiss in UI). Registered in _execute_fix
dispatch + bulk fixable_types.
- Frontend (enrichment.js): 'Low Quality' type label, 'Upgrade' fix button, a
3-way _promptQualityUpgradeAction modal (Re-download / Delete / Ignore),
wired into both single-finding fix and bulk-fix (Ignore → dismiss inline).
- Tools "Quality Scanner" button now triggers Run Now of this job and points
the user to Library Maintenance → Findings.
The old standalone /api/quality-scanner endpoints are left intact (unused by the
button) to avoid churn. Verified: job registers, fix handler dispatches.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ROOT CAUSE of the quality scanner's "18/18 could not be probed". The shared
resolver (core/library/path_resolver.py) suffix-walked starting at index 1,
which is correct for absolute media-server paths (/music/Artist/... — index 0
is the empty leading segment) but WRONG for SoulSync's own library, which
stores RELATIVE paths like "Asketa/Another Side/track.flac". Index 0 there is
the artist folder; dropping it meant the resolver joined base/Another Side/...
(no artist) and nothing ever matched — so every library track came back
unresolved and the probe opened a relative path that didn't exist from CWD.
Start the suffix walk at index 0 so the FULL relative path is tried first.
Safe for absolute paths (i=0 yields base//Artist/... which harmlessly misses
and falls through to i=1) and Windows drive parts (E: fails on POSIX, falls
through). Other tools (orphan/fake-lossless detectors) were unaffected because
they os.walk the transfer folder directly and never used this resolver.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When config stores './Transfer' (relative) and DB has clean relative paths
like 'Artist/Album/Track.flac', os.path.abspath resolves './Transfer' to
/app/Transfer and os.path.join produces the correct absolute candidate —
no component-by-component descent needed. The old approach relied on
find_on_disk starting from a relative base_dir, which worked as long as
CWD stayed consistent but was fragile. New fast path: build abs_bases
(all candidate dirs in absolute form) upfront, then try direct join first.
Fall through to confusable-tolerant suffix scan only when direct join misses.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
So the [PathResolve] 'searched dirs + cwd' warning fires on every scan, not
just the first after a container restart — needed to diagnose where the
library files actually live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diagnostic on the live system showed transfer='./Transfer' (relative config)
while the files live at the absolute mount '/Transfer' — so nothing resolved.
_resolve_library_file_path now also searches the CWD-absolute (os.path.abspath)
and root-absolute ('/Transfer') forms of relative transfer/download paths, with
dedup. The unresolved-path diagnostic now logs the real dirs searched + cwd.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- list_quarantine_entries now surfaces the probed quality (context._audio_quality,
recorded before the gates) so each quarantine row shows what the file actually
is when deciding to approve/delete. Rendered as a quality chip in the review UI.
- _resolve_library_file_path logs the searched base dirs once when it can't
resolve a path, so a remaining mount/path mismatch is diagnosable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1) _resolve_library_file_path now tries the FULL relative path (index 0)
against each base dir, not just suffixes. The library scanner stores clean
"Artist/Album/Track.flac" paths; skipping index 0 dropped the artist folder
so the file never resolved — every quality-scanner probe failed ("20/20
could not be probed"). Now they resolve under the transfer/library dir.
2) Quality gate moved BEFORE the AcoustID check in post_process_matched_download.
- A wrong-quality file is rejected without paying for an AcoustID fingerprint.
- context['_audio_quality'] is set before either gate quarantines, so the
real quality is recorded on the sidecar for EVERY quarantine trigger —
it's known when reviewing/approving any quarantined file.
- force_import still never fires on a quality mismatch (only AcoustID).
normalize_import_context mutates in place, so the moved block keeps its
context fields. New test pins the order + that AcoustID isn't run on a
quality reject.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diagnostics revealed the real cause: the tracks table stores file_path
RELATIVE to the library root (e.g. "Asketa/Another Side/01-01 - Another
Side.flac"), so probing the raw path failed for the entire library — every
track came back unprobeable and was left unflagged ("20/20 could not be
probed").
The scanner now resolves each path via _resolve_library_file_path (checks
transfer/download/library dirs, same helper the rest of the app uses) before
probing, falling back to docker_resolve_path. Injected via deps for testability.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A task stuck in 'post_processing' past the cutoff was force-marked 'completed'
("assume it worked"). In a large batch, post-processing (AcoustID + quality +
import) is serialized and backs up, so tasks sit in post_processing while merely
QUEUED — then got falsely completed, showing as downloaded with no file on disk
(/Transfer empty).
Now: the cutoff is 30 min (was 5) so legit backlog isn't cut off, and when it
does fire the task is only completed if it actually produced a file
(final_file_path exists on disk) — otherwise marked failed (honest + retryable).
Applied at both stuck-detection sites (check_batch_completion + _v2).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The worker used logging.getLogger(__name__) → "core.discovery.quality_scanner",
which the app log view (soulsync.*) doesn't surface — so the scan looked like it
did nothing ("API Starting scan" straight to "quality_scan_completed" with no
worker output). Switched to get_logger("discovery.quality_scanner") so "Found N
tracks", "Profile targets", and the unprobeable-file diagnostics show up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The unified scanner must READ each file to judge real bit depth/sample rate
(extension alone can't tell 16-bit from 24-bit FLAC). If the stored library
path doesn't resolve to a readable file in this container, every probe returns
None and — since an unprobeable file can't be judged — the whole library passes
silently ("scans nothing").
Now: resolve the path via docker_resolve_path before probing, and count +
log unprobeable files (first 5 paths at WARNING, plus an end-of-scan summary
"N/M tracks could not be probed"). This makes a systematic path/mount mismatch
visible instead of an empty result.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The library quality scanner judged quality by FILE EXTENSION only
(get_quality_tier_from_extension) and read the legacy v2 `qualities` dict —
so every FLAC was "lossless tier 1" regardless of bit depth / sample rate. It
could never flag a 16-bit FLAC as upgradeable under a 24-bit profile, and it
ignored the v3 ranked_targets entirely. Completely inconsistent with the
download guard.
Now both share one core:
- selection.targets_from_profile(profile) — single profile→targets conversion
(v2→v3 migration), reused by load_profile_targets.
- selection.quality_meets_profile(aq, targets) — strict: meets iff the real
measured quality satisfies a ranked target (fallback ignored — it's a
download concession, not a definition of "good enough").
- guards.check_quality_target refactored to use both.
- quality_scanner probes real quality (probe_audio_quality) and checks against
the v3 targets via quality_meets_profile. Extension tier kept only as a
fallback label when a file can't be probed.
Result: the scan flags exactly what the download gate would reject — 16-bit
when you want 24-bit, wrong sample rate, MP3 when you want FLAC.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A file quarantined for QUALITY (e.g. MP3-VBR rejected by a FLAC-only profile)
showed up in BOTH the Completed and Quarantine tabs. Cause: the verification
wrapper (post_process_matched_download_with_verification) handled the
_acoustid_quarantined / _integrity_failure_msg / _race_guard_failed markers but
NOT the quality marker _bitdepth_rejected (nor _silence_rejected). A quality
quarantine leaves no _final_processed_path, so the wrapper hit the
"no final path — assuming success" branch and marked the task Completed.
Unlike acoustid/integrity (retry driven by the wrapper), the inner pipeline
already owns the quality/audio-guard outcome — it quarantines then re-queues the
next-best candidate or marks the task failed. So the wrapper now just returns
when it sees _bitdepth_rejected/_silence_rejected, without marking completed
(which clobbered both the quarantine state AND any successful retry).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The audiophile preset (fallback_enabled=False) still shipped a "FLAC 16-bit"
target in its ladder, so 16-bit FLAC matched and imported even though the name
implies hi-res-only. Split the ladder: audiophile now uses a strict 24-bit FLAC
list; balanced keeps 24-bit + 16-bit + MP3. Gives users a one-click strict
"24-bit only" profile that actually rejects 16-bit/lossy.
Not a matches_target bug — that correctly rejects 16-bit vs a 24-bit target;
the leak was the preset's target LIST including 16-bit (+ fallback accepting
off-list lossy like MP3-128).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of "completed/failed/unverified don't show during a running
batch, only after it ends" (F5 didn't help): build_unified_downloads_response
sorted live tasks active-first (downloading/searching/queued = priority 0-3,
completed/failed = 4-7) then truncated the whole array at items[:limit] (300).
During a busy batch the active+queued tasks filled the limit and pushed every
terminal task off the end, so /api/downloads/all never returned them — the
Completed/Failed/Unverified tabs filter client-side and had nothing to show.
Fix: `limit` now bounds only the persistent-history tail. Live in-memory
tasks are always returned in full — they're already bounded by the 5-min
cleanup automation, and array order is presentation-only since the page
filters per tab client-side.
Verified with a repro (320 queued + 1 completed + 1 failed → terminal rows
were absent at limit=300; now present).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three fixes from on-device testing of best-quality mode:
1. clear_completed_local no longer prunes terminal tasks that belong to a
STILL-ACTIVE batch (one with non-terminal work remaining). The 5-min
"Clean Completed Downloads" automation was yanking completed/failed/
unverified rows out of download_tasks mid-run — and failed/cancelled
aren't in library_history — so they only reappeared after the batch
ended. Now the whole active batch stays intact until it finishes.
2. search_all_sources runs every source CONCURRENTLY (asyncio.gather)
instead of sequentially, so the pool waits only for the slowest source
(e.g. usenet/Prowlarr) in parallel rather than summing all latencies.
3. The pool log now reports per-source contribution counts
(e.g. "usenet=0, hifi=11, soulseek=1") instead of just echoing the
chain, so a release-level source that returns nothing for a track-title
query is visible rather than appearing to have been searched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an opt-in search strategy toggle in the Quality Profile:
- priority (default): unchanged — first source in the hybrid chain that
meets a quality target wins.
- best_quality: pool candidates from EVERY source per query and download
them best→worst by actual audio quality; source order only breaks ties.
Implementation reuses existing plumbing so the retry system is untouched:
- engine.search_all_sources pools raw tracks across all configured,
non-exhausted sources (no first-source short-circuit).
- candidates.order_candidates: new quality_first sort path — profile
quality rank dominates, confidence/peer signals break ties. Priority
path is byte-for-byte unchanged (regression-locked by tests).
- task_worker passes quality_first + targets through; skips the redundant
hybrid-fallback block in best-quality mode (pool already covered it).
- Per-source retry budgets unchanged: a source that spends its budget is
added to exhausted_download_sources and thus dropped from the whole
pool. Independent of post_processing.retry_exhaustive.
- Query generator NOT touched.
Also clarifies the "Allow fallback" setting wording: it accepts OFF-LIST
quality as a last resort (not "walk down my list"), and notes that
lossy_copy.downsample_hires also bypasses the quality gate — the cause of
16-bit/MP3 files slipping through a 24-bit-only profile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Toggle to search all sources and download best→worst by actual audio
quality, vs today's priority-first (first satisfying source wins).
Reuses exhausted_download_sources for per-source budget removal; query
generator and budget counters untouched. Independent of retry_exhaustive.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
get_audio_quality_string now appends the FLAC sample rate so the Downloads
quality chip and library history read e.g. 'FLAC 24bit/96kHz' instead of just
'FLAC 24bit' — surfaces hi-res frequency (44.1/48/96/192kHz) at a glance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Unverified review actions (play/audit/approve/delete) only rendered for
persistent-history rows, so a freshly-completed unverified download — still a
live task without a 'history-<id>' task_id — showed no buttons until it aged
into history (Quarantine always worked because it uses the quarantine entry
id). Thread the library_history row id from import through to the live task
(add_library_history_entry now returns lastrowid -> context._history_id ->
task.history_id -> /api/downloads/all), and resolve verifHistoryId from it.
Also surface the real probed audio quality (mutagen-read from the file, e.g.
'FLAC 24bit') on completed rows as a chip, so you can see what was actually
downloaded.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Some Monochrome instances only have 30-second Tidal DOWNLOAD access: the HLS
variant playlist for a 220s track comes back as ~30s of segments + ENDLIST
(verified live on us-west.monochrome.tf — lossless=30s, hires=403). The client
downloaded that 30s file, which then got quarantined by the new audio guard.
Detect it at manifest time: sum the playlist's EXTINF runtime and compare to
the track's real duration (get_track_info). When the playlist is < 85% of the
track, decline the manifest and rotate the instance, so the download falls
through to a real source (Soulseek/Qobuz/Tidal/Deezer) instead of fetching a
preview. Best-effort — unknown duration disables the check (the post-download
audio guard remains the safety net).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The actual HiFi/Monochrome bug isn't silence padding — it's a TRUNCATED file:
the container claims the full length (e.g. 3:08) but only ~30s of audio
decodes. silencedetect finds nothing (there's no silent audio, just missing
audio) and ffmpeg's time= even reports 0 with no error, so the duration and
quality guards all pass.
Detect it by decoding and comparing the real audio length (astats sample
count / sample rate) against the container duration: reject when the real
audio covers < 85% of the claimed length. detect_broken_audio() runs this
truncation check first, then the silence-ratio check. Wire it into the guard
that runs at the integrity/length verification point.
Verified on the real file: 'only ~30s actually decodes of a 188s file (16%)';
a normal 180s file is not flagged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The silence guard sat after the quality guard, so a strict quality profile
quarantined every file before silence detection ever ran. Move it to right
after check_audio_integrity (where the length is verified) and before the
AcoustID/quality gates, so a mostly-silent file is caught regardless of its
quality verdict and reported with the correct reason. Same quarantine +
next-candidate retry pattern (trigger='silence').
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
HiFi/Monochrome HLS assembly can produce a file with the correct container
duration but only ~30s of real audio + silence padding — the duration and
quality guards both pass, so nothing caught it until you listened. Add
core/imports/silence.py: ffmpeg silencedetect over the audio, reject when the
silent fraction exceeds 50%. Wire it into the post-download pipeline with the
same quarantine + next-candidate retry pattern as the quality guard
(trigger='silence'), and surface it via import_rejection_reason. Fails open
when ffmpeg/mutagen are unavailable so tooling problems never quarantine a
legit file.
Also mark 'quality filter' and 'silence guard' failures as recoverable
quarantine rows in the downloads UI (were shown as plain failures).
Verified end-to-end: a 30s-tone + 180s-silence FLAC is flagged '86% silence
(only ~30s audible of 210s)'; a 210s tone passes. 7 parser unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove the per-source download-quality dropdowns (Tidal/HiFi/Qobuz/Deezer/
Amazon) — with the global ranked-targets system they were redundant and
conflicting. Add quality_tier_for_source(): picks the LOWEST source tier
that satisfies the user's top target (respects the quality ceiling, saves
bandwidth) or the source's max as best effort. Every source's search +
download + retry path now derives its tier from the global profile instead
of config_manager.get('<source>_download.quality').
Settings keep the per-source allow_fallback toggles; the quality selects are
replaced with a note pointing at Quality Profile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Quality Profile is now a global system driving every source, so stop hiding
it behind Soulseek being active — show it on the downloads tab regardless.
On the review queue, make Unverified rows row-clickable to open the audit/
info modal (matching Quarantine rows, which were already clickable); the
action buttons stopPropagation so they don't double-trigger.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
check_flac_bit_depth now delegates to check_quality_target, which probes the
real file and treats bit depth as a MINIMUM (24-bit satisfies a 16-bit
target) — the old context-string parsing, per-quality bit_depth_fallback, and
'reject higher bit depth' semantics are gone. Rewrite the wrapper tests to
the probe-based model and update the rejection-reason assertion to the
unified 'quality filter' wording.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Quality guard: rejects with a 'file is X, wanted Y' reason (the string the
track-detail modal surfaces), accepts when a target is met or fallback is on,
skips when unprobeable.
force_import isolation: the 'quality' bypass must not skip the AcoustID check
and vice-versa; a quality reject persists trigger='quality' (not 'acoustid')
in the sidecar — so a quality mismatch never routes through the force_import
path (reserved for AcoustID version-mismatch).
Model: lossy matches a MINIMUM bitrate (>=, a range); lossless matches on bit
depth + sample rate, never exact bitrate, so a FLAC's varying bitrate (mono /
compression) can't falsely reject it. v2->v3 migration preserves order.
47 passing across the quality + guard suites.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the v2 per-tier quality UI (FLAC on/off + MP3 sliders + bit-depth
buttons) with a draggable ordered target list. Each row shows its rank +
label with move/delete; an add form picks format and, for lossless, bit
depth + min sample rate, or for lossy a minimum bitrate threshold (>=) so
VBR/mono files aren't falsely rejected. Persists v3 ranked_targets via the
existing /api/quality-profile. Presets + fallback toggle retained; help
text and tooltip rewritten for the new top-down source-gating model.
Verified: v3 profile round-trips UI shape -> DB -> load_profile_targets.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
search_and_download_best applied confidence scoring to streaming results
but never quality-ranked them — only the Soulseek path did. Apply
rank_for_profile to the confidence-passing survivors so the best version
wins (match first, then quality). Stable ranking keeps confidence order
within an equal tier; an "or scored" fail-safe keeps a candidate to try.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add core/quality/selection.py: rank_with_targets() returns (ranked,
satisfied) where satisfied = a candidate meets a real target (strict).
load_profile_targets()/rank_for_profile() are the DB-backed wrappers.
search_with_fallback now skips a source that can deliver no target-meeting
quality and escalates to the next (source priority still wins among
satisfying sources; first source's results kept as fallback unless the
profile disables it). Returns RAW tracks — the satisfied check is a coarse
source gate; match-filtering + final ranking stay in the orchestrator so
the correct track is never pruned. Ranking is fail-open: a ranking error
never drops a source's real results.
Tested: rank_with_targets satisfied/fallback matrix + engine escalation,
stop-on-first, raw-not-pruned, fallback on/off. Amazon field test updated
for the corrected format token.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add core/quality/source_map.py centralising each source's tier->AudioQuality
mapping (Tidal/HiFi tiers, Qobuz real kHz/bit-depth, Deezer codes, Amazon
codec/tier). Add TrackResult.set_quality() to merge a mapped AudioQuality
onto a result. Wire HiFi, Qobuz, Deezer, Tidal, Amazon search results to
stamp real sample_rate/bit_depth so the global ranker no longer relies on
crude kbps heuristics for streaming sources. Fixes Qobuz/Amazon display
labels ('FLAC 24-bit/192kHz', 'Lossless') breaking format derivation.
Tested: 22 passing (mappers + set_quality merge semantics).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Design for source-binding + quality-aware fall-through ranking
(per-source population, source-priority-king), ranked-targets UI,
quarantine-reason surfacing, and tests. Locks the constraints that
quality quarantine reuses the trigger='quality' retry path and never
sets force_imported (reserved for AcoustID mismatches).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
wolf's report: Spotify shows 'Calma - Remix', Find & Add searches that literal
string, but the library stores the track as just 'Calma' (only the 3:58 duration
marks it the remix). The literal LIKE '%calma - remix%' misses, so it fell to the
OR-fuzzy fallback which floods on the common word 'remix' (20 unrelated '... remix'
hits). Dropping '- Remix' (searching 'Calma') finds it instantly.
Fix: search_tracks (and api_search_tracks) now retry on the BASE title — the part
before Spotify's ' - ' version separator — BEFORE the OR-fuzzy flood. So
'Calma - Remix' resolves to 'Calma' (or 'Calma (Remix)') and the noise fallback is
never reached when the base matches. New core.text.title_match.base_title_before_dash
(splits the first spaced ' - '; leaves bare hyphens like 'Up-Tight' alone).
Tests: pure helper (3) + real-DB integration reproducing the Calma case, the
parenthesized-remix variant, plain-title-unaffected, and no-flood (4). 64
search/match tests green.
The Deezer ARL field round-trips a redaction sentinel for a saved-but-untouched
secret (shown as dots). The save path already guards against the sentinel
overwriting the real token (ConfigManager.set), so the ARL was never actually
lost — but the connection TEST read the field value and sent the sentinel as the
token, so Deezer returned USER_ID=0 ('Invalid ARL token') after navigating away
and back. That false failure made it look like the ARL kept resetting.
Fix:
- ConfigManager.resolve_secret(key, posted): empty/sentinel posted value -> the
stored value; a real string -> a genuine new secret. Reusable for any secret
connection-test (single source of truth).
- /api/deezer-download/test now resolves the effective ARL via resolve_secret, so
an untouched field tests the stored token.
- testDeezerDownloadConnection() strips the sentinel before sending (untouched ->
empty -> backend uses the saved token).
Seam/regression tests for resolve_secret (sentinel/empty/none -> stored, real ->
passthrough, nothing stored -> empty). JS integrity 64 green.
Enrichment matched artists by NAME ONLY (0.85 gate), so for a common name
('Rone' has ~5 artists) it stored whichever the source ranked first — often the
wrong one, which then drove a wrong/sparse library 'Standard' discography while
'Enhanced' (the real owned albums) showed the full set.
Fix — use the decisive signal the library already has (the albums you OWN):
- worker_utils: pick_artist_by_catalog() + catalog_overlap_score() +
owned_album_titles()/release_titles(). When 2+ candidates clear the name gate,
fetch each one's catalog and choose the one overlapping the owned albums; falls
back to the current best-by-name pick when there's nothing to disambiguate or
no overlap (so the common single-candidate path makes no extra API calls).
- Wired into Spotify (covers Spotify-Free, same client), iTunes, Deezer (now
multi-candidate search_artists + get_artist_info store), and MusicBrainz
(match_artist gains owned_titles; release-groups as the catalog).
Re-match path (#868):
- build_reset_query now also clears the stored source-ID column for artist/album
item resets — previously a 're-match' only nulled match_status, so the worker's
existing-id short-circuit re-confirmed the WRONG id and never re-resolved. Tracks
excluded (ids live in tags, not a column).
- MusicBrainz also self-corrects its 90-day name->mbid cache: match_artist bypasses
a cached mbid whose catalog has ZERO overlap with the owned albums, so a re-match
isn't blocked by a stale wrong cache entry.
Tests: shared selector (9), per-worker disambiguation for all 4 sources + MB
backward-compat + MB cache-revalidation (8), reset-clears-id (2). 99 worker/
enrichment tests green.
Four refinements on top of the tiered matcher:
1. Direct source track-ID tier (new top tier): enrichment writes each source's own
track ID into the file tags (spotify_track_id/deezer_track_id/itunes_track_id/...).
If we have the active source's track ID, fetch that exact track by ID via
get_track_details — zero search. Tiers are now: track-ID -> ISRC -> album->track
-> artist+title. _read_file_ids reads ISRC + all per-source IDs in one tag read.
2. Skip already-proposed tracks: a re-run loads existing finding entity_ids for the
job and skips those tracks before any API call (pending stays deduped, dismissed
stays dismissed) — re-runs are cheap.
3. Wrong-version guard: the fuzzy tiers (album-search + track search) reject a
candidate whose length differs from ours by >5s (live/edit/remix with same title).
_load_tracks now selects t.duration; exact tiers (track-ID/ISRC/stored-album-ID)
skip the guard.
4. Tighter album matching: same-title cuts in an album are disambiguated by closest
duration when track_number doesn't decide it.
Findings record matched_via = track_id | isrc | album | search. 30 repair tests pass
(added track-ID tier, duration guard, dedup-skip, and unit coverage).
Replaces the Soulseek-only bit-depth heuristic with a source-agnostic
quality system that works across all download sources.
## core/quality/model.py (new)
- AudioQuality dataclass: format, bitrate, sample_rate, bit_depth
- QualityTarget: one ranked entry in the user's priority list
- filter_and_rank(): source-neutral candidate ranking
- rank_candidate(): scores any AudioQuality against ranked_targets
- v2_qualities_to_ranked_targets(): migration helper
## core/download_plugins/types.py
- SearchResult gains sample_rate + bit_depth fields
- audio_quality property returns unified AudioQuality
- AlbumResult gets audio_quality aggregated from tracks
## core/soulseek_client.py
- Parses slskd attributes array (type 4=sample_rate, type 5=bit_depth)
- Real values instead of kbps heuristic
- filter_results_by_quality_preference() replaced by filter_and_rank()
## database/music_database.py
- Quality profile v3 with ranked_targets list
- Auto-migration v2 → v3 on load
- Presets (audiophile/balanced/space_saver) updated to v3
## core/imports/file_ops.py
- probe_audio_quality(): reads actual downloaded file via mutagen
returns AudioQuality with ground-truth values
## core/imports/guards.py
- check_quality_target(): replaces check_flac_bit_depth
checks all formats/sources against ranked_targets
- check_flac_bit_depth() kept as backwards-compat wrapper
## core/imports/pipeline.py
- Uses check_quality_target() instead of check_flac_bit_depth()
- Quality mismatch triggers _requeue_quarantined_task_for_retry('quality')
so next-best candidate is tried before failing (same as AcoustID)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the blind fuzzy search with a smart hierarchy that uses the data we
already have, best identity first:
1. ISRC embedded in the file tags (enriched track) -> exact track.
2. Album -> track: use the album's stored source ID (albums.spotify_album_id /
itunes_album_id / deezer_id / musicbrainz_release_id / audiodb_id) when the
ALBUM is enriched (even if the track isn't); else find the album by searching
'artist album', then locate our track in that album's tracklist by normalized
title (track_number breaks ties). Pins the exact album context. (artist->album->track)
3. Plain artist+title search with similarity scoring. (artist->track) — loosest.
_load_tracks now returns dict rows (adds track_number + the album source-id
columns). Findings record matched_via = isrc | album | search. All clients
(spotify/deezer/itunes/discogs) expose search_albums + get_album_tracks with a
uniform {'items': [...]} shape, so the album tier is source-agnostic.
26 repair tests pass (added album-tier + _find_track_in_album coverage).
The job was doing a blind fuzzy search for every low-quality track, ignoring that
enrichment writes each track's ISRC + per-source IDs into the file tags. Now it
reads the file's embedded ISRC and resolves the EXACT track via each source's
'isrc:' search (universal cross-source key), guarded by an ISRC-equality check so
a source that ignores the syntax can't produce a false match — exact track, exact
album context, one call. Falls back to the name/artist fuzzy search only for
un-enriched tracks with no usable ISRC. Findings record matched_via=isrc|search.
4 new seam tests (guard accept/reject, ISRC-preferred-over-fuzzy, fuzzy fallback).
Phase 2 of the redesign. The tool that judged quality by extension and auto-dumped
matches into the wishlist is gone; quality scanning is now the reviewed
quality_upgrade repair job.
Removed:
- Frontend: Tools-page Quality Scanner card, its JS handlers/poller/socket listener,
help tooltip + tour entry (webui index.html, core.js, helper.js, wishlist-tools.js).
- Backend: /api/quality-scanner/{start,status,stop} endpoints, the in-memory state +
executor + 1s socket broadcast, the QualityScannerDeps/run_quality_scanner shim.
- core/discovery/quality_scanner.py: the auto-acting worker + deps class (the shared
match/normalize helpers stay — the new job imports them).
Rewired:
- Automation 'start_quality_scan' action now triggers the quality_upgrade repair job
via repair_worker.run_job_now() (AutomationDeps gains run_repair_job_now, drops the
4 scanner fields). Action block's vestigial scope field removed (scope lives in the
job's settings now). NOTE: the 'quality_scan_completed' trigger no longer fires (the
repair job doesn't emit it).
- Updated all automation test _build_deps helpers + conftest tool-progress harness;
deleted the obsolete worker test. 528 affected tests pass; 6123 collect cleanly.
QUALITY_TIERS / _get_quality_tier_from_extension kept (used elsewhere).
The old Quality Scanner tool judged quality by file EXTENSION only (a 128k and a
320k MP3 looked identical), ignored the bitrate-based quality profile, used min()
of enabled tiers so the default profile flagged the ENTIRE non-lossless library,
and auto-dumped every match into the wishlist with no review.
This new repair job does it properly:
- meets_preferred_quality(): pure, bitrate-AWARE decision honoring every enabled
quality bucket (320 MP3 passes a FLAC+320+256 profile; 128 MP3 doesn't). Floor
is the worst enabled bucket, not the best.
- scans watchlist artists or whole library, finds below-quality tracks, matches a
better version at scan time (reusing the existing tested match helpers), emits a
FINDING showing the match + confidence. Off by default; nothing auto-queued.
- _fix_quality_upgrade apply handler adds the matched track WITH album context to
the wishlist — the user-approved version of what the old tool did silently.
- Transcode/fake-lossless detection intentionally left to the existing Fake
Lossless Detector job.
12 seam tests incl. a regression pinning the default-profile flooding bug. The old
tool is still in place; removing it + rewiring its automation action is the next step.
When the modal opens instantly (before data loads), it was rendered in the
'fresh' phase — showing clickable Start Discovery / Wing It buttons over an empty
table, even though discovery is already auto-starting. Open it in 'discovering'
instead: the footer becomes the non-interactive 'Discovering matches…' info line
and the progress text reads 'Starting discovery…' instead of 'Click Start
Discovery to begin…'. Only Close stays clickable while the table loads.
The prior UX commit removed a redundant frontend pre-fetch, but the modal was
still only opened at the END of openTidalDiscoveryModal — AFTER awaiting
/api/tidal/discovery/start, whose backend handler fetches the whole playlist
synchronously (Tidal sleeps 1s/page, ~10s) before responding. So the modal still
didn't appear for ~10s. Now open the modal first (with a 'Loading playlist from
Tidal…' note), then fire the discovery-start POST and begin polling; return early
so the shared open at the bottom is skipped for this path.
Clicking Discover on a fresh Tidal card awaited /api/tidal/playlist/<id> (which
paginates Tidal with a 1s sleep per page + rate-limit throttle, ~10s for a large
playlist) BEFORE opening the modal — and the backend discovery worker then
re-fetched the same playlist anyway. Now that the modal builds its rows from the
backend discovery results (#867), open it immediately and let discovery populate
it: no blocking pre-fetch, no redundant double-fetch of the playlist.
Two issues in the same path:
1. The shared discovery modal pre-renders one row per track from a
separately-fetched frontend track list, then the poll dropped any backend
result without a pre-rendered row (if (!row) return). When the frontend's
track fetch came back rate-limited/partial (~21) while discovery's own fetch
got all 59, the surplus results vanished. Now the modal CREATES a row for any
result lacking one, so authoritative backend results drive the list (fixes
all sources sharing the modal).
2. get_playlist hydrated a whole relationships page in one _get_tracks_batch
call, but Tidal caps filter[id] at 20/request, silently truncating larger
pages. Chunk to the cap like get_album_tracks already does.
Seam + regression tests (tests/test_tidal_playlist_batch_chunking.py).
Status checks asked is_spotify_authenticated() (official OAuth only) instead of
is_spotify_metadata_available(), so a Spotify-Free primary read as disconnected.
get_primary_source_status had spotify_free awareness but it was dead code:
get_client_for_source('spotify') returns None unless officially authed, so the
free-availability probe never had a client. Fetch the client directly for that
check; add the missing free branch to the dashboard test message. Seam + regression tests.
The reconcile read each completed task's final_file_path to find paths — but not
every import path sets it (the verification worker marks the task completed
without it), so tracks that imported via that path were silently dropped (user
saw 3 of 5 symlinks). Root cause: leaning on a fragile per-task field.
Now reconcile_batch_playlists identifies the organize playlists the batch touched
(its own + any reached via a completed track's source_info provenance) and
rebuilds each from CURRENT library ownership via _rebuild_one_from_db
(check_track_exists over membership). It just asks the library what's owned, so
it's robust to HOW a track imported (modal worker / slskd monitor / verification
worker) and still prunes tracks that left. Takes a db handle; all three callers
pass MusicDatabase().
Reconcile tests rewritten for the DB-rebuild form (organize batch, wishlist
provenance, non-organize skip, plain no-op). 973 downloads/imports/playlist
tests pass.
on_download_completed and check_batch_completion_v2 are duplicate completion
paths. Monitor-detected downloads (Deezer / slskd-monitor / verification-worker
imports) finish the batch via the V2 path, but the materialize reconcile was
only added to on_download_completed — so those batches never built playlist
folders (no '[Playlist Folder] Rebuilt' line at all). Add the same non-fatal
reconcile to the V2 path. Now all three completion points (both lifecycle paths
+ the master.py all-owned path) materialize. 550 tests pass.
The download modal auto-saves an M3U on every render (save_to_disk, no force).
When m3u_export.enabled is off it writes nothing — but only AFTER ~30s of
per-track DB search + fuzzy matching, which it then discards; fired repeatedly
during analysis it jammed the batch (0 tasks, user cancels). Bail out at the top
of generate_playlist_m3u for exactly that case (save_to_disk and not force and
not enabled). Manual 'Export as M3U' sends force=True and content-only requests
send save_to_disk=False — both unaffected.
Pre-existing bug, unrelated to the playlist-folder feature, but it was blocking
the discovery->download flow.
Symmetric to the post-download reconcile (which handles ADDITIONS): when a
playlist's membership is re-synced (the mirror step — scheduled refresh or the
manual mirror endpoint), rebuild its folder from current membership WITH prune
IF it's organize-by-playlist. So a track that just LEFT the playlist has its
symlink cleaned up the instant membership changes, not only on the next download.
Factored a shared _rebuild_one_from_db (used by the manual 'Rebuild' button and
the mirror hook) + rebuild_mirrored_playlist_if_organized. Gated to organized
playlists, non-fatal at both mirror call sites.
Now the invariant 'folder = the playlist's current owned members' holds on every
change: additions caught at download, removals caught at mirror. 2 new tests
(removed track pruned; non-organized skipped). 985 + 277 tests pass.
Replaces the two organize-only triggers with a single reconcile_batch_playlists
called at both batch-completion points. It groups the batch's newly-resolved
tracks by their per-track playlist provenance:
- the batch's OWN organize playlist → full (re)build with prune, and
- a track that completed for a DIFFERENT playlist (e.g. a WISHLIST fulfilling a
track that belongs to an organize playlist) → ADDED to that folder, no prune.
So a late wishlist arrival now lands in its playlist folder immediately, instead
of only on the next sync/manual rebuild — the folder = the playlist's owned
members, kept true on every ownership change regardless of download path. Uses
the paths the batch already captured (no DB re-match, no waiting on the server
scan/sync). Non-fatal.
3 new reconcile tests (organize full-rebuild, wishlist add-without-prune, plain
batch no-op). 983 downloads/imports/playlist tests pass.
- Settings: 'Playlists Folder' path field (Unlock pattern, separate-root help
text), a Symlinks/Copies selector, and a 'Rebuild playlist folders now' button
(standard test-button style). Wired through PATH_INPUT_IDS / load / save, plus
'playlists' added to the settings save allowlist so it persists.
- POST /api/playlists/materialize/rebuild → rebuild_organized_playlists_from_db:
rebuilds every organize-by-playlist folder from CURRENT ownership, re-matching
each track with check_track_exists (name, not IDs) so it self-heals after a
reorganize / membership change. +1 test.
70 materialize tests + JS integrity pass; settings round-trip wiring verified.
- Routing (step 5): organize-by-playlist tracks no longer set the per-track
_playlist_folder_mode flag, so they import NORMALLY into Artist/Album — exactly
what a normal download does. _playlist_name provenance is kept (origin.py).
- Triggers (step 4): build the playlist folder from the batch's own payload at
both end-of-flow points — the all-owned path in master.py (no downloads, so the
lifecycle never runs) and the batch-complete hook in lifecycle.py (after
downloads). Both gated on playlist_folder_mode, both non-fatal.
Works for the all-owned case (the smack test that did nothing before) and for
mixed owned/downloaded, with no source-ID or mirrored-playlist dependency. The
materialized folder uses the default ./Playlists root + symlink mode until the
Settings UI is added.
Updated the master test to assert the new contract (provenance kept, routing
flag gone). 979 tests pass.
materialize_playlist_from_batch(batch, download_tasks, config) collects the real
on-disk path of every resolved track from the batch's OWN payload — owned via
analysis_results.matched_file_path, downloaded via tasks.final_file_path — runs
each through the playback path resolver (Docker-correct), de-dupes, and hands the
list to rebuild_playlist_folder. Gated on playlist_folder_mode.
No re-matching, no source IDs, no mirrored-playlist lookup — works for any
organize-by-playlist download including the all-owned case. 5 tests. Still
isolated; the triggers wire it in next.
- settings: playlists.materialize_path (separate root, mapped apart from the
music library so the media server never double-scans it) + materialize_mode
(symlink|copy).
- core/playlists/materialize.py: pure filesystem engine that (re)builds a
playlist folder of relative symlinks (or copies) into the real library —
idempotent, prunes stale entries, disambiguates filename collisions, never
escapes the root, and auto-falls-back to copy when the FS can't symlink.
No DB, no app state; ops injectable. 13 unit tests.
Isolated + additive — nothing live calls this yet (stitcher/trigger/routing
come next).
The download analysis already matches every track to a library row via
check_track_exists / manual match, then discarded the result. Keep it: each
analysis_results entry now carries matched_file_path + matched_track_id (the
owned file's real location, or None). Symmetrically, a completed download task
now records final_file_path (where the import landed).
Purely additive, no behavior change, no new matching, zero perf cost — just
stops throwing away what the pipeline already computed. This is the foundation
for playlist materialization: owned + downloaded tracks both report where their
real file is, so the folder can be built by name match, not source IDs.
- The download-modal 'Organize by Playlist' toggle had no onchange, so flipping
it never saved or synced the saved per-playlist preference. Add the handler
(source auto-derived from the ref) so both controls read/write the one
organize_by_playlist value — manual action persists, the other reflects it.
- loadDashboardSyncHistory polled /api/sync/history every 30s even while the
launch-PIN/login gate was active, 401-spamming the log. Skip when locked, and
on a 401 (stale session after a restart) surface the unlock screen so it
self-heals instead of spamming.
A DB<->filesystem path mismatch (Docker volume change, remount, Music
Paths unset for the container) makes EVERY library file fail to resolve
to a DB track, so the orphan detector flags the whole library as
orphaned. The mass-orphan check only logged a warning and then created
the findings anyway — so a user batch-applying 'move to staging' or
'delete' would relocate or wipe their entire library.
Make it a hard skip (create zero findings) like the dead-file cleaner
and stale-removal paths already do (#828). Centralise the predicate as
is_implausible_orphan_flood() alongside is_implausible_stale_removal()
so the rule lives in one tested place. Small genuine orphan sets still
surface unchanged — only an implausibly large flood (>50% and >20) is
suppressed.
Tests: seam cases for the new predicate + scan-level regressions (mass
mismatch -> 0 findings; small genuine set -> still reported).
Per feedback — instead of two export buttons (one on the watchlist filter bar, one
in the library header), there's now a single "Export" button. The modal gains a
Watchlist | Library scope toggle at the top; switching scope re-fetches and shows/
hides the "library counts" option (library-only). One place, both rosters.
Also relaxed the two export endpoint wiring tests — they asserted an empty DB,
which is false in a shared test run (the artists table may already hold rows); now
they assert a valid JSON array + headers/columns instead. The endpoints are
unchanged and verified against real data.
Extends the watchlist export to the full library. The exporter is now general
(core/exports/artist_export.py, renamed from watchlist_export) — adds tidal/qobuz
links and an extra_fields passthrough, so the library export also carries
lastfm/genius URLs + soul_id, and an optional "library counts" toggle adds owned
album/track counts per artist.
- GET /api/library/artists/export?format=&links=&contents= — pulls every artists
row, normalizes onto the canonical *_artist_id keys, optionally GROUP-BY counts
for album/track totals.
- The export modal is now openArtistExportModal(scope): "Export Library" button in
the library header + the existing "Export" on the watchlist bar (a thin wrapper).
Library mode shows the extra "library counts" toggle.
Tests (11): builder across formats + the new tidal/qobuz links + extra_fields
columns; watchlist + library endpoint wiring. 64 integrity green; ruff clean.
An "Export" button on the watchlist filter bar opens a modal (same aesthetic as the
artist DB-record inspector) to export your whole watchlist roster — each artist's
name + source IDs (spotify / musicbrainz / deezer / discogs / itunes / amazon),
with an optional "external links" toggle that adds the discography URLs built from
those IDs. Live preview, copy, and download in the chosen format.
- core/exports/watchlist_export.py: pure builder (json/csv/txt + links, present-IDs
only, deterministic columns) — the single source of truth, fully unit-tested.
- GET /api/watchlist/export?format=&links= shapes the roster + returns it (with
X-Export-Count / X-Export-Ext headers for the modal).
- Frontend reuses the DB-record helpers (_jsonSyntaxHighlight / _arecCopy).
Tests (8): builder across json/csv/txt, links on/off, present-ids-only, empty +
bad-format fallback, mime/ext, and endpoint wiring. ruff clean; 64 integrity green.
Scoped to the watchlist for v1; library-wide export + a "library contents"
(owned albums/tracks) option are natural follow-ups.
New Aria2 JSON-RPC adapter, alongside qBittorrent / Transmission / Deluge. Aria2's
RPC (default :6800/jsonrpc) maps cleanly onto the uniform adapter contract:
- the --rpc-secret token leads every call as "token:<secret>" (no username — the
secret uses the existing password field),
- addUri returns a GID (our torrent id); tellStatus → TorrentStatus with state
mapping (active→downloading, or seeding once the payload is complete; waiting→
queued; etc.),
- remove picks forceRemove vs removeDownloadResult by status, and (since aria2
doesn't delete files on remove) unlinks the file paths itself for delete_files,
- bare-host URLs get /jsonrpc appended.
Wired into adapter_for_type + the Settings dropdown (with a help note: port 6800,
secret in the Password field). All adapter methods go through the same interface,
so the stall/orphan handling and downloads pipeline work unchanged.
Tests (9): registry wiring, state mapping (incl. active→seeding), token-prefixed
params, /jsonrpc fixup, status parse (+ name fallback, no div-by-zero). 126 torrent
tests green; ruff clean.
A maintenance job to keep the music library tidy — finds empty folders left behind
by imports/relocations/deletions (empty artist/album dirs, or dirs holding only OS
junk like .DS_Store/Thumbs.db) and removes them.
Safety is the focus (deleting directories is destructive):
- only TRULY empty folders are flagged — a folder with a cover image or any audio
is never touched; only OS-junk files count as "no real content" (a setting),
- the library root + symlinked dirs are never removed,
- walks bottom-up so a parent left empty by its removable children cascades,
- the apply handler RE-CHECKS emptiness at delete time, so a folder that gained a
file between scan and apply is left alone.
dir_is_removable + remove_empty_folder are pure/injectable seams. Wired through the
job registry, repair_worker apply handler (_fix_empty_folder), fixable-types, and
the findings UI. Opt-in (default off), weekly interval.
Tests (10): removable decision (empty / real-file / surviving-subdir / junk-only /
strict mode) + apply re-check (removes empty + junk, refuses content/root/symlink).
Repair + integrity suites green; ruff clean.
A transient ping failure (network blip, Navidrome busy mid-scan) makes
_setup_client null out the configured creds, and _connection_attempted then
latches the client "disconnected" — so is_connected() returned False forever until
the user hit the manual Test button to re-read config. That's the reported
"disconnects every 5-10 min, reconnects instantly on Test."
Fix: ensure_connection no longer latches on a failed attempt — once a short
throttle (_RECONNECT_THROTTLE_S = 20s) elapses it re-attempts, and is_connected()
triggers that retry whenever it's currently disconnected. So a blip recovers on its
own within the next status check, no manual reconnect. The throttle prevents ping
storms when Navidrome is genuinely down.
Tests: transient failure self-heals after the throttle (and doesn't re-ping within
it); a connected client never re-pings; first connect attempts once. 115 navidrome/
media-server tests green.
The per-member login password was easy to miss — the lock button had no dedicated
styling and nothing showed who was actually stranded. Now, when login mode is on:
- a banner explains every member needs a login password (+ to use the lock button)
- each member row shows a status pill: "⚠ No login password" (red) or "🔒 Login
ready" (green) — so you can see at a glance who can't sign in yet
- the lock button is properly styled (it had none) and pulses red when that member
has no password, so the action you need is obvious
When login mode is off, none of this shows (no noise). Pure UI/clarity — no
behavior change.
Invariant: while security.require_login is on, every profile must have a login
password or it's locked out. Previously only the admin's own anti-lockout existed,
so members could be stranded (created without a password, or login flipped on while
passwordless members existed). Closed all the write-points:
core/security/login_provisioning.py (pure policy, single source of truth):
- members_without_password(profiles) — non-admin profiles that can't sign in
- create_needs_password(require_login) / removing_password_strands(require_login)
Wired into web_server:
- create_profile: while login is on, a new member must be given a password (400
otherwise) and it's set on creation.
- enable-login (settings save): refuses to turn login on while any member lacks a
password — lists them — same shape as the existing admin anti-lockout.
- set-password: refuses to CLEAR a password while login is on (would strand them).
UI: Create Profile form gains a login-password field (alongside the optional PIN);
the Manage Profiles per-member password button (prior commit) covers existing
members + changes.
Tests: pure policy seam + endpoint enforcement (create blocked w/o password when
on, allowed w/ password, no friction when off, clear blocked when on). 442
profile/settings/auth tests green; ruff clean.
Closes the gap where "Require login" was effectively admin-only: a member with no
password can't sign in and can't bootstrap one themselves (can't log in to reach
the setting). The set-password endpoint already allowed admin→anyone — this adds
the missing UI.
Each non-admin row in Manage Profiles gets a lock-icon button that opens an inline
form to set / change / remove that member's LOGIN password (separate from the
quick-switch PIN), with a confirm field + a hint explaining when it's used. Admin
rows don't get it (admin manages their own in Settings → Security, which keeps its
anti-lockout). textContent-only rendering, so a profile name can't inject markup.
Test: admin sets a member's password → the member can then authenticate
(verify_profile_password) and a wrong password fails; admin can clear it back to
no-login. 64 script-split integrity tests green.
Post-processing applies ReplayGain only to slskd/WebUI downloads — content added
via Lidarr, the REST API, or by hand never got it, and there was no way to (re)apply
RG to existing tracks or fix ones where analysis failed (raised in #437 + comments).
New ReplayGain Filler repair job (sibling of Lyrics/Cover Art fillers): scans for
tracks with no ReplayGain track-gain tag and creates a finding per track; the scan
only READS tags (cheap) and no-ops when ffmpeg is absent. Applying a finding runs
the same ffmpeg ebur128 analysis the import pipeline uses (gain = ref - LUFS) and
writes the RG tags in place — no moves, no re-matching. Opt-in (default off),
schedulable like the other maintenance jobs.
Wired: job registry, repair_worker apply handler (_fix_missing_replaygain) +
fixable-types, and the findings UI (label / fix-button / detail rows).
Tests: pure needs_replaygain decision (missing/blank/present/+0.00-is-tagged) +
the apply handler's analyze→compute→write seam with the pipeline gain formula,
ffmpeg-absent + missing-file guards, and registration. 93 repair tests green.
Beckid's ask: bypassing the login/PIN overlay shouldn't show the app pages at all,
not even the (data-less) chrome. The overlay was cosmetic-on-top; the static shell
sat behind it, so "Hide Distracting Items" exposed the empty UI.
Now the lock screens add body.app-locked, and a CSS rule hides every body child
except the two lock overlays themselves (display:none !important). Safari's
hide-element trick can only ADD hiding — it can't undo this rule — so removing the
overlay leaves a blank page. initApp() drops the class once authenticated (first
line, before component layout init). Defense-in-depth on top of the server-side
HTTP + WebSocket gating, which already blocks any actual data.
Targeted + safe: the app shows by default (no blank-screen risk); only an active
lock hides it. Profile picker (not a security lock) is unaffected.
The integration test only exercised the launch-PIN path; the actual #852 report is
the "Sign in to SoulSync" username/password modal. Add login-mode cases: socketio
connect is rejected when require_login is on + unauthenticated, allowed once
login_authenticated. Confirms the WS gate covers both overlays.
Typing SOULSYNC_WEB_BIND_HOST=0.0.0.0 every launch is awkward. `python dev.py --lan`
sets it for you (binds 0.0.0.0 so other devices can reach it); plain `python dev.py`
stays localhost-only. Set before build_backend_env() so it propagates to both the
direct (Windows) and gunicorn backend modes.
The dev gunicorn config hard-bound 127.0.0.1:8008, so the dev server was
unreachable from any other device — even via the host's own LAN IP. Read the bind
host/port from SOULSYNC_WEB_BIND_HOST / SOULSYNC_WEB_BIND_PORT (same vars the
direct web_server.py run already uses), defaulting to the existing 127.0.0.1:8008.
Default behavior is unchanged (localhost-only), so other devs notice nothing.
To reach the dev server from another device on the LAN, opt in explicitly:
SOULSYNC_WEB_BIND_HOST=0.0.0.0 python dev.py
A small glowing button at the bottom-right of the artist hero (library artists
only) opens a programmer-style modal showing the COMPLETE artists DB row — every
source id + match status, cached bios / tags / similar / urls, soul_id, timestamps,
the lot (62 columns) — plus owned album/track counts.
- Backend: GET /api/artist/<id>/record returns the full row with JSON-text columns
(genres, aliases, lastfm_tags/similar, discogs_urls, …) decoded into real
arrays/objects, + album/track counts. 404 for non-library artists.
- Frontend: editor-themed modal (Tokyo-night tokens) with a Fields tab (copyable,
filterable key/value rows) and a syntax-highlighted JSON tab. Copy-all-as-JSON,
per-value copy (HTTP/Docker clipboard fallback), and Save .json. Esc / click-out
to close. Helpers namespaced (_arecEsc) so they can't clobber the shared globals.
Tests: endpoint returns the full row with decoded JSON + counts; 404 for a missing
artist. 64 script-split integrity tests still green; ruff clean.
The dev-nightly build runs `ruff check .` before "Build and push to GHCR" in the
same job, so the three S110 (try/except/pass) errors introduced since the last
green build (ce6ce4d) failed the lint step and SKIPPED the image push entirely —
every dev-nightly since #704 went red, so the dev image was never rebuilt and none
of the recent fixes (incl. the #852 WebSocket login-bypass fix) ever shipped to
the image users pull.
All three are deliberate best-effort swallows; annotate them with the repo's
existing `# noqa: S110 — <reason>` convention rather than adding dead logging:
- relocate.py: tag write is best-effort (re-import re-derives tags)
- acoustid_scanner.py: verification-status tag is optional context
- web_server.py: audio-duration probe falls through to 0
ruff check . + compileall now clean; pytest already passed in CI at ce6ce4d.
Per the release convention: WHATS_NEW + VERSION_MODAL_SECTIONS carry only the
current release, with older cycles folded into the "Earlier" summary.
- WHATS_NEW '2.7.1': download verification & review (badge, persistence, review
queue), the #852 websocket login-bypass fix, the acoustid Relocate action (#704),
faster artist pages (#853), the LB-weekly un-wedge (#702), the torrent metaDL
stall + orphan fix, and the smaller fixes (#851/#840/search auto-select) +
contributor PRs (#845/#848/#850). 2.7.0 rolled into "Earlier versions".
- VERSION_MODAL_SECTIONS: verification & review leads, security fix section,
fixes list, and an "Earlier in 2.7.0" aggregator replacing the 2.6.x one.
- Fixed the "Go to page" links: the downloads page id is 'active-downloads', not
'downloads' — the old entries' links silently did nothing.
PR #853 added artist album-list caching to Deezer, but unlike the Spotify path it
had no equivalent of the truncated-fetch guard: Deezer paginates, and a transient/
malformed response on page 2+ (artist with >100 albums) broke the loop and cached
the PARTIAL list as the full discography — serving an incomplete album list from
cache until TTL.
Fix: track whether pagination finished cleanly. A malformed/empty-of-data response
mid-walk now clears a `complete` flag and the artist→album-LIST is cached only when
complete. Individual album entities still cache regardless (each is complete; we
just have fewer). A clean end (short page / empty page / reached limit) still caches
as before.
Tests: a page-1-ok / page-2-errors walk no longer caches (second call refetches
instead of serving a permanently-incomplete list); a clean two-page walk still
caches (happy path intact). 181 deezer/metadata tests green.
noldevin: a magnet stuck "downloading metadata" ran 11h despite a 15-min stall
timeout, got cleared from SoulSync but left active in qbit, then re-grabbed as a
duplicate. Two bugs:
1. Stall never fired on metaDL. StallTracker reset its clock on any `downloaded`
byte increase, but a metaDL torrent's byte counter still ticks up from DHT/peer
protocol overhead while making no real progress — so the clock reset forever.
Fix: the byte counter only counts once metadata is in (size>0). During the
metadata phase (size==0) the only thing that counts as progress is *obtaining*
metadata, so a magnet that can't even do that within the timeout is correctly
flagged stalled. size=None preserves the old byte-only behavior (back-compat).
2. Orphaned in qbit. The monitor's stall exit removed the torrent, but the `error`
exit and the 6h deadline exit only marked the download failed — leaving the
torrent active in qbit, untracked here, so SoulSync re-grabbed the same dead
torrent (qbit logs the duplicate-add). Fix: both terminal exits now run
_cleanup_torrent (shared with the stall path), which removes+deletes (abandon)
or pauses per the stall action — nothing is left orphaned.
Tests (10 new): metaDL byte-noise no longer resets the clock (stalls at timeout);
obtaining metadata resets it; real byte-progress still tracked after metadata;
_cleanup_torrent removes+delete_files on abandon / pauses on pause / no-ops on
empty hash or no adapter / swallows a client error. 151 torrent tests green.
The #832 fix enforces the launch PIN / login via a Flask before_request hook, but
that hook does NOT run for the socketio handshake — empirically a normal endpoint
401s while /socket.io/ returns 200 with the gate on. So removing the client overlay
(Safari "Hide Distracting Items", devtools) + opening a socket streams live data
(downloads, logs, dashboard, notifications) completely unauthenticated.
Fix: the socketio connect handler now enforces the same check and returns False
(rejects the connection) when a gate is active and the session isn't verified.
Rejecting connect blocks every downstream WS event (subscribe/join), so all live
data is covered. core/security/ws_gate.is_ws_connection_blocked is the pure seam:
login mode (when on) > launch PIN > open, mirroring the HTTP gate exactly. Fails
OPEN on a config-read error, same as the HTTP gate.
Audited every other surface empirically with the gate on + unauthenticated: SSE
streams, catch-all pages, library/dashboard data, admin endpoints, search,
image-proxy, audio-stream (incl. a /etc/passwd traversal probe) all 401; /api/v1
key-gated. The WebSocket was the only hole.
Tests (10): pure gate logic (login>pin precedence, all on/off combos) + real
socketio.test_client integration — connect rejected when gate on + unauthenticated,
allowed when gate off or PIN verified.
Root cause (from the reporter's app.log): a ListenBrainz weekly playlist syncs
through the in-memory youtube_playlist_states discovery machine. When that live
state is lost — a Docker restart, or the discovery process ending while the user
waits for the media-server scan — the DB discover-download snapshot survives but
the live state is gone. Every recovery action (Cancel/Reset/Delete) then hit
`key not in states` and returned 404 "YouTube playlist not found" (hence the
confusing "Youtube" on a ListenBrainz playlist), leaving the playlist permanently
wedged with no way to dismiss or re-sync. Works for the maintainer because a
single session with no restart keeps the live state alive.
Fix — these are cleanup ops, so "the thing is already gone" is SUCCESS, not 404:
- cancel_sync core (shared by YouTube + ListenBrainz + Tidal/Deezer/Qobuz/...) →
missing key returns idempotent success.
- reset_youtube_playlist / delete_youtube_playlist → same.
The playlist becomes recoverable: Cancel/Reset clears the dead state and the user
re-syncs fresh.
Tests: cancel_sync core (missing key = idempotent 200 not 404; present key still
cancels + clears the worker + reverts phase); endpoint-level idempotency for
cancel/reset/delete; updated the old test that locked the 404 wedge. 834 sync/
discovery tests green.
The 'retag' fix corrects a mismatched file's tags/DB but leaves it in the WRONG
artist/album folder, so the library shows the right title while the file sits under
the previous track. AcoustID yields only title+artist (no reliable album), so an
in-place move has no safe target.
New 'relocate' action: retag the file, move it into Staging, drop the stale tracks
row, and clean up the emptied folder. The auto-import worker (which watches Staging)
re-identifies it with full metadata and files it correctly — reusing the import
pipeline instead of guessing a destination.
- core/repair_jobs/relocate.py: pure, injectable orchestration (retag -> move ->
drop row) + collision-safe staging_destination. Row is dropped only AFTER a
successful move, so a failed move never orphans the library entry.
- _fix_acoustid_mismatch gains the 'relocate' branch (thin wrapper: resolve path,
staging dir, drop-row closure, empty-parent cleanup).
- UI: "Relocate" button on the AcoustID-mismatch fix modal.
Tests (8): staging-dest collision suffixing; relocate happy path; tag-write failure
still relocates; FAILED move does NOT drop the row; no-tags skips write; a real
file move through safe_move_file; and a handler integration test (file moved to
staging + tracks row deleted end-to-end). Repair + integrity suites green.
The artist-separator setting + backend join already supported any delimiter; just
add ' & ' to the dropdown. Safe by construction — artists are joined from the
source's artist LIST, never split, so a name containing '&' (Florence & The
Machine) is one entry and can't be mis-parsed. Closes the join half of #840.
Locks the one-time backfill that derives verification_status for pre-column
library_history rows from acoustid_result: pass->verified, skip->unverified,
fail->force_imported; never overwrites an existing status (NULL-only); leaves
no-acoustid rows NULL; idempotent across repeated inits. Exercises the real
_initialize_database path (clears the per-process init guard to re-trigger it).
resolve_history_audio_path drives a DESTRUCTIVE delete (os.remove), but lived
endpoint-bound in web_server with zero tests. Lifted to core/matching/history_paths
with injected effects (exists / resolve_library_path / lookup_titled_paths) so the
fallback chain — and the collision-safety that stops delete() from removing the
wrong same-title file — is a clean importable seam. web_server now wraps it (DB
lookup + os.path.exists + prefix resolver injected); behavior preserved.
9 tests lock it: recorded-path hit, prefix-resolve fallback, single tracks-table
candidate, and the safety rules — multiple same-title candidates with NO artist ->
None (refuse to guess), artist filter picks only the matching path, artist named
but unmatched -> None, no-title/empty-lookup -> None. Full suite green (5906).
The candidate matcher rejected valid downloads of titles with a '/' or ':' (e.g.
Sawano's "You See Big Girl / T:T") because the unified normalize() removed those
chars ("t:t" -> "tt") while keeping the '_' that source filenames substitute for
them ("T_T" -> "t_t") — the asymmetry tanked the similarity score. Now '/ \ : _'
all map to spaces before the strip, so "/ T:T" and "_ T_T" both normalize to "t t".
Verified on the real library: similarity for the Sawano pair 0.927 -> 1.000; only
348/40786 strings (0.85%, all containing those separators) change; worst-case
joined-variant match (e.g. "12:05" vs "1205") stays 0.889, well above the 0.70
title threshold — no match regressions. Fixes the matching half of #851.
The merged PR left the review-queue's mutating endpoints ungated. Both now require
admin, matching the Phase 3 destructive-endpoint convention:
- /api/verification/<id>/delete (os.remove + drops the history row) — @admin_only,
so a non-admin on a login/multi-profile instance can't delete library files.
- /api/verification/<id>/approve (flips verification_status + writes the tag) —
@admin_only; also wrapped its DB writes in `with db._get_connection()` for
rollback-on-error + codebase consistency (was a bare conn).
Read/playback endpoints (stream/play/compare/entry/config) stay open — the app's
LAN-read model. Tests: non-admin gets 403 on delete + approve; admin isn't blocked.
On the Search page and the global search widget (both share createSearchController),
the source picker stayed empty when the active metadata source was Spotify-no-auth,
until you clicked Spotify manually.
Root cause: get_primary_source_status reports the no-auth composite as source
'spotify_free' (for display labelling). The controller's initActiveSource set
activeSource = 'spotify_free' (it's a valid SOURCE_LABELS entry), but the icon row
renders from SOURCE_ORDER, which only has 'spotify' — so no icon matched the active
source and nothing highlighted.
Fix: normalize 'spotify_free' -> 'spotify' when deriving the initial active source
(they're the same searchable source; the picker only has a Spotify icon). Now
no-auth auto-selects Spotify like plain Spotify does. One spot, fixes both surfaces.
The Your Albums Discogs collection sync stored bare release_ids while
search/discography now store tagged ('r<id>') ones (#848). This didn't cause a live
bug — the pool dedups by normalized name, and discogs_release_id is only ever
re-fetched (which handles bare via release-first) — but it left the "type travels
with the ID" invariant half-applied. Now the collection sync tags its IDs too, so
every stored Discogs album ID is uniform and a future ID comparison can't be tripped
by mixed forms.
Collection items are always releases, so they're tagged 'r<id>'. Test locks the
stored value + that a tagged collection ID routes only to /releases (never /masters).
A discography page fires 70+ cover-art requests at once. Routed through
the service worker one-for-one, that burst overruns the browser's
per-host connection pool (~6); the overflow fetches reject, and the
cache-first strategy mapped each rejection to Response.error() — which
Firefox surfaces as NS_ERROR_INTERCEPTION_FAILED, a hard, *uncached*
image failure for that load. The page renders artless cards on first
visit and only "heals" on reload (cached images shrink the burst).
Fix the image path three ways:
- Cap concurrent image fetches (semaphore, 6) so the burst queues
instead of saturating the connection pool — the actual first-load fix.
- Retry a rejected fetch once with a short backoff; most failures are
transient connection-cap rejections that clear as the burst drains.
- On final failure return a benign 504 instead of Response.error(), so a
dead image degrades to a normal broken image (recoverable next nav)
rather than NS_ERROR_INTERCEPTION_FAILED.
Cache hits bypass the throttle entirely. Static-asset strategy is
unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Going forward these only carry the current release plus one brief "earlier versions"
summary — no accumulating per-version backlog.
- WHATS_NEW: replaced the full 2.6.x→2.5.x backlog with a single '2.7.0' block
(per-profile accounts, login/recovery/reverse-proxy, the fixes, artist-sync) + an
"Earlier versions" one-liner. The "Older Versions" button auto-hides with one
version, so the nav still works.
- VERSION_MODAL_SECTIONS: five curated 2.7.0 sections + a brief "Earlier in 2.6.x".
- Content drawn from the 2.7.0 pr_description. Added a convention comment at the top
of WHATS_NEW. JS validated (string-aware brace/quote check clean); 64 integrity
tests pass.
After saving a password or recovery question, a refresh made the section look
unset (passwords are never echoed back to the browser), so it seemed like you had
to redo it. Now the saved state is reflected:
- "✓ A login password is set" appears when the admin has a password; the field
becomes "Enter a new password to change it".
- "✓ Recovery question saved: <question>" appears, the saved question is pre-
selected (preset or custom), and the answer field becomes "Enter a new answer to
change it".
- Shown both on load (applyLoginSavedState from /api/profiles, which now includes
recovery_question — not secret, already shown on the sign-in screen) and
immediately after saving.
64 integrity tests pass.
Mirror the PIN setup's confirm step so a typo can't silently set a password you
can't reproduce. Both the Step 1 admin password (Settings) and the forgot-password
reset (login screen) now require entering it twice and reject a mismatch before
saving. 64 integrity tests pass.
The security section had grown into a flat pile of toggles with hidden
dependencies. Regrouped into three labelled cards so it reads top-to-bottom:
- 🔑 Lock with a PIN — set PIN (Step 1) → Require PIN
- 👤 User accounts (login) — Step 1 admin password → Step 2 recovery question →
Step 3 Require login. The Step 3 toggle is now visually LOCKED (greyed +
disabled + "set the admin password first" hint) until an admin password exists,
so the anti-lockout rule is obvious instead of surfacing as a 400 on save. It
unlocks the moment the password is saved.
- 🌐 Reverse proxy & remote access — the proxy toggle, with the auth-proxy header
nested under it (indented), plus WebSocket origins.
- get_all_profiles/get_profile now expose has_password + has_recovery so the UI
can reflect setup state; updateRequireLoginGate() drives the lock.
- New .security-subgroup/.security-subhead/.security-nested/.security-locked CSS.
All IDs + handlers preserved. Inert unless used; default install unaffected.
64 script-split integrity tests pass.
- Settings → Security: a recovery-question picker (5 presets + Custom) + answer
field + Save, posting to /api/profiles/1/set-recovery. handleRecoveryQuestionChange
reveals the custom box.
- Login screen: a "Forgot password?" link opens a recovery view — enter username →
fetch your question → answer + new password → reset → reload signed in. Reuses the
launch-PIN overlay styling/structure (entry + recovery views).
All inert unless login mode is on, so a default/LAN install never sees any of it.
64 script-split integrity tests pass (every new handler resolves).
Closes the forgot-login-password gap. A per-profile recovery question + answer lets
a locked-out user reset their own password.
- DB: additive recovery_question + recovery_answer_hash columns (idempotent
migration). set/get-question/verify/has methods; answer is hashed (pbkdf2) and
matched forgivingly (trim + lowercase + collapse whitespace). No recovery set →
never verifies.
- Endpoints (allowlisted in the login gate so they work pre-auth):
GET /api/auth/recovery-question?username= (generic 404 when absent),
POST /api/auth/recovery-reset {username, answer, new_password} — brute-force
limited; a correct answer sets the new password + authenticates the session.
POST /api/profiles/<id>/set-recovery (admin or self) to configure it.
Tests: set/get/verify, forgiving match, hashed-not-plaintext, no-recovery-never-
verifies, full reset flow (wrong answer rejected + password intact; correct answer
resets), unknown-user 404. 25 tests pass. Next: the Settings + login-screen UI.
Pins the zero-impact guarantee: with require_login off (default), the launch PIN
still enforces exactly as before (the login deferral doesn't fire), and with both
off there's no gate at all (today's behavior).
The UI that makes opt-in login usable. Off by default → your LAN setup is unchanged
(none of this appears unless security.require_login is on).
- Login screen overlay (reuses the launch-PIN styling): username + password →
/api/auth/login → reload into the app. Shown when /api/profiles/current reports
login_required (checked before profile selection).
- POST /api/profiles/<id>/set-password (admin, or self) to set/clear a login
password, distinct from the PIN.
- Settings → Security: "Login password (admin account)" field + a "Require login"
toggle (with the anti-lockout note). Wired into the existing settings load/save.
- Sign-out button in the profile bar, revealed only in login mode (login_mode flag
on /api/profiles/current); soulsyncLogout() → /api/auth/logout → reload.
Tests: set-password sets/clears + verifies; /api/profiles/current signals
login_required. 20 login/password tests pass; 64 script-split integrity pass.
Remaining (small follow-up): a password field in the Manage Profiles edit form so
admins can set OTHER profiles' passwords from the UI (the endpoint already exists).
The backend auth for opt-in username/password mode (security.require_login, default
off → zero change; the launch PIN + picker behave exactly as today).
- core/security/login_gate.py: pure gate (mirrors launch_lock) — when login mode is
on, an unauthenticated session reaches only the page shell, /api/auth/login,
/api/auth/logout, /api/profiles/current, /api/setup/status, and the key-authed
/api/v1 API. Deliberately does NOT expose the profile list pre-auth (you type your
name, not pick from a roster).
- _enforce_login before_request enforces it; _enforce_launch_pin no-ops when login
mode is on (login replaces the shared PIN, per design).
- POST /api/auth/login (username = profile name, case-insensitive; brute-force
limited per IP; generic error so names don't leak) + POST /api/auth/logout.
- Anti-lockout: the settings save refuses to turn ON login mode until the admin
account has a password.
Tests: gate blocks→login→access→logout→blocked; case-insensitive username; wrong
password / passwordless profile / unknown user all 401 generically; login list not
exposed pre-auth; can't enable login without an admin password. 12 tests pass. Next:
the login screen + set-password UI + the toggle (increment 3).
Opt-in username/password login — profiles become real accounts. This is the data
layer: a per-profile login password, kept SEPARATE from the quick-switch PIN
(different security purpose; a 4-digit PIN must not become the password guarding a
public instance).
- Additive migration: profiles.password_hash column (idempotent, metadata-flagged).
- set_profile_password / verify_profile_password / profile_has_password /
get_profile_by_name (the login username = profile name, unique + case-insensitive).
- Security default: a profile with NO password is NOT loginable (verify returns
False) — unlike the PIN where "no PIN = always valid". You can't authenticate to
an account with no credential.
Tests: migration adds the column; set/verify; no-password-never-loginable; clearing;
name lookup; and password is fully independent of the PIN. 6 tests pass. Next:
the login endpoint + require_login gate (increment 2).
Config is DB-backed (metadata.app_config) — there is no config.json — so the
reverse-proxy settings I added earlier had NO way to be set by a user and were
effectively dead. Added them to Settings → Security, next to the launch-PIN toggle:
- "Behind a reverse proxy" checkbox (security.trust_reverse_proxy) — help text notes
it's for nginx/Caddy/Traefik+TLS, to leave OFF for direct/LAN http://, and that it
needs a restart (applied at app init).
- "Auth proxy user header" field (security.auth_proxy_header) — e.g. Remote-User,
with the must-strip-client-headers warning; blank = off.
Wired into the existing settings load + save; the save loop already persists every
key in the security object via config_manager.set, so no backend change needed.
Fixed Support/REVERSE-PROXY.md to point at Settings → Security instead of a
nonexistent config.json. Off by default → zero impact for direct users.
64 script-split integrity tests pass.
Lets SoulSync sit behind Authelia/Authentik/oauth2-proxy as the gatekeeper: when
security.auth_proxy_header names a header (e.g. Remote-User), a request carrying it
is treated as already-authenticated and passes the launch lock — the proxy did the
login (with 2FA).
- core/security/auth_proxy.py: trusted_proxy_user(get_header, header_name) — returns
the user iff the configured header is present + non-empty; empty header name (the
default) → always None → feature off.
- _enforce_launch_pin ORs it into pin_verified. OFF by default, so a direct install
is unaffected AND a client-spoofed header does nothing unless the operator opted in.
- Doc'd in Support/REVERSE-PROXY.md with the must-strip-client-headers warning.
This is the lightweight Tier 3 (auth-proxy integration), not a full per-user login —
the proxy owns identity; SoulSync trusts it.
Tests: helper off/on/blank/exception-safe; integration — trusted header passes the
gate, no header is locked, and (the safety pin) a spoofed header is IGNORED when the
feature is off. 6 tests pass.
Fold a conservative security-header set into the SAME opt-in proxy mode, so it's
zero-impact when off. When security.trust_reverse_proxy is on, an after_request
adds X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN, and HSTS
(safe — only honoured over the proxy's HTTPS), via setdefault so it never clobbers
a header the proxy already set. No CSP (needs per-deploy tuning; better at the
proxy). When OFF (default), the after_request isn't registered → no headers added.
Tests: off adds none of the headers; on adds all three. Doc updated. 6 tests pass.
A publicly-exposed instance gated only by the launch PIN was brute-forceable. Added
a lenient in-memory failed-attempt limiter (core/security/rate_limit.py): 10 wrong
PINs from one IP within 5 min → 429 with Retry-After, failures age out on their own
(self-heal, no persistent lockout), and a CORRECT entry clears that IP instantly.
Wired into /api/profiles/verify-launch-pin. By design it can only ever trigger on a
flood of WRONG PINs — correct entry, a couple of typos, or a no-PIN install are
never affected, so normal use sees no change. Keyed per-IP so an attacker can't
lock out a legit user.
Tests: limiter is lenient under threshold, trips on a flood, success clears it,
failures self-heal, per-IP isolation; endpoint returns 429 after 10 wrong PINs with
Retry-After. 6 tests pass.
Tier 1 of "secure behind a reverse proxy". STRICTLY opt-in so direct/LAN installs
are byte-for-byte unchanged.
- core/security/reverse_proxy.py: apply_reverse_proxy_mode(app, config_get) — a
no-op unless security.trust_reverse_proxy=true. When OFF (default), the app is
untouched: no ProxyFix, X-Forwarded-* stays UNtrusted (a direct client can't
spoof its IP/scheme), session cookie keeps Flask defaults. When ON (operator is
behind nginx/Caddy/Traefik with TLS): trust one proxy hop's X-Forwarded-*, and
mark the session cookie Secure + SameSite=Lax. Any config error → safe no-op,
never breaks startup.
- Wired once at app init.
- Support/REVERSE-PROXY.md: nginx (with the Socket.IO Upgrade headers people
always miss) / Caddy / Traefik configs, the setting, and the "put auth in front
(Authelia/Authentik/oauth2-proxy)" recommendation + the off-for-plain-HTTP note.
Tests: off (and missing-key, and a config exception) is a strict no-op — not
ProxyFix-wrapped, cookie defaults intact; on wraps ProxyFix + secures the cookie;
and the real web_server app is NOT in proxy mode by default. 5 tests pass.
Per the original intent, "Sync" is now a single-artist deep scan: it uses the SAME
reconciliation source as the whole-library deep scan instead of a separate
disk-existence check.
- Phase 1 already calls the deep-scan worker's _process_artist_with_content; now it
passes seen_track_ids so the pull collects the server's current track IDs for the
artist (existing + new), exactly as the library deep scan does.
- Phase 2 stale = (artist's DB tracks for this server) − seen, then
delete_stale_tracks(server_source) — identical mechanism to deep scan, scoped to
one artist. The old os.path.exists disk check (which could mass-delete on an
unreachable mount) is gone.
- Removal only runs when the server pull SUCCEEDED — no trustworthy 'seen' set
(no server, unreachable, or a failed pull) → skip, never delete. The
is_implausible_stale_removal guard (>50% unseen) stays as the same safety net
deep scan has for a flaky response. @admin_only retained.
Tests rewritten for the server-diff model: removes only tracks the server no longer
has; guard skips when most are unseen; a failed pull skips removal entirely;
admin-only. 8 tests pass.
The enhanced-tab "Sync" button's stale-removal phase deleted any track whose file
wasn't on disk, with NO guard — so if the music storage was momentarily
unavailable (sleeping NAS, dropped mount, unmounted Docker volume, WSL hiccup),
os.path.exists returned False for EVERY file and one click wiped the whole artist
(tracks + their now-"empty" albums) from the DB. The deep-scan path already had a
50%-stale safety net (#828); this endpoint never got one.
- New core/library/stale_guard.py: is_implausible_stale_removal(missing, total) —
a tested rule (skip removal when missing > 50% of a >=5-track set), centralised
so every stale-removal site can share it.
- sync_artist_library: if the guard trips, SKIP removal (delete nothing), return
removal_skipped + warn; the frontend shows "storage may be offline — skipped"
instead of silently deleting. Empty-album cleanup now also only runs on the
non-skipped path and uses `album_id IS NOT NULL` (fixes the NOT IN-with-NULL
no-op). Frontend also refreshes the view on additions, not just removals.
- @admin_only on the endpoint — it deletes tracks + albums but was ungated, while
the sibling delete_album endpoint is gated.
Deep scan was already safe (different mechanism: server-diff + its own 50% guard).
Tests: guard unit rules; endpoint skips removal when all files missing (keeps the
tracks), removes only the genuinely-gone few otherwise, and 403s for non-admins.
7 new tests pass.
The #843 fallback saved the discovery-cache match using the client's
original_artist verbatim — but the client sends a joined "A, B, C" string, while
EVERY in-memory + sync path keys the cache by the first artist (artists[0]). So a
multi-artist fix (the reporter's exact "Cherrymoon Traxx, Hermol, SBM, BELS"
case) would have saved under a key the sync never looks up — the fix would
"succeed" with no error but silently never apply.
Reduce the client artist to the first (split on comma) in the no-state branch so
its cache key matches the in-memory/sync convention exactly. Single-artist tracks
are unaffected.
Test: no-state save now keys by the first artist, and a new test pins that the
no-state and in-memory paths produce an IDENTICAL cache key for the same
multi-artist track. 74 discovery tests pass.
The discovery FIX → Confirm flow 404'd with "Discovery state not found" whenever
the in-memory discovery state was gone — a server restart, or an imported
playlist that wasn't discovered in THIS process — even though the card is still
shown from persisted data (the reporter's log shows "Returning 0 stored ...
playlists for hydration", i.e. the in-memory states were empty).
The thing that actually makes a manual fix STICK is writing it to the discovery
cache (save_discovery_cache_match), keyed by the original track's name + artist —
which doesn't need the in-memory state at all. But the endpoint 404'd on the
missing state before reaching that write, so the fix was dead after a restart.
- update_discovery_match (core/discovery/endpoints.py) now only does the in-memory
result update when the state exists; the durable discovery-cache write always
runs, falling back to client-provided original_name/original_artist when there's
no in-memory state. With neither a state nor originals it still 404s (unchanged).
- The FIX confirm (wishlist-tools.js) now sends original_name/original_artist
(from the source track it already has) so the backend can key the cache.
Covers all sources that share the helper (tidal/deezer/qobuz/spotify-public).
Tests: no-state-but-originals saves the cache + returns success; no-state-no-
originals still 404s; existing with-state path unchanged. 73 discovery tests pass.
Regression in 2.6.9. The spotify_public source adapter (used by auto-sync /
refresh_mirrored) called scrape_spotify_embed() directly — the embed widget only
exposes ~100 tracks — instead of fetch_spotify_public(), the wrapper the rest of
the app uses, which pulls the full list via the paginated public API and only
falls back to the embed on failure. So initial discovery got the whole playlist
but every auto-sync re-fetch truncated it to 100.
Switched the adapter to fetch_spotify_public (same return shape — drop-in). Albums
still resolve via the embed (already whole); on any failure it falls back to the
embed exactly as before.
Test: the adapter returns all 150 tracks when the full fetch yields 150 (was
capped at 100). 29 adapter tests pass.
A wishlist track (or tracks in an album) that slskd accepted then REJECTED would
sit at "DOWNLOADING... 0%" indefinitely, spam an ERROR every status poll
("…Completed, Rejected - letting monitor handle retry"), and — for albums —
block the whole batch from ever completing.
Root defect: the status formatter's non-manual error branch keeps the task
'downloading' and trusts the retry monitor to resolve it, with NO backstop. When
the monitor can't make progress (a rejected transfer with no other source), the
task is stuck forever.
Backstop: measure how long the ERROR state has persisted (keyed off the task's
last status transition, so a slow-but-healthy transfer is never failed, and each
monitor-retry episode gets a fresh window). Once it exceeds the monitor's retry
window (60s, vs the monitor's ~15s) with no resolution, mark the task failed and
fire the worker-freeing completion callback so the batch can finish. Also log the
error ONCE per episode instead of every 2s poll. The healthy path is untouched —
a working retry transitions the task before the grace, so this never fires.
Manual picks still fail immediately (unchanged).
Tests: rejected-within-grace stays downloading; rejected-beyond-grace fails +
schedules completion; manual pick fails immediately. 45 status tests pass.
Reported via Find & Add (Billie Eilish "bad guy"): the track was in the library
and on Plex, but never showed in the modal's 20 results. Root cause (proven
against the real 307k-track DB): the search did `ORDER BY tracks.title`, which is
case-SENSITIVE in SQLite (BINARY collation sorts 'B' before 'b'). Billie's title
is lowercase "bad guy"; everyone else's is "Bad Guy", so all the capitalised ones
sorted first, filled the LIMIT, and her exact match landed at ~#25 — cut off.
- search_tracks now ranks by relevance: exact title match first (case-insensitive
via unidecode_lower), then prefix, then alphabetical — so an exact match can't
be sorted below the limit by a capital letter. Helps every caller.
- Added a rank-only `rank_artist` hint (never filters): Find & Add already knows
the source track's artist, so it now passes it and the exact title+artist match
floats to #1. Filtering was deliberately avoided — if the track is tagged under
a slightly different artist on the server, a filter would re-hide it.
Verified on the real DB: title-only "bad guy" now surfaces Billie at #4 (was
>#20); with the artist hint she's #1. Seam tests: lowercase exact title isn't
buried; rank hint floats the match without filtering; exact title beats a
superstring title. 10 tests pass.
Automations + auto-sync respect 'append' mode and preserve a server playlist's
description + cover image, but manually matching a missing track ("Find & add")
recreated the whole playlist and wiped them.
Root cause: the add-track endpoint's Jellyfin branch called
`update_playlist(<entire track list>)`, which deletes + recreates the playlist on
Jellyfin/Emby. Switched it to the purpose-built `append_to_playlist([the one
found track])` — the same in-place, dedupe-safe op the 'append' sync mode already
uses — so the playlist (and its description/image) is preserved and only the
missing track is added. append_to_playlist reads `.id` off the track, so the
endpoint now sets it (it previously only set ratingKey).
Plex (in-place addItems) and Navidrome (in-place Subsonic updatePlaylist) were
already non-destructive; Emby routes through the jellyfin branch, so this covers
it too.
Tests: the add-track endpoint appends in place and never calls update_playlist;
a link-to-existing-track touches nothing. 18 tests pass (incl. the existing
append-mode suite).
YouTube/Tidal/Qobuz results encode the name as ``id||title``. When the title
itself contains a '/' (e.g. the Sawano AoT track "YouSeeBIGGIRL/T:T"), two places
wrongly basename-split it on the slash and kept only the last segment ("T:T"):
- core/downloads/file_finder.py — the completed-download finder truncated its
search target to "T:T", so the real on-disk file (slash sanitised by the
writer) never matched → "not found after processing" → the download got
QUARANTINED. Now an encoded ``id||title`` keeps the whole title as the target
and contributes no remote-directory components; real Soulseek PATHS still get
basename + dir extraction unchanged.
- webui/static/downloads.js — the manual-search FILE column showed only "T:T".
Added a ``||``-aware short-label helper (mirrors the correct handling already
used elsewhere in the file); real file paths still show their basename.
Tests: the finder locates "YouSeeBIGGIRL∕T: T.mp3" from the encoded title
"…||YouSeeBIGGIRL/T:T" (the screenshot case), doesn't match an unrelated file,
and a genuine Soulseek path still resolves to its last segment. 21 finder tests
+ 64 script-split integrity tests pass.
On fresh page load the Downloads pill now immediately reflects whether
Download Verification is enabled (calls _verifLoadConfig in
loadActiveDownloadsPage instead of only on first filter click).
Also changed /api/verification/config to check the `acoustid.enabled`
toggle rather than the raw api_key string — matches the UI setting
"Enable Download Verification".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ⚠ Unverified filter rows gain actions: inline play (range-streamed from the
history file path, server-side only), YouTube compare, Approve -> new
human_verified status (tag + history + tracks; AcoustID scanner skips these
entirely), Delete (file + entry)
- API: /api/verification/<id>/stream|approve|delete (path only from DB row)
- backfill: history rows with acoustid_result='fail' that exist at all were
imported despite the failure = force_imported (covers pre-fix fallback
imports like the user's 'My Ordinary Life')
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The pipeline has three success exits (simple download, playlist folder mode,
main) but only the main one persisted the verification status — force-imported
playlist tracks got no tag, no history status, and never appeared in the
Unverified filter. Extracted _persist_verification_status() and call it at
every exit. One-time idempotent backfill derives status for existing history
rows from their recorded acoustid_result (pass->verified, skip->unverified).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- '⚠ Unverified' filter pill on the Downloads page lists completed downloads
whose verification status is unverified/force_imported (review queue)
- the quarantine-retry engine's attempt counter (already tracked internally)
is now surfaced: task.retry_info ('2/5') shows next to Searching/Downloading
in the modal and as 🔁 on the Downloads page rows, with the trigger in the
tooltip
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The persistent Completed list is built from library_history (not live tasks),
so the badge never showed after a session ended. Column added (additive),
written at import, passed through _build_history_download_item, rendered by
_adlVerifBadge next to the status label.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Also: evaluate() treats an empty expected artist as title-only comparison
(old scanner behaviour — a missing DB artist is no evidence of a wrong file),
and the thresholds are now defined once in the core and re-exported.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The library AcoustID scan now calls audio_verification.evaluate() (alias-aware
artist match + cross-script SKIP) instead of its own non-ASCII-stripping
_normalize and threshold logic, so it no longer false-flags correct anime-OST /
kanji tracks. Duration-collision guard kept as a scanner pre-check on the top
recording. evaluate() is now purely a title/artist/version/cross-script decision.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
verify_audio_file now calls audio_verification.evaluate() and re-exports
normalize/similarity/_alias_aware_artist_sim from the core, so import and the
library scan can no longer drift apart. Alias-rescue diagnostic moved to the core.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Functionally unchanged — just brought it up to the polish of the rest of the app
(My Accounts / Manage Workers style). Same markup hooks + JS bindings, so no
behaviour change.
- Glassy gradient panel with blur backdrop, rise+fade entrance, soft shadow.
- Sticky header with a gradient people-icon badge + subtitle; close button
rotates on hover.
- Profile rows are cards now: hover lift, and the profile you're signed in as is
highlighted (accent ring + a "You" pill).
- Role/status shown as pills (Admin / No Downloads / N pages) instead of a
dot-joined string.
- Edit/Delete are clean SVG icon buttons (was ✏️/🗑️ emoji) with accent/red hover.
- Inputs get a focus glow; colour swatches are larger with a check on the
selected one.
64 script-split integrity tests pass; all JS-referenced classNames verified present.
assert against a monkeypatched get_spotify_client sentinel instead of
web_server.spotify_client (which isn't a stable singleton across the suite) — same
fix already used for the per-profile builder tests.
The playlist source registry built the Spotify/Tidal adapters with a client
GETTER (resolved fresh on every read), but web_server passed `lambda: <global
client>`. Swapped those to get_spotify_client_for_profile /
get_tidal_client_for_profile.
Combined with part 1 (the engine running each automation as its owner), an
auto-sync pipeline now reads its source playlist through the OWNER's account:
- interactive sync → the user's session profile,
- background automation → the automation owner (via core.profile_context),
- admin / profile 1 → the global client, so the admin's existing auto-sync
pipelines pull exactly as before.
The adapters re-resolve per read, so a singleton registry is fine. Deezer/Qobuz
getters left global (their playlist login is tangled with downloads — deferred).
Tests: the Spotify/Tidal source adapters resolve the global client under admin
and re-resolve through the profile context per call (unconnected → safe global
fallback). 27 endpoint/profile tests pass.
Background automations had no session, so get_current_profile_id() fell back to
admin (1) — wrong for a non-admin's scheduled job. Now the engine declares the
automation's owner around handler execution via a contextvar
(core/profile_context.py), and get_current_profile_id() consults it only when
there's NO web request. So:
- a real logged-in request always wins (foreground unchanged),
- admin + system automations are profile 1 → resolve to admin exactly as before
(the 8 admin-owned auto-sync pipelines behave identically),
- only non-admin-owned automations gain their correct identity, deep through the
whole call chain (incl. the per-profile client resolvers) — no threading
profile_id through dozens of signatures.
Reset in a finally so a pooled thread can't leak the override to the next job.
Tests: contextvar set/reset/nested; get_current_profile_id honours the override
only outside a request (a real session still wins); and end-to-end — the engine
runs a non-admin automation as profile 4, an admin one as 1, an explicit trigger
profile overrides the owner, and the context resets even when the handler raises.
27 + 4 tests pass.
Part 2 (next): point the sync handlers' source-playlist READ at
get_spotify_client_for_profile so a non-admin's auto-sync pulls THEIR playlist.
My credential/admin-gating endpoint tests override DATABASE_PATH at import to a
temp dir, but with a custom prefix — which tripped the DB-isolation guard
(test_database_path_env_is_isolated requires 'soulsync-testdb-' in the path) once
those modules loaded. Still a temp DB (no real-DB risk), just the wrong prefix.
Switched to the soulsync-testdb- prefix so isolation is preserved and the guard
passes.
The cursor:pointer + hover rules were collateral damage when the dead
credential-set CSS block was removed. Re-added them (admin = pointer on the
section + its rows; non-admin stays default).
Third service (the easy one — ListenBrainz already had a working per-profile
token path). Consolidated all per-profile streaming accounts into the My Accounts
modal:
- My Accounts gains a ListenBrainz row with a token-paste connect (a new 'token'
service type alongside the OAuth-popup ones), reusing the existing
/api/profiles/me/listenbrainz save + the generic disconnect.
- Connections API reports listenbrainz status (connected + username).
- Personal Settings (the gear modal) dropped its Spotify/Tidal/ListenBrainz
sections — those duplicated My Accounts — and now shows only the per-profile
server-library selection (non-admin) or a pointer note (admin). The old
renderPersonalSettings{Spotify,Tidal,LB} functions are left defined but unused.
So every per-profile account connection (Spotify, Tidal, ListenBrainz) now lives
in one place. Tests: LB connect status + disconnect via the generic endpoint.
23 endpoint tests pass; 64 integrity tests pass.
Second service. Each profile connects its own Tidal; its playlist reads use that
account, everything else stays global. The gotcha vs Spotify: TidalClient loads
AND saves tokens to one global slot (tidal_tokens), so a naive per-profile client
would clobber the admin's tokens on refresh.
- get_tidal_client_for_profile builds a dedicated TidalClient seeded with the
profile's tokens, refreshed via the shared/global app creds, and OVERRIDES its
_save_tokens to persist to the PROFILE row — never the global slot. Admin
(profile 1) + unconnected profiles use the global client unchanged. Cached per
profile + evicted on (dis)connect.
- DB: set_profile_tidal_tokens / get_profile_tidal (encrypted); the OAuth callback
now uses them + evicts the cached client.
- Wired the Tidal playlist reads (list + tracks) to the per-profile client; the
module import line left intact.
- My Accounts: Tidal row (Connect via /auth/tidal?profile_id=, status, Disconnect).
Connections API extended; disconnect made generic (/<service>/disconnect).
Admin sees "managed in Settings" for every service.
Tests: per-profile token refresh writes to the profile and leaves the global
tidal_tokens untouched (the safety guarantee); connect status + disconnect;
admin/unconnected → global client. 22 endpoint tests pass.
Review/regression fix: the shared-app gate I added wrongly required a token
cache even for profiles that set their OWN app creds (legacy), breaking the
per-profile caching tests. Now a per-profile client is built when the profile
has its own app creds OR has connected (its own token cache) — otherwise it
falls back to the global/admin client. Made the fallback-safety tests
order-independent (monkeypatch get_spotify_client to a sentinel) since the real
global client isn't a stable singleton across the suite.
10 metadata-cache + resolution tests pass together.
First service of the per-profile playlist-auth feature. Each profile connects
its OWN Spotify account through the shared (admin's) app, getting its own token;
used for that profile's playlist reads. Admin + unconnected profiles + all
background workers keep using the global/admin client — fully non-regressive.
- Shared-app OAuth: get_spotify_client_for_profile + the /auth/spotify init &
callback now use the GLOBAL app creds (falling back from any legacy per-profile
app creds) with the profile's own token cache, and show_dialog=true forces the
account chooser so a user can't silently inherit the admin's Spotify session.
The builder gates on the profile's own token cache existing — no cache → global.
- My Accounts modal (new, all-profile-accessible via the profile bar): one-click
Connect/Disconnect Spotify + connection status (account name). GET
/api/profiles/me/connections + POST .../spotify/disconnect; admin's Spotify is
read-only here (managed in Settings).
- Wired the request-scoped reads to the per-profile client: the playlist LIST,
the playlist TRACKS view, liked-songs count, and user info — so a connected
user sees and opens THEIR OWN (incl. private) playlists, not the admin's.
Tests: builder falls back to the global client for admin/None/unconnected (the
non-regression guarantee); connections status reports unconnected; admin
disconnect rejected. 124 profile/spotify/gate/integrity tests pass.
Still on the global account (next step): sync/download jobs run in background
workers with no profile context — stamping the requesting profile onto the job
is the remaining wiring. Other services (Tidal/Deezer/Qobuz/Last.fm/ListenBrainz)
follow this same pattern.
The model shifted from "admin creates shared credential sets, users pick" to
"each profile self-auths its own playlist accounts". Removed the admin-facing
Connected Accounts manager: the Settings section, credential-sets.js, its CSS,
the script tag + integrity-registry entry, and the loadSettingsData hook.
The credential-sets backend (service_credentials tables + /api/credentials and
/api/profiles/me/services endpoints) is left in place but dormant — additive,
tested, harmless — rather than churn migrations that already ran on installs.
Per-profile self-auth reuses the existing per-profile columns + the
get_*_for_profile client pattern instead. The Service Status modal (admin-only)
is unaffected.
Active metadata source / media server / download source are app-wide
infrastructure, so the quick-switch modal is admin-only again:
openServiceSwitchModal() no-ops (with a toast) for non-admins, and the sidebar
Service Status loses its clickable affordance for them. Per-profile playlist
account selection will live on its own user-accessible surface, not here.
Regression from the #832 server-side launch gate (Beckid). On a PIN-required,
unverified session the gate 401'd /api/setup/status — which the frontend checks
BEFORE the PIN screen. The 401 left setup_complete undefined, so `!undefined`
relaunched the full setup wizard on every visit (cancel → PIN screen worked,
which was the tell).
Two-layer fix:
- Allowlist /api/setup/status in the launch gate (it's a harmless boolean, no
secrets) so the frontend gets the real answer even while locked.
- Make the frontend fail-safe: only launch the wizard on a definitive
setup_complete === false from an OK response — never on a 401/locked/ambiguous
one.
Test: locked session still 401s data endpoints but /api/setup/status returns
{setup_complete:true}; added a gate-allowlist assertion. 21 gate tests pass.
The Plex logo is a white wordmark, so it vanished on the modal's white logo
disc (it only shows on Settings because those toggles sit on dark). Added a
per-logo `dark` flag (Plex + SoulSync) that renders their disc dark (#1f2329)
across the hero, rail, and option cards, so the white logo is visible. Other
logos keep the white disc.
The modal's server logos were the odd ones out — Jellyfin used the wide
jellyfin.org wordmark and Navidrome a stretched navidrome.org image. Switched
the modal's _SS_SERVER_INFO (drives both the rail and the option grid) to clean
square icons: Plex's plex-logo.svg, Navidrome's tweakers.net icon, and the
homarr-labs Jellyfin PNG. Also swapped the Settings page Jellyfin toggle from
the jellyfin.org wordmark to the same homarr-labs icon so they match.
Follow-up to the modal fix: the sidebar Service Status + dashboard service card
also mislabeled "Spotify (no auth)" as plain "Spotify". They read the status
`source`, which came straight from metadata.fallback_source ('spotify') with no
awareness of the metadata.spotify_free flag.
- get_primary_source_status now reports a DISPLAY source of 'spotify_free' when
fallback_source='spotify' + metadata.spotify_free is set (the raw 'spotify' is
still used for the auth/connected checks), and treats the free path's
availability as "connected" so the dot isn't falsely red on a no-auth setup.
- getMetadataSourceLabel maps 'spotify_free' → "Spotify (no auth)"; the status
presentation treats spotify_free as part of the Spotify family (session /
rate-limit / cooldown display still work). Added a SOURCE_LABELS entry.
- testDashboardConnection normalizes spotify_free → spotify (the only logic
consumer of the source value — the dashboard Test button).
Routing is unchanged (the real source stays 'spotify' + free flag); this is
purely the display layer. Settings was always correct. 64 integrity tests pass;
the 2 failing soundcloud tests are pre-existing (confirmed identical on a clean
tree).
Correctness (the modal was lying): "Spotify (no auth)" is a COMPOSITE the
Settings page stores as fallback_source='spotify' + metadata.spotify_free=true,
not a literal 'spotify_free' value. The modal read the raw fallback_source and
showed plain "Spotify" as active even when Settings clearly said "(no auth)".
The endpoint now mirrors that mapping both ways — reports active='spotify_free'
when the flag is set, and switching to it writes fallback_source=spotify +
spotify_free=true (and clears the flag for any other source). Modal + Settings
now always agree.
Visual: the modal itself (not just the cards) is richer now —
- a hero header per tab: big brand-logo disc + "Active <kind> source" eyebrow +
the active name + a one-liner + an Active pill, all tinted by the brand color
with a soft radial glow (the Manage-Workers hero feel);
- the panel gained brand-tinted radial depth instead of flat black.
Test: spotify_free composite round-trips like Settings (stored split + reported
as spotify_free; flag clears on switch). 15 endpoint + 64 integrity tests pass.
Visual rework toward the Manage Workers feel:
- Cards are now circular brand-logo discs on white, with each service's brand
color (Spotify green, Deezer purple, Plex gold, …) driving the logo ring +
active glow/gradient + hover lift. Replaces the flat emoji tiles.
- The left rail is alive: each tab shows its category + the CURRENT active
choice's logo and label (e.g. "Metadata · Deezer"), with the active tab in a
brand-tinted gradient + accent bar — mirroring the worker rows.
Correctness fix (answers "modal says spotify, settings says spotify (no auth)"):
the modal read the RAW configured source, but the rest of the app shows the
EFFECTIVE one. get_primary_source() silently downgrades a configured 'spotify'
to the default (deezer) when Spotify isn't authenticated — so configured and
effective diverge. The endpoint now returns `effective` alongside `active`, and
the Metadata panel shows a note ("Configured source isn't connected — actually
using Deezer right now") whenever they differ. Settings was never broken; the
modal just wasn't showing the resolved source.
78 tests pass (integrity + endpoints); smoke confirms configured spotify →
effective deezer surfaces, spotify_free stays itself.
Replaces the basic credential-pill quick-switch with a Manage-Workers-styled
modal (topbar + left rail + panel, entrance animation, brand-logo cards).
- Sidebar Service Status: whole panel opens the modal; clicking the Metadata /
Media Server / Download rows deep-links straight to that tab. Removed the
"switch ▸" hover text.
- Three tabs: Metadata (source logo cards, unavailable ones dimmed), Server
(Plex/Jellyfin/Navidrome/SoulSync logos), Download (Single⇄Hybrid segmented
toggle; Hybrid shows a draggable priority list). Logos reuse SOURCE_LABELS +
HYBRID_SOURCES; active card gets an accent ring + check.
- Admin writes the GLOBAL active source/server/download (reuses the same setters
+ client reloads as the Settings save, so changes take effect immediately).
Non-admins see it read-only (editable=false) — the per-profile override is the
next layer.
Backend: GET /api/profiles/me/active-sources (any profile; reports editable),
POST /api/profiles/active-sources (@admin_only; validates against the allowed
metadata/server/download lists, applies + reloads). New service-switch.js
(registered + in the integrity registry); old modal removed from
credential-sets.js (admin Connected Accounts manager stays).
Tests: 14 endpoint tests — read shape, admin sets metadata/hybrid+order
(reflected), bad-value 400s, non-admin read-only + 403 on write. 64 integrity
tests pass; real-app smoke confirms render + deep-links + the full set/reflect
cycle.
Frontend for the credential-set feature, matching the blocklist/house modal
style. Functional end to end against the existing endpoints; visuals are a
clean first pass to refine.
Admin manager (Settings → Connected Accounts, admin-only — empty for non-admins):
per service, the saved accounts render as pills with a delete ✕, and "+ Add
account" reveals an inline form built from each service's required fields.
Create POSTs /api/credentials; secrets are entered but never read back (the API
only returns id/label). Loads via loadCredentialSets() at the end of
loadSettingsData().
Quick-switch modal (sidebar Service Status is now clickable for ALL profiles):
shows, per service the admin set up, a "Default" pill + one pill per account,
highlighting the profile's current choice; clicking persists via
/api/profiles/me/services/select and re-renders. Empty-state message when the
admin hasn't configured any alternates.
webui/static/credential-sets.js (new, registered in index.html), house-style
CSS appended, sidebar made clickable, settings hook added. Registered the new
module in the script-split integrity test (onclick coverage). 64 integrity
tests pass; real-app smoke confirms index renders, the asset serves, and
admin-create → per-profile-list round-trips.
Note: selections are stored but not yet consumed by the live clients (the
resolver remains dormant) — wiring playlist-pull/enrichment to use a profile's
selected account is the next step.
Discogs masters (/masters/{id}) and releases (/releases/{id}) share one
numeric ID namespace, so release N and master N are different albums.
search_albums() (type=release) yields RELEASE ids, but get_album,
get_album_tracks and _fetch_and_cache_album all tried /masters first and
only fell back to /releases. Whenever a release id collided with a real
master id, the master lookup succeeded and returned a valid-but-wrong
album — the fallback never fired.
Tag the object type into the id string ('r12345' / 'm12345') at the single
parse point (Album.from_discogs_release) and route each fetch to the
matching endpoint. Legacy untagged ids are tried release-first (then master),
which self-heals pre-existing bad matches without a migration. The
enrichment worker now persists the tagged id too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial line-by-line review of the feature diff turned up:
- /api/database/update/stop was NOT @admin_only while its sibling start_update
was — a non-admin could abort a library scan. Gated.
- /api/metadata-cache/evict was NOT gated while its clear siblings were. Gated.
- validate_credential_payload now treats whitespace-only values as missing, so
a blank-but-spacey secret can't be saved to fail confusingly later.
Tests updated: both endpoints added to the admin-gating matrix; a whitespace-only
validation case added. 42 credential/gating tests pass.
Review also confirmed (no change needed): migration is idempotent + additive +
O(1); encryption round-trips with a non-dict guard; no SQL injection; stale
selections fall back to None safely; no secret ever returned to the browser;
the hybrid-drag index math is correct in both directions; the new resolver is
fully DORMANT (zero runtime callers) so existing client behaviour is untouched;
and @admin_only is a no-op for single-profile installs (default profile = admin).
The hybrid download-source list set item.draggable=true and the help text said
"drag to reorder", but no drag handlers were wired — only the arrow buttons
worked (and _syncHybridOrderFromDOM was dead code). Wired real
dragstart/dragover/drop on each item, reordering _hybridVisualOrder (the same
model moveHybridSource uses) then rebuilding + autosaving. Added grab/grabbing
cursors + a dragging state. The arrow buttons still work unchanged.
Lets any profile pick which admin-created credential set is active for it,
without creating/seeing secrets:
- GET /api/profiles/me/services per-service options (id+label only) +
this profile's selected_id (stale-safe)
- POST /api/profiles/me/services/select {service, credential_id|null}
Not admin-gated by design — it only writes a per-profile pointer and exposes no
secrets. Validates the chosen set exists AND belongs to that service (can't
select a tidal set under spotify), and rejects unsupported services. null clears
back to the global/admin default.
Tests: a non-admin reads options + selects + clears (no secret in the response),
and selection rejects wrong-service / nonexistent / unsupported. 10 endpoint
tests total.
The audit found these were UI-hidden but API-open — any profile (or the
anonymous default-admin) could call them directly. Added @admin_only to the 15
that mutate SHARED/global state:
- DB: update, backup, backups DELETE, restore, vacuum
- library: track DELETE, album DELETE, tracks delete-batch, clear-match
- plex clear-library; metadata-cache clear + clear-musicbrainz
- internal API keys: list, generate, revoke
Deliberately NOT gated: profile-scoped own-data ops like /api/wishlist/clear
(clears the caller's OWN wishlist via profile_id) — gating that would wrongly
block a non-admin from managing their own data. Verified by test.
Zero change for single-profile installs (the default profile IS admin), so
existing users are unaffected; only genuine non-admin profiles get 403.
Tests: non-admin → 403 on all 15 (the 403 fires before the view body, so no
destructive op runs); admin not blocked on the read-only one; wishlist/clear
stays open to non-admins (over-gating guard). 17 tests.
Admin-only CRUD over the named credential sets from Phase 0:
- GET /api/credentials list all sets grouped by service (NO secrets)
- POST /api/credentials create {service,label,payload}, validated
- PUT /api/credentials/<id> update label and/or payload (partial)
- DELETE /api/credentials/<id> delete (clears any profile selections → fallback)
All four are @admin_only (non-admin → 403), payloads validated via
core.credentials.store, secrets never returned to the browser. Additive — no
existing endpoint or behaviour changes.
Tests: real web_server app + Flask test client (8) — create/list/update/delete
roundtrip, payload never leaks in list, missing-field/unsupported-service/blank-
label/duplicate(409)/404 validation, and the non-admin 403 gate on every write.
Verified the web_server import coexists with the rest of the suite (175 mixed
tests pass).
Groundwork for admin-created, per-profile-switchable credential sets ("pills")
across auth services (Spotify/Tidal/Deezer/Qobuz/Plex/Jellyfin/Navidrome).
Strictly additive and dormant — nothing reads it at runtime yet, so zero
behaviour change for existing installs.
- core/credentials/store.py: pure service registry + payload validation +
stale-safe active-set selection (pick_active_credential falls back to None
when a selected set was deleted, so a profile never breaks).
- migration service_credentials_v1: two new tables — service_credentials
(admin-created named sets; payload Fernet-encrypted at rest) and
profile_service_credentials (each profile's selected set per service).
- MusicDatabase CRUD: create/update/delete/list/get_service_credential
(list never returns the payload; get decrypts for the resolver), plus
set/get_profile_service_credential and resolve_profile_service_credential
(returns the profile's active payload or None → caller uses global default).
Tests: 12 — pure validation + stale-safe selection, and real-temp-DB storage
proving encryption round-trips, payload never lists, dup(service,label)
rejected, per-profile/per-service resolution, and delete clearing dangling
selections to a clean fallback. 95 migration/DB tests still pass.
The #831 watchlist work added the standalone webui/static/watchlist-history.js
(loaded in index.html) but didn't add it to NON_SPLIT_JS, so the onclick-
coverage test couldn't see its functions and failed on openWatchlistHistoryModal
referenced in the History button. Register it alongside its sibling
origin-history.js — same fix the registry comment was added for.
Bumps _SOULSYNC_BASE_VERSION 2.6.8 → 2.6.9, the docker-publish workflow's
default version tag, and adds the 2.6.9 What's New entry (15 items, security
fixes first: #832 launch-PIN enforcement and the settings-secret leak, then
#833/#831/#830/#829/#828/#827/#825/#824/#823/#740, Spotify (no auth), multi-
artist tags, decimal-volume dedup).
Found during the #832 audit: GET /api/settings returned dict(config_data) — and
config_data is DECRYPTED in memory — so every API key, OAuth secret, Plex/
Jellyfin token, and service password went to the browser in cleartext. Fernet
"encrypted at rest" protects a leaked DB file; it does nothing once the API
hands the plaintext to the client (devtools, HAR captures, an XSS, a screen
share, or a non-PIN'd LAN viewer).
Fix (centralized in ConfigManager):
- redacted_config() deep-copies config and replaces every _SENSITIVE_PATHS value
that's actually set with REDACTED_SENTINEL; unset secrets stay empty so the UI
still shows "not configured". Dict-valued secrets (tidal/qobuz OAuth sessions)
collapse to the sentinel too. GET /api/settings now serves this copy.
- set() ignores a write of REDACTED_SENTINEL to a sensitive path, so the masked
placeholder round-tripped by an unchanged settings form can never overwrite
the real secret. A real value still saves; an empty value still clears.
Frontend: secret inputs are type=password, so the sentinel renders as dots
(looks like a saved secret). _wireRedactedSecrets() clears the mask on focus so
editing types fresh rather than onto the sentinel, and re-masks on blur if left
untouched — so an unchanged secret round-trips the sentinel (kept), an edited
one saves the new value, and a deliberately emptied one clears.
Tests: every sensitive path masks; unset stays empty; dict secrets mask; live
config not mutated; sentinel round-trip keeps the real secret; real value
overwrites; empty clears; sentinel on a non-secret path writes normally.
9 new tests; 518 config-touching tests pass (1 pre-existing soundcloud mock
failure, unrelated — fails identically on a clean tree).
the-hang-man: tracks with an apostrophe (e.g. "I'm Upset") deleted the DB row
but left the file. The library DB stored the title with U+2019 (the curly form
Spotify/Apple metadata uses) while the file was written to disk with U+0027
(ASCII). _resolve_library_file_path compared the curly path byte-for-byte via
os.path.exists, missed every time, and reported "could not be deleted".
Fix: resolve confusable-tolerantly. New core/library/path_resolve.find_on_disk
descends the path component by component, taking an exact match when present and
otherwise folding a small set of typographic look-alikes (curly vs straight
quotes, en/em dash, ellipsis, nbsp) for the comparison ONLY — it never renames,
just finds the file that's actually there. Exact matches always win per
component, so paths that already resolved are byte-for-byte unaffected. This
also fixes existing mismatched files (no re-import) and every caller of
_resolve_library_file_path (sidecar cleanup, dead-file checks, streaming), not
just delete.
Case is deliberately NOT folded: a case-sensitive dataset (ext4/ZFS) can hold
names differing only by case, and folding could resolve the wrong file. The
reported failure is purely typographic.
Tests: real temp-file fixtures exercising the actual byte mismatch — curly-DB →
ascii-disk resolves, exact still works, confusable in a folder component, exact
wins when both encodings present, genuinely-different name does NOT collide,
missing file → None. 10 new tests; 949 resolver-adjacent tests pass.
Beckid: the admin launch PIN was a CLIENT-SIDE overlay only. `launch_pin_required`
just told the frontend to draw a fixed div over the app — removing it (Safari
"Hide Distracting Items", devtools, or any non-browser client like curl) gave
full unauthenticated access to every /api/* endpoint, because the server never
checked it. Anyone who reverse-proxies SoulSync publicly was wide open.
Fix: a before_request gate (_enforce_launch_pin) that rejects every request from
an unverified session while security.require_pin_on_launch is on. The decision
is a pure, unit-tested helper (core/security/launch_lock.request_is_locked) so
the allow/deny matrix can't silently regress. Allowed while locked: the page
shell + static assets, the unlock flow (current/list/select/verify/reset/logout),
and the public REST API /api/v1/ (its own @require_api_key governs it) — EXCEPT
/api/v1/api-keys-internal*, the "no auth required" key-management endpoints,
which stay locked so an attacker can't mint an API key and walk in the side door.
Everything else (data, settings, profile create/edit/delete/set-pin, socket.io)
is blocked.
A blocked top-level browser navigation (deep link / refresh on a sub-page like
/dashboard) is redirected to the root lock screen instead of dumping raw JSON —
detected via Sec-Fetch-Mode: navigate / Accept: text/html (is_html_navigation).
Programmatic fetch/XHR still get the JSON 401 so the frontend can react.
Also fixed the verified flag: get_current_profile POPPED launch_pin_verified
(one page load), but an enforced gate needs it to persist — now READ, so
verification lasts the session (until logout/expiry). No-ops entirely when
require_pin_on_launch is off (default).
Tests: full allow/deny matrix + navigation detection. 20 gate tests + 232
profile/security tests pass.
Boulder: the cards are good but everything around them was basic — six
identical grey pill buttons, a plain header, and a dated Global Settings modal.
Action chips (artist-detail button language — tinted gradient + hover lift +
icon scale): Scan is the primary CTA with the accent gradient and a shimmer
sweep; the rest get per-hue identity (similar-artists blue, settings slate,
origins green, history amber, blocklist/cancel red). One .wl-chip base class
with a --chip-rgb variable per hue. Header count/timer become pill meta chips
(timer accent-tinted).
Chip-safe labels: the scan/update handlers set button.textContent, which would
wipe the new svg + shimmer children on first use — added _wlSetChipLabel()
(preserves icon/shimmer, swaps the text node) and converted all 11 writes.
Global Settings modal: emoji + inline-styled header replaced with the
origins/blocklist house-style head (title/sub/✕); option cards now show live
checked-state feedback (:has(:checked) accent ring + grayscale-dimmed icons
when off — also upgrades the per-artist config modal, same components); the
master-override toggle gets a CSS .enabled treatment instead of the hard-coded
green inline border the JS used to write.
All element ids/onclicks unchanged; JS syntax-checked; 131 watchlist tests pass.
Boulder's screenshots: the v1 deck shifted around depending on what data had
arrived (the album row vanished entirely without art, leaving floating
"Processing…/Processing…" text), the images were small, and the feed header
floated in empty space. Redesigned in the artist-detail-page language:
- Big 148px square portrait (rounded, shadowed) anchors the left side, with
the current album stamped as a 62px overlay badge in its corner — when art
is missing, both keep their slot and show a glyph placeholder instead of
collapsing, so the deck NEVER changes shape mid-scan.
- 24px artist name + uppercase accent phase line + a fixed-height
"now checking" block (accent left rule) for album + track, with stable
placeholders ("Looking for new releases…" / "—") instead of doubled
"Processing…" text.
- The additions feed is an inset fixed-height panel (artist-page sidebar
style): same size whether 0 or 10 tracks, empty state centered.
- JS: hide the artist photo when the CURRENT artist has none (previously the
prior artist's photo lingered), cleaner placeholder copy.
Boulder: the live display was a cramped ~600px box showing a fraction of the
data the scan already tracks, with no animation and no history.
Live scan deck (replaces the three-column box, full width):
- Header: pulsing live dot, "x / y artists" progress text, and two live
counter chips (found / added) that pop when they change.
- Animated progress bar (artist index / total) with a shimmer sweep.
- Stage: artist avatar with accent glow + name + readable phase line
("Checking album 2 of 5"), album art + album + current track.
- "Added to wishlist this run" feed: taller, bigger art, slide-in animation
that plays once per new track (feed re-renders only when it changes).
- All data was already in scan_state (current_artist_index, total_artists,
tracks_found/added_this_scan, current_phase) — just never displayed. The
legacy fullscreen-modal markup shares element ids and lacks the new ones,
so it keeps working untouched.
Scan History (persistent):
- New watchlist_scan_runs table — one row per run (status, timestamps,
artists/found/added counts) + the full track ledger JSON. Saved at scan
completion AND cancellation; idempotent on run_id; pruned to the last 100
runs. Wishlist rows erode as tracks download, so this is the durable record.
- GET /api/watchlist/scan/history (runs) + /history/<run_id>/tracks (ledger).
- New History button on the Watchlist page → modal in the origins/blocklist
house style: run cards (date, cancelled chip, artists/found/added stats)
expanding into the Added / Skipped track lists with art and badges.
Tests: save+fetch with ledger, idempotent re-save, prune keeps newest,
unknown-run empty, cancelled runs recorded. 398 watchlist/wishlist/history
tests pass; JS syntax-checked; all rendered strings escaped.
Tacobell444 (#707 follow-up): the scan summary said "New tracks: 19 • Added to
wishlist: 10" with no way to see which tracks those were — you had to scan your
wishlist and guess what was new.
Scan ledger: the scanner now records a per-run scan_track_events list (track,
artist, album, thumb, status added|skipped — skipped = found-new but declined
by add_to_wishlist: already queued or blocklisted; capped at 500). The status
endpoint already serializes scan_state, so the payload flows free. The
completed (and cancelled) scan summary on the Watchlist page gets a
"Show tracks" toggle expanding a styled list — Added section + Skipped section
with badges, reusing the live-feed row styling.
Download Origins grouping: the modal now groups entries by what triggered them
(watchlist artist / playlist name) with collapsible headers + counts instead of
a flat list with a per-row badge. Entries arrive newest-first so groups order
themselves by their newest download. Same row markup, checkboxes/delete intact.
Provenance: watchlist adds now stamp scan_run_id into wishlist source_info, so
per-run grouping is queryable later (future "what did run X add" views).
Tests: per-run ledger seam test (added + skipped statuses, album/artist fields,
FIFO unchanged). 316 watchlist/wishlist tests pass; JS syntax-checked.
Third round of the multi-artist report. The earlier fixes (Deezer contributors
upgrade, _artists_list, feat_in_title/artist_separator) were all in place and
correct — but gated on source == 'deezer', and on the real Search → Download
Now path NOTHING carried the source: core/search/sources.py serialized tracks
with no source field, search.js's enrichedTrack didn't add one, so
get_import_source() resolved '' and the whole Deezer-specific block silently
skipped. Files were tagged with only the primary artist until a Retag (which
rebuilds context with the source set — exactly why retagging always fixed it).
The earlier tests passed because they set context['source'] directly — the one
field the real flow never had (same mock-drift as the #823 append tests).
Reproduced with Netti93's exact track (deezer 3966840171) through the real
extract_source_metadata: before — source '', artists ['August Burns Red'];
after — source 'deezer', contributors fetched, artists ['August Burns Red',
'Polaris'], title 'Sonic Salvation (feat. Polaris)' per feat_in_title.
Fix, three layers:
- core/search/sources.py: serialized tracks/albums/artists carry "source"
(the canonical name the orchestrator already passes; '' when unnamed).
- core/imports/context.py get_import_source: also reads '_source' from the
nested dicts (track_info/original_search/album/artist) — additionally fixes
the discography/wishlist flows, which always passed '_source' that nothing
read.
- search.js: enrichedTrack + the album-download path carry source through to
the download task.
Tests: real-payload staging-shaped contexts (source in track_info, '_source'
shape, and the pre-fix sourceless shape staying safe — mocked Deezer client),
serializer source-field tests, resolver fallback tests; exact-shape serializer
tests updated for the new key. 1977 import/metadata/search tests pass (the
only 2 failures are the known soundcloud ones).
carlosjfcasero round 2: after 6fa956d6 append stopped recreating the playlist,
but every sync re-appended ALL matched tracks — every track N times. His log
shows it plainly: "added 22 new tracks to 'Disney' (skipped 0 already present)"
on a playlist that already had those 22.
Root cause: the dedupe read `{t.id for t in get_playlist_tracks(...)}` — but
JellyfinTrack only defines `ratingKey`, never `id`, so the existing-ids set was
ALWAYS empty and everything looked new. NavidromeTrack is also ratingKey-only,
so the Navidrome append had the identical bug. Plex (plexapi ratingKey) was
fine. The existing tests were green because they mocked existing tracks as
SimpleNamespace(id=...) — encoding the same wrong assumption as the code.
Fix:
- New pure planner plan_playlist_append(current, desired) in
core/sync/playlist_edit.py (next to the reconcile planner): order-preserving,
drops already-present ids, dedupes within desired, stringifies (Emby numeric
vs string safe).
- Jellyfin/Emby: existing ids fetched from the canonical
/Playlists/{id}/Items endpoint (same as reconcile — works for Jellyfin GUIDs
and Emby numeric ids), ratingKey fallback if that request fails.
- Navidrome: dedupe on ratingKey (the attribute that actually exists).
Tests: planner (skip-present incl. the reporter's unchanged-playlist case,
desired-order, dupes-within-desired, int/str ids, empties) + the append-mode
suite rewritten to pin the REAL shapes (raw Items dicts for Jellyfin,
ratingKey objects for Navidrome) + a new fallback-path test. 524
playlist/sync/jellyfin/navidrome tests pass.
carlosjfcasero round 2 (manual-add fix didn't help — different path). His log
pinned it: the mirrored sync auto-added 'Llamando a la tierra (Serenade From
the Stars)' by M-Clan every run even though his library has the song (stored
bare). Reproduced exactly: the subtitle restates no album context, so the #808
context strip keeps it, and the length-ratio penalty in
_calculate_track_confidence crushes the pair to 0.142 (needs 0.7). Sync →
"missing" → wishlist, forever; and the cleanup uses the SAME matcher, so it
deterministically never removed it. Self-reinforcing.
Fix at the matcher seam (benefits sync, cleanup, downloads, discography alike):
core/text/title_match.strip_subtitle_qualifiers(title, other) strips a
bracketed qualifier only when it (a) isn't restated in the other title, (b)
contains no version-marker token (EN + ES: live/remix/acoustic/version/
dueto/directo/vivo/...), and (c) introduces no new digit token ('(Pt. 2)',
'(2007)' stay different releases). Wired as a third comparison variant in
_calculate_track_confidence with its own length guard. Verified against his
log's other unmatched tracks: '(Live)' 0.15, '(Dueto 2007)' 0.179,
'(Versión 1988)' 0.167 all still correctly blocked — version qualifiers keep
their meaning; the M-Clan case goes 0.142 → 1.0 in both directions.
Also: sync's check_track_exists call now passes album= (cleanup already did),
enabling the album-aware fallback for multi-artist albums during sync.
Tests: tests/test_subtitle_qualifier_match.py — the reported case verbatim
(end-to-end through check_track_exists, both directions, batched candidate
path included), EN+ES version qualifiers still blocked, numeric guard,
'#769 Dani California' and '#808 OurVinyl' guards still hold. 1396
matcher/wishlist/sync tests pass.
The per-album status only looked at added + the generic wishlist-skip count, so
anything else (other-artist credit, already owned, content-filtered) showed the
misleading "No new tracks" — which is what made Vicky-2418's artist-mismatch
skips look like "you already have it." The backend already streams the full
breakdown (tracks_skipped_artist/owned/filter); the UI just ignored it.
New shared _discogItemStatus(data) builds an accurate line from all the counts:
"4 by other artists", "13 already owned", "1 added, 2 already owned", etc.
Replaces the duplicated 4-line block at both render sites. Frontend only; JS
syntax + per-case output verified.
Vicky-2418: Download Discography skipped some albums/singles as "No New Track"
even on a brand-new artist, while manual one-by-one worked. The log showed the
real reason wasn't ownership at all — it was "N skipped (artist mismatch)".
Root cause (confirmed against the iTunes API, not guessed): iTunes returns a
collab as ONE combined string. Narvent's "Miss You (Ambient Remix)" is credited
'TRVNSPORTER, Narvent & SKVLENT'. track_artist_matches did an exact full-string
compare ('trvnsporter, narvent & skvlent' == 'narvent' → False), so every
collaborator's discography entry was dropped — despite the #559 comment claiming
it "keeps features" (it didn't, because features arrive combined, not as a list).
Fix: match the requested artist as a COMPONENT of the credit — split on the
common separators (, & ; / feat ft featuring vs x), while still including the
full string so band names with internal separators ("Florence + the Machine")
match exactly. Component matching stays exact per name, so true contamination
(the #559 case — artist not credited at all) is still dropped, and substrings
("Drakeo the Ruler" ≠ "Drake") still don't match.
Also corrects an over-conservative #559 test that asserted "Drake & Future"
shouldn't match "Drake" — it should; Drake is a credited collaborator. The guard
drops uncredited artists, not legit collabs packed into one string.
Tests: the exact real case (Narvent in the combined credit) + each collaborator,
contamination still dropped, feat/ft/featuring/x forms, no substring false
positive. 36 filter tests + 747 discography/metadata tests pass.
Tacobell444: when tracks land in an album across multiple batches (a wishlist
run, the Album Completeness job, a missed track re-downloaded later), the folder
is rebuilt from API metadata each time — so when $albumtype or $year come back
blank/different on a later batch, the folder NAME changes and the album splits,
forcing a Reorganize.
Fix: build_final_path_for_track now checks whether the album already lives in a
single folder on disk and, if so, drops the new track there instead of a freshly
templated folder. Match (chosen): exact stored Spotify album id first, then a
STRICT >=0.85 name+artist match (vs the 0.7 used elsewhere) — a wrong match here
misplaces a file. New core/library/existing_album_folder.resolve_existing_album_folder
holds the logic; always-on with template fallback.
Safety rails: only returns a folder UNDER the transfer dir (never a read-only
library/NAS mount), only when the album lives in EXACTLY ONE folder (multiple =
disc subfolders, which DatabaseTrack can't disambiguate — those defer to the
template), and any failure falls through to the template path. Added
MusicDatabase.get_album_by_spotify_album_id for the id-first lookup.
Tests: single-folder reuse, no-match, below-threshold, multi-folder defer,
outside-transfer reject, id-first, missing transfer dir, no-files-on-disk.
8 tests; 1556 path/import/download tests pass (only the known soundcloud
failures remain).
macstainless: a Plex-on-macOS user running SoulSync in Docker had all 5,250
tracks flagged "dead" even though the files exist and play in Plex. Root cause:
the DB stores paths as Plex reported them (/Volumes/Core/Music/...), which don't
exist inside SoulSync's container. resolve_library_file_path() returns None for
"couldn't find it at any known base dir" — and for a mis-mounted library that's
EVERY track, not a deletion. The job treated None as "file deleted" and created
a finding per track.
Fix mirrors the existing transfer-folder abort: collect unresolvable tracks, and
if at least max_unresolved_fraction (default 0.5) of the library is unresolvable
once it's past min_tracks_for_guard (default 25), treat it as a path-mapping/
mount problem — abort with an actionable message (Docker mount / Settings →
Library → Music Paths) and create ZERO findings. A small fraction unresolvable
is still reported as genuine dead files, and tiny libraries (< min) report as
before. Both thresholds are configurable per the job's settings.
Tests: mass-unresolvable aborts with no findings; a lone dead file among real
ones is still reported; a small all-dead library still reports; thresholds
configurable. 54 repair tests pass.
Per Boulder's calls on the new enrichment toggle:
- Naming: "Spotify Free" was misleading (it's a hybrid — pick it, connect an
account, and sync still uses your official playlists). Relabel the user-facing
strings to "Spotify (no auth)" — the real distinction is needs-credentials vs
not. Internal value/key (spotify_free, _free_*) unchanged, so no migration.
- Default ON: metadata.spotify_free_enrichment now defaults True (worker + UI
load both treat unset as on). So bulk enrichment runs on the no-auth path by
default and the official account is reserved for interactive search/sync; turn
the toggle off to enrich through the connected account. The toggle overrides
auth for the worker (authed users still enrich via no-auth) — matching the
intended model.
- Worker runs on the toggle alone: is_spotify_metadata_available() now honors
_prefer_free (+ package installed), so the worker enriches via no-auth even
with no account connected and no 'no-auth' source selected. Only fires on a
client carrying the flag (the worker's own), so interactive/watchlist
availability is unchanged.
- UI: moved the toggle from "Metadata Source" to the Spotify section next to the
auth fields, always visible, on by default. Help notes the genre trade-off.
Tests: prefer_free makes metadata available without auth/source (and is inert
without the package); interactive availability unaffected. 218 Spotify tests pass.
User-facing opt-in for metadata.spotify_free_enrichment (the engine landed in
38461295). A checkbox in the Metadata Source frame, independent of the primary-
source dropdown, so a user with an official Spotify account connected can choose
to run the bulk enrichment worker on the no-creds Spotify Free source — sparing
their official API quota / dodging rate-limit bans for interactive search + sync.
Help text notes the trade-off (no artist genres from Free). Default off.
Wiring mirrors the existing spotify_free setting: saved in the metadata payload,
loaded into the checkbox, persisted via the generic metadata.* config loop (no
backend change). Auto-save already covers checkboxes in #settings-page.
Two related fixes at one root cause. Every catalog method gated the official API
on `use_spotify = is_spotify_authenticated()` and only fell to Free *after*
official failed. So when the client was authed but should defer to Free —
specifically when the worker's daily real-API budget was spent — it kept hitting
the official API anyway (just stopped *counting* it). The budget "bridge" never
actually diverted; it only stopped pausing.
Root-cause fix: the official gate is now `is_spotify_authenticated() and not
self._free_active()` across all 8 catalog methods (search_artists/albums/tracks,
get_artist/album/album_tracks/track_details/artist_albums). No-auth and
rate-limited are unchanged (auth is already False there); the change only affects
the cases where auth is True but we deliberately defer to Free. The user-account
methods and the metadata-availability helper are untouched.
New opt-in: metadata.spotify_free_enrichment. When set, the worker puts
`_prefer_free` on its OWN client and _free_active() honors it (needs only the
package installed — the flag is the opt-in — not the 'Spotify Free' source
choice). So a connected user can run bulk enrichment on the no-creds source to
spare their official quota, while interactive search/resolve stay official-first
(they use a different client that never sets the flag). Default off.
Tests: _free_active honors prefer_free (and is inert without the package);
search_albums defers to Free — official .sp raises if touched — both under
prefer_free AND under budget-exhaustion (the divert that previously never
happened). 215 Spotify tests pass.
SpotipyFree has no album-name search (search_albums returned []), and
SpotifyClient.search_albums had no Free branch at all — so when Spotify Free was
the active source (no-auth / rate-limit ban / spent budget), album matching
couldn't use Spotify and dropped straight to the iTunes/Deezer fallback. The
enrichment worker's per-album matching was effectively blind on Free.
Fix — work the gap using ONLY the free source:
- spotify_free_metadata: new search_albums_via_artist(artist, album) resolves the
best-matching artist, scans their discography (get_artist_albums_list — which
Free DOES support), and ranks by album-name similarity. Pure rank_albums_by_name
helper is unit-testable; the network method composes it.
- SpotifyClient.search_albums: add a Free branch mirroring search_artists/tracks
(official → Free-if-active → iTunes/Deezer), plus optional artist/album params
so the Free path has the names it needs. Bare-query callers skip it unchanged.
- spotify_worker: _process_album_individual passes artist+album so the bridge can
match albums on Free.
Tests: rank ordering + limit, via-artist picks the right artist and ranks,
empty on missing artist/album/no-match, and SpotifyClient.search_albums returns
Free albums (not the iTunes fallback) when free is active. 210 Spotify tests pass.
Tacobell444: the Logs tab has no savable settings, but its live-viewer controls
(source picker, filters, auto-scroll) were tripping the settings auto-save —
each one POSTs /api/settings and logs "Settings saved successfully via Web UI",
flooding app.log and drowning out the logs the user is trying to read.
Fix: debouncedAutoSaveSettings bails when the active settings tab is 'logs'
(checked via .stg-tab.active). Purely frontend — no save is scheduled while on
that tab, so the backend never logs the save. Doesn't touch the existing
_suppressSettingsAutoSave form-population guard, and every other tab auto-saves
exactly as before; manual Save still works everywhere.
The manual album "Add to Wishlist" modal had NO ownership check at any layer —
the album view opened the modal without ownership info, the modal added every
track, and the backend (add_album_track_to_wishlist) added each unconditionally.
So adding an album you (partially) own dumped the owned tracks straight into the
wishlist (carlosjfcasero #825) — and the auto-cleanup doesn't reliably remove
them. The bulk discography path already dedups (full missing-track analysis);
this path didn't.
Backend (the reliable seam): add_album_track_to_wishlist now skips a track that
already exists in the library, gated on the same wishlist.allow_duplicate_tracks
toggle the watchlist scan + cleanup use — OFF → skip owned (returns
{success, skipped:true}), ON → add anyway. Default is ON, so default users are
unaffected; the quality re-download flow uses a different endpoint, so it's
untouched.
Frontend: handleAddToWishlist + addModalTracksToWishlist count skipped tracks
separately so the toast is honest ("Added 3 (5 already owned)" / "All N already
in your library") instead of falsely claiming owned tracks were added.
Tests: skips owned when duplicates off, adds missing when off, adds owned when
on (and doesn't even run the check then). 205 wishlist tests pass.
Sokhi: "downloads searching for way too many tracks at once" — a wishlist run
that fanned out into ~one batch per album. Verified the actual search/download
concurrency IS capped at 3 (single shared missing_download_executor), so it
wasn't really hammering slskd — but the display showed ~20 "searching" and the
batch list was a mess.
Root cause: run_full_missing_tracks_process was supposed to "block its album-pool
worker for the whole search+download" (that's what the dedicated album_bundle_
executor is for), but it RETURNED the instant it had STARTED the downloads. So
the album pool only throttled the fast analysis phase — every album batch blew
through analysis and immediately dumped its tracks into the shared download pool,
all pre-marked 'searching'. The intended serialization never happened.
Fix: add serialize= to run_full_missing_tracks_process. Album-bundle batches
(dispatched on album_bundle_executor) pass serialize=True and now hold their pool
slot via _wait_for_batch_drain() until every task in the batch reaches a terminal
state — so only ~N albums are in flight at once. The wait is passive (downloads
are driven by the monitor + completion callbacks on other threads, so no
deadlock) and bails on shutdown, a removed batch, or a safety cap. The residual /
playlist / manual paths run on the SHARED pool and pass serialize=False (blocking
there would steal a real download worker), so they're unchanged.
Tests: _wait_for_batch_drain returns immediately when all-terminal, waits until
tasks finish, bails on shutdown, respects the cap, handles a missing batch. 975
download/wishlist tests pass (only the pre-existing soundcloud /app failures).
carlosjfcasero: "append" sync mode still recreated the playlist (wiping image +
description) on both the sync-page auto-sync and the Playlist Pipeline. Root
cause: _run_sync_task defaulted sync_mode='replace', and every AUTOMATED caller
omits the mode — auto_sync_playlist (mirrored auto-sync + pipeline), the
iTunes-link sync, and Wing It. So those paths always replaced, ignoring the
user's chosen mode entirely. (Manual sync + the per-source discovery path already
passed a mode, which is why it only bit automated runs.)
Fix: when no mode is passed, _run_sync_task resolves the user's configured global
"Playlist sync mode" (normalize_sync_mode(None, playlist_sync.mode)) — the same
thing _submit_sync_task already does — instead of hardcoding 'replace'. The
global default is still 'replace', so users who never changed it are unaffected;
only those who set Append/Reconcile get the corrected behavior.
Tests: normalize_sync_mode(None,'append')→'append' (and 'replace' unchanged);
auto_sync_playlist must not force a mode (no sync_mode kwarg / no 7th positional)
so the resolution can happen. 896 sync/automation/discovery/playlist tests pass.
Part 1 stopped existing full dates being destroyed; this adds first-class support
for full release dates so they can be set + persisted instead of truncated to a
year at the DB layer.
- Schema: new nullable `release_date TEXT` on the albums table (idempotent
ALTER-ADD-COLUMN repair on startup + the live CREATE). NULL = year-only, every
reader falls back to albums.year, so it ships safe/dormant.
- Tag writer: write_tags_to_file + build_tag_diff prefer db_data['release_date']
(the full date) over the year int; _date_to_write writes the full date. When
there's no release_date it's exactly Part-1 behavior (year, preserving an
equally-specific existing file date).
- Retag read path: SELECT al.release_date in the tag-preview/write queries and
thread it into _build_library_tag_db_data.
- Manual edit: release_date added to ALBUM_EDITABLE_FIELDS + a "Release Date"
field (YYYY-MM-DD, validated client-side) in the album editor; the artist-album
query returns it so existing values show. User-set dates are authoritative.
- Enrichment: Spotify + iTunes workers store the source's full release_date
(YYYY-MM / YYYY-MM-DD) when present, only when empty — never clobbering a
manual value.
Tests: writer uses release_date over year + overrides an existing file date;
falls back to year when absent; diff compares the full date. Migration verified
idempotent + enrichment no-clobber. 1435 tag/retag/db/library tests pass.
Sokhi's log showed the actual cause: with the wishlist "allow duplicates" toggle
on, is_track_missing_from_library skips a track when _albums_likely_match() says
the wanted album and a library album are the same — and it was matching albums
that differ ONLY by a decimal volume number:
[AllowDup] Album match — skipping (wanted: '...Vol.5', library: '...Vol.5.5')
[AllowDup] Album match — skipping (wanted: '...Vol.5.5', library: '...Vol.4.5')
His character-song CDs share track titles across volumes, so tracks from albums
he DOESN'T have got skipped because a same-titled track sits in a similarly-named
volume he DOES have — the partially-filled discography never completed.
Root cause: _normalize_album_for_match strips the dot ("Vol.5.5" -> "vol 5 5"),
and _VOLUME_MARKER_RE captured only a single trailing digit, so Vol.5, Vol.5.5
and Vol.4.5 all reduced to marker "5" and the volume-disagreement guard never
fired. Fix: capture the full multi-part number ((\d+(?:\s+\d+)*)) so "5" / "5 5"
/ "4 5" are distinct and the guard correctly rejects the match.
(Not lookback — the log confirms lookback=all was already honored.)
Tests: decimal/multi-part volumes (incl. the real CJK names) now block the
match; identical decimal volumes + naming-drift cases still match. 111 watchlist
tests pass.
THE bug behind "embeds art but never writes cover.jpgs": _fix_missing_cover_art
passed `details['album_folder']` (= dirname of the raw DB path, e.g. Jellyfin's
/data/music/...) as the target folder. That path doesn't exist inside the
SoulSync container — only the resolved /app/... path does. So apply_art_to_album_
files' `os.path.isdir(target_dir)` was False and the ENTIRE cover.jpg block was
silently skipped: embedding still worked (it uses the resolved paths) and the DB
thumb updated, but the sidecar was never written. Exactly Sokhi's symptom + the
"Cover art already present — database thumbnail updated" toast (cover_written
stayed False).
Fix:
- _fix_missing_cover_art: derive the folder from the RESOLVED file
(os.path.dirname(resolved[0])), never the raw album_folder.
- apply_art_to_album_files: bulletproof it — if the passed folder doesn't exist,
fall back to the real directory of the files instead of silently skipping.
Tests: a non-existent folder still writes cover.jpg to the real file dir. 1348
cover/art/repair tests pass.
Root cause of Sokhi's endless 0-findings: the SCAN resolved each track's path
through the path-mapping layer with NO fallback, while the APPLY
(_fix_missing_cover_art) uses `_resolve_file_path(...) or p` — i.e. it falls
back to the raw DB path when mapping returns nothing. On his Docker setup the
mapping returns None, so the scan set has_local=False and skipped every album
(never looking at the folder), even though the apply WOULD have written the
cover.jpg from the raw path.
Fix: make the scan match the apply — if mapping returns nothing but the raw DB
path is a real file (container path == stored path), use it as-is. Now the scan
actually inspects the folder, sees the missing cover.jpg, and flags it; the
apply then writes it from the embedded art.
Tests: unresolved-path-but-real-file → flagged (sidecar_from_embedded); the
fallback does NOT fire for a non-existent path (media-server-only stays skipped).
Kept the [cover-diag] logging from the prior commit to confirm on Sokhi's run.
Sokhi's scans keep returning 0 findings and we've been guessing at the cause
across several fixes. Add a skip-reason breakdown + per-album decision dump so
the next scan log shows it definitively instead:
- [cover-diag] per-album (first 5): album, raw DB path, resolved path,
has_local, embedded, sidecar, db_missing, cover_enabled, needs_fix.
- [cover-diag] skip breakdown in the summary: have_disk_art /
no_local_db_has_art (path didn't resolve, DB has thumb) / no_art_source.
Leading hypothesis this will confirm: the file paths aren't resolving
(resolved=None → has_local=False), so the scan only ever looks at db_missing —
which flipped every album from flagged (before the DB-art fix populated thumbs)
to skipped (after). If so the real fix is path resolution, not the art logic.
No behavior change — logging only.
Sokhi: after the earlier flag fix, scans returned 0 matches — albums with
embedded art but no cover.jpg were treated as fully arted and skipped, so their
cover.jpg never got written.
Root cause: the scan used album_has_art_on_disk (True if EITHER embedded art OR
a cover.jpg exists), conflating the two. Now it checks them separately:
- a local album is flagged if it lacks embedded art, OR it lacks a cover.jpg
sidecar AND cover.jpg writing is enabled (metadata_enhancement.cover_art_
download — Boulder: "only scan for cover.jpgs when enabled").
- an album that has embedded art but no sidecar is fixable even when the API
finds no art: the apply writes cover.jpg from the EXISTING embedded art.
apply_art_to_album_files now writes the cover.jpg sidecar by extracting the
album's own embedded art (new extract_embedded_art) — consistent with the
files, no API call — and only falls back to download_cover_art when there's
nothing embedded to extract. _fix_missing_cover_art no longer bails on a
missing artwork_url when sidecar_from_embedded is set.
Tests: scan flags embedded-but-no-cover.jpg (incl. when API finds nothing),
still skips albums with both, still flags artless albums; apply writes cover.jpg
from embedded art (no download), falls back to download when none, skips when a
sidecar already exists; extract_embedded_art unit tests. 1344 cover/art/repair
tests pass.
A parsed link is unambiguously a Tidal/Qobuz /track/ URL (no false positives),
so if its source isn't connected or the track can't be resolved, return a clear
400 ("Tidal isn't connected — … or search by name") instead of silently
running a useless search of the raw URL text. The frontend already surfaces the
400's error message in the modal.
When a track shows "Not found", the manual search now accepts a pasted Tidal or
Qobuz track link, not just a typed query (CubeComming: the fuzzy search misses
versions; he can find the track on Tidal but can't get it to appear).
How it works (robust, reuses the proven path): parse the link → (source,
track_id) → fetch the track via the source client's get_track → build a clean
"artist title (version)" query → run THAT source's normal search → bubble the
result whose id matches the link to the top. So the candidate is a normal,
already-downloadable streaming result — no hand-built download encoding — and
it downloads through the existing verified flow.
Degrades gracefully: if the source isn't connected or the link can't be
resolved, it falls back to a normal text search of the raw input — the user is
never worse off than typing it themselves. Scoped to Tidal + Qobuz (the
streaming sources that download by track id, with public track URLs); Soulseek
can't take a link (P2P, no ids), YouTube/SoundCloud are URL-native via a
different path (future).
- core/downloads/track_link.py: pure parse_download_track_link (tidal/qobuz
/track/<id>, slug/region suffixes, scheme-less) + query_from_track_payload
(per-source title/artist, Tidal version-append).
- manual-search endpoint: link detection → resolve → restrict to that source →
id-match bubble.
- placeholder hint mentions pasting a link; maxlength 200→300 for long URLs.
Tests: 14 (parser shapes + payload extraction incl. remix version-append +
qobuz performer/album-artist fallback). JS valid.
#775 already resolves pasted Spotify / Apple / MusicBrainz / Deezer links to an
exact artist/album/track on the Search page. Added Discogs to that set (the one
source in the request not already covered; Amazon left out per request).
- by_id resolver: discogs in SUPPORTED_SOURCES + _KNOWN_HOSTS; parse
/artist/<id>-slug, /release/<id>-slug, /master/<id>-slug (master→album), and
strip the slug to the leading numeric id. Discogs has no standalone track
URLs (tracks live inside a release), so artist + album resolve.
- Fetch dispatch: discogs albums use get_album(include_tracks=False) like
itunes/musicbrainz; artists use get_artist; both already return the common
normalized card shape. Updated the not-a-link hint to mention Discogs.
Frontend needs no change — it adopts whatever source the resolver returns and
Discogs is already a search source. Tests: parse release/master/artist
(slug-stripped, scheme-less) + resolve release→get_album(numeric id) and
artist→get_artist. 43 by_id tests pass.
CubeComming: manual imports of large hi-res Qobuz FLACs came out as empty
shells — no audio, no tags, no length/bitrate. Root cause: mutagen's in-place
save() rewrites the file, and it's NOT atomic; if the process is interrupted or
OOM-killed mid-write (he also reported the memory-growth issue #802), the file
is left truncated — audio and tags gone.
save_audio_file (the chokepoint both the enrichment tag-write AND wipe_source_
tags route through) now saves atomically: copy the original to a temp in the
same dir, write the modified tags into the copy, verify it's still valid audio
(duration > 0), then os.replace() it in. The original is untouched until that
atomic swap, so a crash can only orphan the temp — never destroy the user's
file. Falls back to the plain in-place save (byte-identical to before) when the
atomic path can't run, so nothing is ever left worse off. tag_writer's
write_tags_to_file routes through the same helper.
Verified the atomic path works with REAL mutagen on a real FLAC (audio length
preserved 1.0s→1.0s, tag written, temp cleaned). Tests: replace-on-success,
original-survives-save-failure, corrupt-temp-rejected, no-filename-plain-save,
+ a real-FLAC round-trip (skips without ffmpeg). 2443 import/metadata/tag tests
pass.
#816 hover-flicker — .dash-card:hover and .qa-tile:hover both did
transform: translateY(-Npx). Hovering a card's bottom edge lifted it off the
cursor → un-hover → drop → re-hover, an infinite rapid loop. Since every
dashboard card is a .dash-card, all of them flickered ("all elements
affected"). Removed the translateY lift; the hover's stronger shadow + border
glow already reads as "raised" without moving the hit box. (qa-tile has
overflow:hidden so a pseudo-element gap-buffer can't help — removal is the
clean, consistent fix.)
#816 Automations "looks strange" — the .qa-tile__flow decoration sits in the
bottom row directly behind the green "Open →" CTA; at 0.45 opacity the accent
nodes/line competed with the CTA (green-on-green clutter). Toned to 0.22 so it
reads as faint background texture; still brightens on hover.
#817 badge overlap — .helper-first-launch-tip was right:84px, only ~4px clear
of the ? float button's 8px pulse ring (button left edge ~72px from right), so
the "New here?" tip touched the button. Moved to right:96px.
CSS values/comments only — no structural changes (brace delta unchanged).
Sokhi: filler works but doesn't write cover.jpg. Cause: the sidecar write
(download_cover_art) respects the import-time "Download cover.jpg to album
folder" toggle, while embedding ignores it — so with that toggle off, art
embeds but no sidecar is written.
A job literally called Cover Art Filler should produce the complete art when
you explicitly run it. download_cover_art gains force=True (bypasses the toggle)
and the filler's apply path passes it. The import pipeline still calls without
force, so it keeps honoring the user's setting.
Tests: filler passes force=True; existing cover-write mocks updated for the new
kwarg. 1732 art/cover/repair/import tests pass.
Three related fixes to Tidal track matching and downloading:
1. version-field handling — Tidal stores remix/live/edit qualifiers in a
dedicated `track.version` attribute (e.g. name="Emerge",
version="Junkie XL Remix"), not in the track name. The qualifier
filter and the matcher only looked at name/album, so the exact
recording was discarded. Fold `version` into both the qualifier
haystack and the candidate title passed to MusicMatchingEngine.
2. divergent-version penalty — once versions are visible, OTHER cuts of
the same base become candidates ("(Shazam Remix)" vs "(southstar
Remix)"). Neither title is a prefix of the other, so the prefix-based
version check missed them and the raw ratio stayed high off the
shared base. Apply a heavy penalty when both titles carry different
version descriptors so the wrong cut can't outscore the threshold.
3. rate-limit backoff — the trackManifests endpoint is aggressively
rate-limited; a bare request failed 429 instantly, burned the quality
tier, re-queued the track and hammered again (a self-amplifying
storm). Honour Retry-After / exponential backoff with a bounded retry
count and shutdown-aware sleep.
Adds unit + end-to-end tests for all three.
Boulder (Plex): "flags every album, but everything has art." His albums show
art in the library (served from the embedded file art), but the DB thumb_url
cache column is empty — and the scan flagged on db_missing (empty thumb_url),
so every local album tripped it despite having perfectly good art in the files.
Now: a LOCAL album is flagged only when its files actually lack art
(disk_missing). An empty thumb_url is just a stale cache when the files have
art — not "missing cover art". db_missing still flags media-server-only albums
(no local files), where the DB thumb is the only art there is.
Tests: local+file-art+empty-thumb → NOT flagged (the bug); local+no-file-art →
still flagged; media-server-only+empty-thumb → still flagged.
The scan checked album_has_art_on_disk() on the RAW DB track path, while the
apply (_fix_missing_cover_art) resolves the path first. On any path-mapped
setup (docker mounts, a Plex/SoulSync path mismatch) the raw path isn't found,
disk art reads as "missing", and EVERY album gets flagged — then the apply
resolves the path, finds the art already there, and reports "already present".
Scan and apply disagreed purely because only the apply resolved paths.
Now the scan resolves the representative path the same way (resolve_library_
file_path, same transfer/download/config inputs the retag job uses) before
checking disk art. Unresolvable → treated as no-local-file (not claimed
disk-missing) so we never false-flag a file we simply can't reach.
Tests: disk check runs on the resolved path (thumb+art → not flagged);
unresolvable path → not flagged + art never checked on None.
JellyfinArtist sets self.thumb (→ artists.thumb_url on scan); JellyfinAlbum
never did. So for Jellyfin/Emby the library scan read getattr(album, 'thumb',
None) == None and albums.thumb_url stayed empty for the WHOLE library. Two
downstream effects: blank album art in the web UI, and the Cover Art Filler
flagging every album as "missing cover art" (db_missing = empty thumb_url) —
making the filler the only thing that ever populated the column, which is
backwards. It should come from the scan, like artist thumbs do.
Fix: JellyfinAlbum.thumb = /Items/<id>/Images/Primary (same shape as the artist
thumb, which already normalizes + displays via fix_artist_image_url). Now the
scan stores album thumbs, the UI shows art, and the filler only flags albums
that are genuinely missing art. Navidrome + Plex already set album thumbs.
Tests: album exposes the Primary-image thumb, None without an id, same shape as
the artist thumb.
The real reason Sokhi's cover-art fix "didn't work" and Boulder saw the same
message on a WRITABLE Windows box: _fix_missing_cover_art reported
"Updated database thumbnail, but could not write art to files (read-only?)"
whenever embedded==0 AND cover_written==False — but the overwhelmingly common
cause of that is every file ALREADY having embedded art (skipped, not failed).
The message blamed read-only on a perfectly writable library, which sent us
chasing a read-only ghost across three commits. Sokhi's /fix returns 200
(success), so he was never hitting the genuine EROFS path at all.
Now the message is derived from the art_result breakdown:
- embedded/cover written → "Applied cover art: …"
- failed>0 (non-EROFS) → "… check file/folder permissions"
- skipped>0 (already arted) → "Cover art already present on all N file(s)"
- otherwise → "no file artwork was applied"
Genuine read-only (EROFS) still hard-fails with the clear mount message.
Tests: already-arted→present (not read-only), failed→permissions, EROFS→hard
fail, embedded→success. 453 cover/repair/art tests pass.
Sokhi reported "14 singles, 0 albums" from a scan and setting lookback to "All"
didn't help — because it was never lookback. The cause is the per-artist
release-type filter: with "Albums" toggled off, _should_include_release drops
every 7+-track release (the full discography is still fetched, then filtered).
That skip was completely silent, so there was no way to see it in the log.
Now logs each skipped release with its type + the artist's albums/eps/singles
toggles, so "why didn't my albums get added" is answerable from app.log.
No behaviour change — diagnostics only.
Sokhi still hit the read-only error after the statvfs fix (6c3e285a). Root
cause was a gap that fix didn't cover: read_only_fs was only set from the
per-file EMBED write, but download_cover_art SWALLOWS its own cover.jpg EROFS
(logs "Error downloading cover.jpg" and returns). So when an album's tracks
already have embedded art, the embed loop is skipped, only the cover.jpg
sidecar write runs, its EROFS is swallowed, and the filler reported success
while spamming the log — exactly Sokhi's case.
Fix (no blast radius — download_cover_art still never re-raises, since its
import-pipeline callers aren't wrapped): on EROFS it now records
'_cover_read_only' on the passed context instead of just logging; apply_art_to_
album_files passes a capture dict and promotes that to read_only_fs. So a
cover-only read-only album now correctly surfaces the read-only message instead
of a silent success.
Tests: +1 — embed skipped (track already arted) + cover.jpg read-only →
read_only_fs True. 472 cover/art/repair tests pass.
#814 — the collapsed Batches rail (44px) hid .adl-batch-active and
.adl-batch-history-section but NOT the JS-rendered .adl-batch-summary line
("N batches · M downloading · …"), so it overflowed as clipped text. Added it
to the collapsed hide rule.
#815 — "Retry Failed" only toasted "Retrying N…" at the start and a generic
"Discovery complete!" at the end, with no sense of how many of the retried
tracks actually progressed. retryFailedMirroredDiscovery now stamps a baseline
(matches-before + retry count) on the state, and a shared completion toast
reports "Retry complete: X of N newly found[, Y still not found]" instead of
the generic message. Normal (non-retry) discovery still shows "Discovery
complete!".
JS syntax clean, 70 script-split/style tests pass.
#811 (reopen of #792, carlosjfcasero, Emby/Jellyfin): "append" mode clobbered
the playlist's custom image. The post-sync image push only excluded
'reconcile' — so append (which edits the playlist in place via
append_to_playlist) still re-pushed the source image over the user's poster
every sync. Now both in-place modes (reconcile + append) skip the image push;
only the destructive 'replace' (recreate-from-scratch) pushes it.
append_to_playlist + set_playlist_image were verified to NOT touch tracks or
description (image push only POSTs /Images/Primary), so this is the identity-
clobber fix for append.
Tests: append + reconcile preserve the image, replace still pushes it.
CubeComming #804: importing Coldplay "Yellow" (the 269s Parachutes album track,
correctly tagged) was quarantined — "Duration mismatch: file is 269.2s, expected
266.0s (drift 3.2s > tolerance 3.0s)". The expected 266s came from a re-resolved
*single* edition, not the file's actual album. The duration-agreement integrity
check exists to catch truncated/wrong slskd TRANSFERS — but a manual import is
the user's own already-tagged file being sorted, so checking it against a
re-resolved release just manufactures false quarantines.
Fix: both manual-import paths (singles + album) now mark the context
is_local_import; the integrity check skips the duration-agreement leg for local
imports via expected_duration_for_check() (new pure helper). The size +
mutagen-parse legs still run, so genuinely broken files are still caught — only
the release-vs-file duration comparison is skipped, and only for manual imports.
slskd downloads are completely unaffected.
This does NOT change the deeper matching (file still groups under Singles vs the
Parachutes album — the #767 canonical-version family); it stops the false
quarantine so the file imports.
Tests: 4 on the helper (local skips, download keeps, zero/None/garbage, string
coercion) + updated the routes context assertion. 557 import/integrity tests pass.
CubeComming had to dig through app.log to learn WHY a file failed ("integrity
check failed: Duration mismatch ..."). The reason was already returned in the
singles/process `errors` array and carried on the queue entry — the window just
showed "Failed" with no detail.
Now each failed file's reason renders under its row (red, left-bordered, with a
title tooltip for the full text). Pure presentation of data already present; no
API change. Vite build clean.
CubeComming #804: since 2.6.7, importing already-tagged files (Bruno Mars,
Coldplay) blanked EVERY tag and filed them under "Unknown Artist". Root cause:
both metadata-enhancement blocks in post_process_matched_download did
`except Exception: wipe_source_tags(file_path)` — a full audio.tags.clear() +
strip + clear_pictures. But enhancement throwing means NO new tags were
written, so wiping just destroys the originals. A transient enhancement error
on a well-tagged file = total metadata loss. (The reported "bitrate change" is
a red herring: mutagen padding on re-save, not a re-encode — ReplayGain only
reads via ffmpeg and tags via mutagen.)
Fix: gate the failure-path wipe on should_wipe_tags_on_enhancement_failure()
(new pure, tested policy) — only wipe UNMATCHED downloads (likely junk source
tags); NEVER wipe a clean/matched import, preserve its existing tags + log.
Unmatched-download behavior is unchanged, so the only thing that changes is the
broken case.
Tests: 3 pin the policy (clean→preserve, unmatched→strip, falsey→strip).
1211 import/pipeline/metadata tests pass.
The maintenance UI only renders the Scan → Dry Run / Auto-fix flow badge for
jobs with auto_fix=True (enrichment.js:1749/1925); auto_fix=False jobs show
'Scan Only'. The Expired Cleaner DOES have an auto mode (dry_run off → deletes
in-scan), so it should be auto_fix=True — that both labels it correctly and
surfaces the Dry Run badge. Safe: auto_fix is a UI/metadata flag only (exposed
at repair_worker.py:369); the worker never auto-applies from it — scan() owns
the dry_run-vs-delete decision. No behavior change, just the right badge.
The destructive job's findings-vs-auto toggle was 'auto_delete: False'. Renamed
to 'dry_run: True' to match the Re-tag job's convention and make the safe
default unmistakable: dry run ON (default) = findings only, deletes nothing;
dry run OFF = hands-off auto-delete. Behaviour-identical to the previous
default — just clearer + consistent. Help text + tests updated.
A Library Maintenance job that cleans up downloads tracked by Download Origins
once they pass a per-origin retention window — findings by default, opt-in
auto-delete.
A download is only ever proposed for deletion when ALL hold: older than its
origin's retention, NOT still in an actively-mirrored playlist / watched
artist, and played fewer than the keep-threshold (default 2 → "played more
than once is kept"). Only touches downloads recorded from the Download Origins
feature forward — never pre-existing or manual library.
- core/library/expired_cleanup.py: pure decision core (retention_cutoff,
is_expired, select_expired) — no DB/clock, fully tested. play_count is the
reliable listen signal (last_played is often unpopulated, so recency isn't
used).
- ExpiredDownloadCleanerJob: gathers facts (play_count via a new
get_origin_cleanup_candidates join; active-mirror via get_mirrored_playlists;
watch via get_watchlist_artists) and either creates 'expired_download'
findings or, with auto_delete on, deletes in-scan. Default OFF, both
retentions default 'off'. Settings auto-render in the Library Maintenance
panel (same as Cover Art / Lyrics / Re-tag).
- delete_origin_download(): shared delete (resolve path → remove file → drop
track row → drop history row); a file that won't delete keeps its row +
reports. Used by auto mode AND the _fix_expired_download apply handler.
- Frontend: type/action ('Delete')/result labels + finding detail render.
Tests: 9 on the pure brain (windows, off, per-origin, protected, play-count
threshold, bad age) + 7 on the job (no-op when off, findings, mirror/watch
protection, auto-delete, delete helper missing/real file). 185 repair/origin
tests pass.
Sokhi got a read-only error from the cover-art filler with NO ':ro' in his
compose. Root cause: my earlier Tim fix added a statvfs pre-flight that bailed
when f_flag & ST_RDONLY — but union/FUSE/network filesystems (mergerfs,
rclone, NFS), ubiquitous in self-hosted setups, misreport those mount flags.
A perfectly writable library could be flagged read-only and blocked. statvfs
is a guess; the only honest test is whether an actual write raises EROFS.
- Removed the statvfs pre-flight entirely. Read-only is now detected solely
from a real EROFS on the embed write, which also fast-fails the remaining
files (so no statvfs needed for the fast-fail Tim wanted either).
- Broadened the user message: a genuine read-only mount isn't always ':ro' —
could be a read-only host/NFS/SMB mount or a mergerfs read-only branch.
Tests: writable FS succeeds even when statvfs would claim read-only (the
regression), real-EROFS-on-write still flagged + bails the rest, EACCES still
not conflated with EROFS. Dropped the now-moot Windows-statvfs test (statvfs
is no longer referenced). 445 art/cover/repair tests pass.
Second lock-in catch: tracks.duration is stored in MILLISECONDS (schema), but
the scan passed it to LRClib as SECONDS. LRClib's exact-match-by-duration
strategy would never hit (215000s vs the real 215s), silently falling back to
the fuzzier title/artist search and storing the wrong duration in the finding.
Now divides by 1000 (guards against 0/garbage). Lyrics were still being found
via the fallback, so no track was missed — just less precise matching and a
wrong stored value. Test pins 215000ms → 215s.
Lock-in pass caught a real bug in 1051ef24: the retag lyrics path stuffed the
library title/artist into a plan's db_data to feed the lyrics query — but
db_data is exactly what write_tags_to_file writes ("only writes fields that
have DB values"). So an UNMATCHED track (one with no source match, meant to
get art/lyrics only) would have had its title/artist tags overwritten from
the library values — an unintended tag write on a track we never verified.
Fix: each plan now carries a separate READ-ONLY lyrics_meta
({title, artist, album}) sourced from the library track + album scope, kept
entirely out of db_data. apply_track_plans reads lyrics_meta for the query
(db_data fallback for older plans); unmatched plans keep db_data={} so no tags
are written. _fix_library_retag threads lyrics_meta through the manual-apply
path too.
Tests: +1 regression pinning that an unmatched lyrics plan calls
write_tags_to_file with EMPTY db_data (no title/artist leak) while still
fetching lyrics. 70 lyrics/retag/repair tests pass.
The lyrics sibling of the Cover Art Filler, plus retag integration — reusing
the existing LyricsClient (LRClib) the import pipeline already uses.
- lyrics_client: extracted the LRClib fetch (exact-match-with-duration →
search fallback) into a shared _fetch_remote_lyrics, used by both
create_lrc_file (unchanged behavior) and a new check-only has_remote_lyrics.
- MissingLyricsJob (core/repair_jobs/missing_lyrics.py): scans tracks with no
.lrc sidecar and — Option A — only flags ones LRClib actually has lyrics
for, so instrumentals/interludes are never surfaced or re-flagged. Registered
in the job list; default OFF; respects the lrclib_enabled toggle.
- _fix_missing_lyrics (repair_worker): applies a finding by fetching + writing
the .lrc and embedding lyrics via create_lrc_file.
- Re-tag tool: new 'lyrics' setting ('fetch'|'skip', default skip). When
'fetch', apply_track_plans now also fetches/refreshes the .lrc per track
(fetch-if-missing, re-embed-if-exists) — threaded through scan gates, finding
details, the auto-apply path, and the manual fix handler. Settings UI
auto-renders the dropdown from setting_options; no markup needed.
- Frontend: type/action/result labels for missing_lyrics + a finding detail
render case.
Tests: 12 — has_remote_lyrics truth table, sidecar detection, scan (only-
fixable / skip-existing / lrclib-disabled), the apply handler, and retag
lyrics_action on/off. 694 repair/lyrics/cover/retag tests pass.
The status pill is a z-index:-1 ::before drawn behind the text. On the
download-status column it looks right, but on the track-match-status variants
(match-found/missing/checking) the -1 pill rendered behind the adjacent
library-status cell and looked broken (Boulder). Removed the pill from the
match-status variants — they keep their coloured text (--row-state-fg), no
pill — and dropped the now-orphan z-index:0 stacking context from the base
rule (position:relative stays for the hover tooltip). Download-status pills
untouched.
Both buttons were added to showWatchlistModal() in api-monitor.js — which has
no callers (dead/legacy code), so neither ever rendered. The live watchlist
is the #watchlist-page in index.html; its action row (.watchlist-page-actions,
next to Global Settings) is the real header. Added both buttons there as
static markup. Download Origins (shipped in 1f7834cc) was in the dead modal
too — this surfaces it for the first time as well.
onclick-coverage integrity test green (both handlers resolve to their
standalone modules).
Closes the last acquisition gap — user-initiated downloads. A blocklist isn't
a censor, so search + discography stay fully visible; instead the download
ACTION is gated, visibly and overridably:
- Download modal (start-missing-process): an up-front check — if the WHOLE
album or artist being downloaded is blocklisted, return 409 {blocked:true}
with the entity, before starting a batch. The modal shows "X is blocklisted
— download anyway?" and re-POSTs with ignore_blocklist:true on confirm
(threaded onto the batch so the Phase 2a per-track filter skips it).
Scattered single-track bans still fall through to the 2a filter quietly.
- Manual /api/download (search-result download): source-file-centric, so it
matches the blocked ARTIST by name; same 409 + confirm + override. search.js
now sends artist/title so the guard has something to match.
- Precedence confirmed: force-download overrides "already owned", NOT a ban
(the 2a filter runs on the force-expanded missing list).
Frontend: shared confirmBlockedDownload() helper; modal + search callers
handle the blocked response and retry with the override.
Tests: manual download blocked-by-name / unrelated-allowed / override-passes,
and the modal up-front 409 for a blocked album. 8 blocklist API tests pass.
Phase 1 guarded the wishlist; Phase 2a closes the other auto-acquisition path.
Playlist sync, album download, and discography backfill all flow through
run_full_missing_tracks_process, which queues missing tracks at one point —
right where the explicit-content filter already drops tracks. The blocklist
filter slots in beside it: each missing track is checked and a banned
artist/album/track is dropped before queueing (logged with a count), so a
blocked item can't slip in via these flows.
Same brain as Phase 1: the wishlist guard's matcher is generalized to
db.blocklist_reason_for_track(profile_id, track_data, source=None) — the new
`source` param lets the queue path supply the batch source, since an analysis
track dict may not carry a 'provider' field (artists still match by name
fallback regardless). One method, two callers (wishlist + queue), one cascade.
Manual single-track downloads (/api/download, candidate picker, redownload)
are deliberately NOT gated here — that's Phase 2b, pending a block-vs-warn-vs-
override policy decision.
Tests: source-fallback isolation (album id-only proves source drives the ID
match; artist name still matches sourceless), and a queue-filter simulation
mirroring master.py. 35 blocklist tests pass (the only failures in the
download family are the pre-existing soundcloud /app ones).
Completes Phase 1 on top of the backend (43c798a7):
- Cross-source backfill: core/blocklist/backfill.py is a pure injected-resolver
core (resolve only missing sources, never raises); core/blocklist/runtime.py
wires the real metadata clients with a confident name-match (exact
significant-token equality; album/track also require the parent artist when
both expose one — no wrong IDs hung on an entry). Resolution runs
synchronously at add time, so a ban is cross-source from the first scan;
the artist name-fallback in matching covers any gap.
- API: GET/POST/DELETE /api/blocklist (profile-scoped) + /api/blocklist/search
(thin wrapper over the manual-match service search on the active source, so
the modal needn't know the source). Add resolves the other sources before
storing.
- Modal (webui/static/blocklist.js): tabbed Artists/Albums/Tracks in the
revamp design language (accent light-edge, pill tabs, debounced search with
spinner + out-of-order guard, per-result Block, "currently blocked" list
with a match-status star and per-row remove). Opened by a new "Blocklist"
button on the watchlist page, next to Download Origins.
Tests: 5 backfill (fill-missing-only, None/exception handling, arg shape) + 4
API (search proxy, add→backfill→list→delete round trip, validation). Modal
registered in the script-split onclick-coverage test; JS syntax-checked.
A proper artist/album/track blacklist (distinct from download_blacklist, which
stays untouched). ID-keyed across metadata sources so a ban survives a source
switch; profile-scoped; cascade artist→album→track.
- core/blocklist/matching.py — pure decision core (no I/O): build an index from
rows, candidate_block_reason() walks track→album→artist. Same-source ID match
is primary; artist NAME is a fallback (covers the ID-backfill window);
albums/tracks are ID-only (common titles like "Greatest Hits" must not
false-positive across artists). Source-isolated so a numeric Deezer id can't
collide with a numeric iTunes id of a different entity.
- DB: new `blocklist` table (profile_id, entity_type, name, 4 source-id cols,
match_status) + CRUD, match-row fetch, backfill-pending query, id-backfill
update (COALESCE — fills NULLs only).
- Guard: _wishlist_blocklist_reason at the top of add_to_wishlist — every
auto-acquisition path funnels through it, so one check covers watchlist,
discography backfill, repair, manual add. Fails OPEN (a guard error never
blocks a legitimate add).
- Discovery unified IN: legacy discovery_artist_blacklist is migrated into the
blocklist on upgrade (replicated to every profile so no global ban silently
stops working; idempotent; legacy table kept for rollback). Discovery reads
(hero + personalized-playlist SQL) now union the blocklist, so a new-modal
ban filters discovery too.
Tests: 13 on the pure matcher (cascade, id-vs-name rules, source isolation,
precedence) + 10 on the DB/guard (CRUD, profile isolation, dedup, backfill,
end-to-end wishlist refusal + cascade + the discovery migration upgrade path).
50 blocklist/personalized tests pass.
Self-review of df929dc0 found one gap: the crossfade preloader hits
/stream/library-audio with the file PATH, which 404s for a streamed (not
disk-mounted) Navidrome track — main playback worked, crossfade didn't.
/stream/library-audio now uses the same _build_library_stream_url fallback on
a disk-miss (resolving the song id from the new track_id param, or a DB
lookup by path), and the preloader passes next.id. Crossfade now works for
streamed libraries too.
Review also confirmed (no change needed): /api/stream/status returns only
status/progress/track_info/error_message — the Subsonic token in stream_url
never reaches the browser; it stays server-side and the browser only hits
/stream/audio. Proxy verified live: Range forwarded, 206 + Content-Range/
Accept-Ranges passthrough, body streamed in 64KB chunks, upstream closed.
mlody95pl: Navidrome sync works, playback fails ("Failed to resume playback").
Root cause: SoulSync plays library tracks by reading the file off its OWN
disk (/api/library/play → resolve path → serve bytes). "Report Real Path"
gives the correct path STRING, but that's Navidrome's container path — the
files still have to be mounted into the SoulSync container to open them, and
the user's compose has /music commented out. So disk resolution 404s.
Navidrome is a streaming server, so requiring a disk mirror to play from it is
the real limitation. Now, when a library file isn't on SoulSync's disk and the
active server is Navidrome, playback streams through the server's own Subsonic
/rest/stream API — no mount needed:
- NavidromeClient.build_stream_url(song_id, max_bitrate) — token-authed
/rest/stream URL (mirrors build_cover_art_url; password never exposed).
- /api/library/play: on disk-miss, _build_library_stream_url (Navidrome-only;
uses the song id sent by the player, or a DB lookup by file_path) sets a
session stream_url instead of failing.
- /stream/audio: proxies that stream_url with Range passthrough so HTML5
seeking works, streaming upstream bytes through in 64KB chunks (no full-file
buffering).
- session state gains stream_url; the two library-play callers now send the
track's server id.
Disk playback is unchanged (file_path path still wins when the file resolves),
so Plex/Jellyfin and mounted-Navidrome setups behave exactly as before.
Tests: 7 on the URL builder (auth shape, no-transcode default, maxBitRate,
guards) + 4 on the play-fallback routing (navidrome-only, passed-id vs
DB-lookup, none). 200 navidrome/stream/media-server tests pass.
Pache711: a cover-art finding showed the (correct) found album art next to a
(wrong) artist image with one "Apply Art" button — no way to take one and
skip the other. Turned out "Apply Art" only ever applied ALBUM art anyway;
the artist image was display-only context, so the bundling was an illusion
the UI created.
Now the finding is genuinely multi-target:
- scan (missing_cover_art.py): also searches for an artist image (always, so
a WRONG existing one can be replaced — Boulder's call), name-matched
exactly. Stored as found_artist_url only when it differs from the current
artist thumb, so nothing is offered when there's nothing to change.
- apply (_fix_missing_cover_art): honors a target via _fix_action —
'album' (default, unchanged "Apply Art" behavior: DB thumb + embed +
cover.jpg), 'artist' (the artist's DB image), or 'both'. New _fix_artist_art
sets artists.thumb_url for the album's artist.
- UI: each found image gets its own apply button — "Use for album" /
"Use for artist". Applying either resolves the finding, so taking the
correct one and ignoring the wrong one IS "fix one, dismiss the other".
Current artist art shows as "(current)" context with no button.
Default stays album-only, so the plain Apply Art button and every existing
caller behave exactly as before. Tests: 5 on the apply targets (artist-only /
album-only / default / both / missing-url) against a real SQLite DB, plus the
existing cover-art suite updated for the new artist search. 107 repair/
cover-art/UI-integrity tests pass.
Ashh: the manual-match modal fuzzy-searches a service and shows the top 8.
When the right release isn't in those 8 (common title — their example was
"Idols", which returns 8 unrelated releases and not Yungblud's), there was no
way through. But the user usually already knows the exact MBID.
Now the modal's search box doubles as a direct-ID box. Paste a MusicBrainz
MBID (bare UUID or a musicbrainz.org URL) and SoulSync looks that exact
entity up and shows it as the single result to confirm + Match — no fighting
the search ranking.
- core/library/direct_id.py: pure detector, returns the canonical ID only
when the text unambiguously IS one (whole-query UUID, or a UUID inside a
musicbrainz.org URL). "Idols", "Yungblud Idols", a UUID buried in free
text → None, so normal search is never hijacked.
- _search_service: direct-ID fast path before the fuzzy search —
get_release (→ get_release_group fallback for albums) / get_artist /
get_recording. A pasted-but-unresolvable ID falls THROUGH to fuzzy search,
so a typo can't dead-end the modal.
- UI: MusicBrainz placeholder now says "…or paste a MusicBrainz ID/URL".
Detector is service-keyed so Spotify/iTunes/etc. direct IDs can be added
later; today only MusicBrainz has a confirmable direct lookup, matching the
reporter's ask + screenshot. 9 tests: detector truth table (bare/URL/plain/
buried/other-service) + dispatch (confirmed release, release-group fallback,
unresolvable→fuzzy, plain query skips direct lookup).
Follow-up to 5187fe5f, which shipped stall handling as config-only keys.
Boulder wanted them user-accessible, so the two knobs now render in the
Torrent Client settings section:
- "Stalled torrent timeout (minutes)" — number input. Shown in MINUTES for
friendliness, stored in SECONDS (download_source.torrent_stall_timeout_
seconds). 0 disables. Blank/NaN falls back to the 10-min default on save.
- "When a torrent stalls" — Abandon (default) / Pause select, maps to
download_source.torrent_stall_action.
Both live under download_source (already in the settings POST allowlist), so
no backend change — load converts seconds→minutes, save converts back.
Inputs/selects only (no onclick), so the script-split onclick-coverage test
stays green. settings.js syntax-checked via Windows node.
noldevin's first torrent was stuck "downloading metadata" — a dead magnet
with no peers. The poll loop would ride the full album deadline (6h default)
on it, holding the worker the whole time, with no built-in escape.
New stall handling, off the existing poll loop:
- core/download_plugins/torrent_stall.py — pure StallTracker (clock injected,
no I/O): forward byte progress resets a stall clock; once a torrent spends
the stall timeout in a working state (queued/downloading/stalled/error)
with zero progress, it's stalled. seeding/completed/paused never count.
Covers the metadata-stuck case (0 bytes, 0 progress) and a dead mid-download
swarm with one rule.
- _handle_stalled: 'abandon' (default) removes the torrent + its partial data
(a metadata stub is junk) and fails the download so the next source can try;
'pause' parks it in the client for the user. Adapter errors are swallowed —
the download still fails cleanly.
- two settings (download_source.torrent_stall_timeout_seconds = 600,
torrent_stall_action = 'abandon'); timeout 0 disables, restoring the old
ride-the-deadline behavior. Config-key driven, matching the existing
album_bundle_* tuning knobs (no UI form, same as those).
Tests: 18 on the tracker + settings (timeout trip, progress reset, idle-state
exemption, pause→resume clock restart, disable, parse tolerance) + 3 on the
plugin action path (abandon removes w/ delete_files, pause pauses, adapter
error survived). 158 torrent-family tests pass.
Follow-up to 603b7a2a (tokens in the config store): /api/settings GET
returns the whole config dict, which would now include the OAuth access +
refresh tokens. The settings UI has no field for them — strip the key from
the response. dict(config_data) is a SHALLOW copy of live state, so the
section is rebuilt rather than popped in place (verified: response clean,
live config intact, token survives a settings save since POST merges
per-key).
wolf39us: "It keeps unauthenticating... daily" — re-auth fixes it until the
next day. Mechanism: spotipy's token cache was a loose FILE at
config/.spotify_cache. /app/config is a declared VOLUME, but a compose file
that doesn't map it explicitly gets an ANONYMOUS volume — recreated empty on
every container pull. So a nightly Watchtower update kept all his settings
(config lives in the database now) while silently dropping the OAuth tokens.
His redirect-URI change won't help: callback URLs only matter during the
initial handshake, never for refresh.
New DatabaseTokenCache (spotipy CacheHandler) stores the token payload in
the same database-backed config store as every other setting — tokens now
survive exactly as long as the rest of the configuration does. The legacy
file is imported once on upgrade (no forced re-auth) and removed on logout;
a failed cache write logs and never raises (spotipy calls it mid-request).
Tests: roundtrip, JSON-string tolerance, one-time legacy import (store wins
after the file vanishes), garbage file ignored, logout clears both stores,
write failure never raises. 204 spotify tests pass.
Netti93's follow-up report (single artist at download time, correct only
after retag) reproduces as FIXED on current dev — verified live against
Deezer's API with his literal track ('VERLIEBT IN MICH', FAYAN feat.
Dalton) and his exact config, through the real tag writer onto a real MP3:
TPE1=FAYAN, TIT2 gains '(feat. Dalton)', TXXX:Artists=[FAYAN, Dalton].
His last test (2.5.6 / May-19 dev) predates the fixes that closed it
(d5de724f contributors upgrade hardening, 0769fcd5 collab-tag loss).
These tests pin the full direct-download shape so it can't quietly
regress: Deezer /search payload (one artist) + provider on the candidate
(not the context) -> contributors upgrade fires -> feat_in_title and
artist_separator both honored. Network-free (client mocked with the live
API's verified response shape).
37725457 fixed _match_to_itunes to use the real iTunes client and flagged
the cross-source corruption as a possibility. Boulder's live DB proves it
happened: 6 of his 9 watchlist "iTunes" ids EQUAL the artist's Deezer id
(Taylor Swift's "iTunes" id was her Deezer id 12246; the real one is
159260351) — written back when the misnamed MetadataService.itunes slot
held a DeezerClient. The June-4 batch (Green Day, SOAD, Vulfpeck, ...) got
NULL instead because the slot now holds the Spotify primary.
The fix alone can't heal those rows: the backfill only fills EMPTY ids, so
a wrong non-empty id is permanent. New migration clears itunes_artist_id
where it equals deezer_artist_id (the corruption signature — distinct id
spaces, so a legitimate equal pair is effectively impossible, and the worst
case is a NULL that re-matches correctly on the next scan). Idempotent by
construction; similar_artists checked clean (its backfill always used the
registry correctly).
Tests: corrupted row cleared / legit + no-deezer rows kept / idempotent —
via a real re-init with the per-process init memo cleared (an app restart).
Boulder noticed his recently added watchlist artists (June 4 batch) have no
iTunes match while Spotify/Deezer/MusicBrainz matched fine. The rotated log
has the receipt: "Cannot match to iTunes - MetadataService not available" ×8
→ "Backfilled 0/8 artists with itunes IDs", every scan.
_match_to_itunes was the only matcher with no fallback: it read the PRIVATE
_metadata_service attr, which is None whenever the scanner is constructed
from a spotify_client — the normal web_server wiring — and gave up, while a
lazy-loading metadata_service property sat right next to it and the deezer/
discogs/musicbrainz matchers all fall back to their registry clients. Bonus
landmine: even when set, metadata_service.itunes is the FALLBACK-client slot
and may actually be a DeezerClient (per _match_to_deezer's own comment), so
"iTunes" matching could have stored a Deezer artist ID as itunes_artist_id.
Now mirrors the other matchers: canonical registry get_itunes_client().
Self-healing — missing IDs are re-attempted every scan, so existing
watchlists backfill on the next run with no migration needed.
Tests: match works with _metadata_service=None (the exact production
condition), unconfident result returns None, missing client degrades
gracefully. 103 watchlist tests pass.
CI failed all 7 requeue tests that passed locally. Root cause is a real
shipping bug, not test flake: config/settings.py's default template set
retry_next_candidate_on_mismatch: False ("Default off — opt-in") while the
monitor reads it with inline default True and the PR documents it as ON.
Outcome split the userbase: a FRESH install (or CI's clean runner) gets the
template key = retry engine silently OFF; an existing config.json lacks the
key = inline True wins = engine ON. Same code, opposite behavior, decided by
install age.
- template aligned to True (the documented + approved default; existing
installs already behave this way via the inline default)
- the requeue tests now pin the toggle ON via the wiring helper instead of
reading the runner's ambient config — CI's fresh defaults vs a dev's
lived-in config.json must never decide whether they pass. _patch_config
composes (it wraps the pinned get and falls through).
64 retry-engine tests pass; fresh-default simulation confirms the toggle
resolves True.
Boulder's lock-in question caught it: the read-only pre-flight called
os.statvfs unconditionally, which doesn't exist on Windows, and the
AttributeError wasn't covered by the except OSError — the whole cover-art
apply would have crashed for every Windows install (docker images are
Linux, so the reporter was fine; the maintainer wasn't). getattr-guarded
now: no statvfs -> skip the pre-flight, per-file EROFS detection (errno is
cross-platform) still active. Test pins the no-statvfs path.
Tim (Discord): cover-art automation fails with '[Errno 30] Read-only file
system' on every file; he chmod 777'd and nothing changed — because EROFS is
the KERNEL refusing writes to a docker ':ro' volume mount, which no chmod
can fix. SoulSync's response was a wall of per-file warnings and a fix
result that still said success with a soft "(read-only?)" hint.
- apply_art_to_album_files now pre-flights the album folder with statvfs
(asks the kernel, writes nothing): a read-only mount short-circuits the
whole album instead of failing file by file. Belt: a per-file/cover EROFS
(overlay quirks where statvfs lies) still sets the flag.
- the repair worker's apply now FAILS the finding with the actual cure:
"remove ':ro' from the volume mapping and recreate the container — chmod
cannot change this". EACCES (a real permissions problem chmod CAN fix)
deliberately keeps the old soft path.
Tests: RO mount short-circuits before any file/cover write, save-time EROFS
still flagged, EACCES not conflated with EROFS. 29 art/repair tests pass.
Sokhi (continued from #806): volume-numbered series ('B小町 …キャラクター
ソングCD Vol.2' / 'Vol.2.5' / 'Vol.4' / 'Vol.4.5') got each other's art from
both normal downloads and the retag tool. Two distinct holes, one principle:
1. The art picker's _album_matches validates by significant-token SUBSET —
built to tolerate '(Deluxe)'/'- Remastered' suffixes. CJK strips out of
the normalizer entirely, so Vol.4 → {b,tv,cd,vol,4}, a clean subset of
Vol.4.5's {b,tv,cd,vol,4,5}: the wrong volume validated as "the same
album with a suffix". Affected every fuzzy art source (iTunes, Deezer,
AudioDB, Spotify) in downloads, retag, and the missing-art repair.
2. MusicBrainz match_release scores by string similarity — Vol.4 vs Vol.4.5
is 0.973, so the wrong volume could win the match outright, and its MBID
then feeds Cover Art Archive with NO downstream validation (CAA is
MBID-keyed, trusted by design). With Sokhi's MB metadata source this is
the likely path in his logs (his release-group 404s push re-matching).
The shared rule (core.text.title_match.numeric_tokens_differ): digit-bearing
tokens must be IDENTICAL between the two titles. A number on one side only —
volume, part, sequel, remaster year — is a different release, never a
suffix. '1989' vs '1989 (Deluxe)' still matches (digits shared); 'Album' vs
'Album 2' now rejects (sequels!). Art picker rejects outright (falls through
to next source / the download's own art — the designed cost of a false
reject); MB matcher halves the candidate's confidence, landing it below the
70 gate while the exact-volume result is untouched.
Tests: helper truth table, the exact reported pairs through _album_matches,
and match_release end-to-end (wrong volume alone → no match beats a wrong
MBID; exact volume beats near-identical wrong one despite lower MB score).
828 matching/metadata + 301 musicbrainz/retag/artwork tests pass.
Boulder: "Taylor Swift shows only 8 albums, nothing before 2022, no singles,
no EPs" — for every artist (actually: every WATCHLIST artist). Traced live:
get_artist_albums caches its result under an UNQUALIFIED key (no limit/page
info), and the watchlist's new-release probe (limit=5, max_pages=1 — the
April "reduce watchlist API calls ~90%" optimization) stored its truncated
single page in that same slot. The artist detail page reads the cache first,
so a watchlisted artist's page showed only the newest handful of releases —
newest-first, hence "nothing before 2022" — re-poisoned on every scan, with a
30-day TTL. When the source-priority fetch comes back tiny, the page's
fallback path quietly serves it, so the symptom looked like a discography
filter bug. Not related to the #808 matching change (that is a pure max(),
provably additive).
Three pieces:
- get_artist_albums tracks whether the fetch stopped while more pages
existed (truncated) and only caches COMPLETE discographies. Individual
albums keep their opportunistic caching — they're complete entities
regardless of pagination. A small real discography that fits one page
stays cacheable even under max_pages=1.
- MetadataCache.purge_artist_album_lists(): delete the already-poisoned
album-list entries (TTL would have kept them for weeks); lists rebuild
lazily on the next artist-page visit.
- one-time startup purge in web_server, config-guarded
(maintenance.album_cache_purge_v1), mirroring the startup-repair pattern.
Tests: truncated probe never stores the list (but still returns its page),
complete multi-page fetch caches, and a genuinely-small one-page discography
under max_pages=1 still caches. 1087 spotify/cache/watchlist/artist tests
pass.
carlosjfcasero: 'Champagne Supernova (OurVinyl Sessions)' is in the library
but the artist page shows it unowned and wishlist cleanup never removes it.
Measured with the real catalogs: Deezer/iTunes title the TRACK with the
qualifier while the library track is bare (the qualifier lives in the album
title) — and _calculate_track_confidence crushed that pair to ~0.17: the
"clean" titles keep parenthetical words, so the length-ratio penalty treats
'Champagne Supernova' vs 'Champagne Supernova (OurVinyl Sessions)' as
different songs. (Also confirmed: the OurVinyl release is absent from
Deezer's discography for the artist, so the standard page's 25-release list
not showing it is the source catalog, not a bug.)
Fix 1 — core.text.title_match.strip_redundant_context_qualifiers: a
parenthetical qualifier whose text appears (word-bounded) in the db track's
ALBUM title — or in the other title — restates release context and is
stripped for a comparison variant scored with its own length guard. Genuine
version markers keep their penalty: '(Live)' on a studio album appears in no
context and still blocks; '(Live)' on 'Live at Wembley' correctly matches —
owning the live album IS owning the live cut. Wired into
_calculate_track_confidence, so every check_track_exists consumer (wishlist
cleanup, discography dedup, repair jobs) benefits.
Fix 2 — the artist-page ownership endpoint's album gate: when album-aware
narrowing eliminates EVERY library candidate (the source's album naming just
doesn't resemble the library's — 'Jillette Johnson | OurVinyl Sessions' vs
'Champagne Supernova (OurVinyl Sessions)' ~0.5), fall back to artist-wide
title matching instead of declaring everything unowned off a failed
album-NAME comparison.
Tests: 8 — the exact reported pair end-to-end through check_track_exists,
word-boundary containment ('live' in 'alive' doesn't count), version-marker
safety both ways, and prefix songs still blocked. 1125 matching/wishlist/
library tests pass.
Review findings from PR #801, fixed as promised after merge:
- core/imports/version_mismatch_fallback.py and core/downloads/task_worker.py
used bare getLogger(__name__) — outside the soulsync.* namespace where
handlers attach, so the entire retry story (the [Modal Worker] search/retry
walk and, critically, the "accepting best quarantined candidate as last
resort" warning) never reached app.log. Same bug class as the prepare.py
fix; both moved to get_logger. A repo sweep shows 61 more modules with the
same pattern — noted as its own cleanup project.
- the full-suite run also caught a miss of MINE, not the PR's: the new
origin-history.js wasn't registered in the script-split integrity test, so
openDownloadOriginsModal failed onclick coverage. Registered — and the
onclick scan now iterates the NON_SPLIT_JS registry instead of its own
hardcoded copy, so the next standalone module can't silently skip coverage.
Merged dev verified: PR's 77 tests + 4233 full-suite tests pass (the only
exclusion is the eternal soundcloud /app file); integrity suite 64/64.
Watchlist scans add announced albums on purpose (so singles download the day
they drop), but the future-dated tracks leaked into two hot paths:
- Fresh Tape / Release Radar: future albums got NEGATIVE days_old, and the
recency score (100 - days*7) has no upper clamp — prereleases weren't just
slipping into the radar, they were mathematically FAVORED above every
released track. That's the "50% prerelease" report. The builder now skips
confidently-future albums (and clamps days_old to 0 as a belt).
- Wishlist processing: every auto cycle burned a full Soulseek search +
timeout per unreleased track (~60 tracks/cycle for the reporter). Both the
auto and manual flows now skip future-dated tracks with a counted log line.
They STAY in the wishlist and join the cycle automatically the day their
release date passes — no state, the date check is per-cycle. An explicit
manual track selection overrides the gate (the user asked for those).
The gate (core/metadata/release_dates.py, pure + tested) is conservative by
design: Spotify dates come as YYYY / YYYY-MM / YYYY-MM-DD, and a track only
gates when its date is CONFIDENTLY future at its stated precision. Release
day counts as released; garbage or missing dates never block anything
(including out-of-range months/days, which fall back a precision level).
Tests: 6 covering all precisions, the release-day boundary, garbage
tolerance, dict shapes, and ordered partitioning. 403 wishlist/watchlist
tests pass.
User ask: "a modal that lists the tracks downloaded via watchlist" — extended,
as discussed, to playlists too. One modal, two tabs, opened from the Watchlist
page (watchlist tab preselected) and the Sync page (playlists tab) — same
shared-modal-different-entry-points UX as the rest of the app.
The data: library_history recorded which SERVICE a file came from but never
what TRIGGERED it. New origin/origin_context columns (migration + index) are
written once at the import chokepoint via core/downloads/origin.py, a pure
tested deriver that reads, in priority: an explicit _dl_origin stamp (set at
batch-task creation for direct playlist batches, where the playlist context
otherwise only survived in folder mode), the wishlist provenance already
riding in track_info.source_info (watchlist_artist_name / playlist_name —
watchlist_scanner has stamped these for ages), and the folder-mode playlist
thread. Manual downloads stay unclassified by design. History starts from
now — provenance can't be conjured retroactively.
API: GET /api/download-origins?origin=watchlist|playlist (paged) and POST
/api/download-origins/delete — deletes the file on disk (resolved through the
shared container/host path resolver), the matching library track row, and the
history entries; a file that refuses deletion keeps its row and reports the
error instead of lying.
UI: webui/static/origin-history.js — tabbed modal in the revamp design
language (accent light-edge, pill tabs, entry rows reusing the
library-history-entry components), per-row delete + select-all bulk delete
with honest result toasts, empty/loading states, per-tab totals.
Tests: 8 — deriver priority/shapes (incl. the exact watchlist_scanner
source_info shape and JSON-string survival), origin filtering + counts,
row fetch/delete isolation between origins, delete-track-by-path.
The lock-in pass caught the cost hole: art is fetched PER TRACK, and the old
code never touched archive.org at all — so an archive.org outage was free,
while the new native-first chain would pay a 10s timeout on every track
(a 12-track album = +2 minutes, exactly the import-slowness class we spent
today killing). One failed original now puts originals on a 10-minute
cooldown: subsequent fetches go straight to the 1200px CDN midpoint (the
pre-#806 behavior, full speed) and recover automatically when the cooldown
expires. Locked by a test: track 1 pays the failure once, track 2 never
touches the original. (Also: the missing time import the first run caught.)
The CAA branch of _upgrade_art_url capped art at the /front-1200 thumbnail —
a deliberate flakiness trade-off, but the policy had rotted into inconsistency:
iTunes art already shipped at 3000x3000, and bare /front URLs (release-group
lookups — exactly what the Re-tag flow produces) bypassed the cap entirely,
which is how Sokhi observed retag delivering full-res while downloads got 1200.
CAA URLs now upgrade to the bare /front ORIGINAL (native res, frequently
3000px+). The flakiness concern that motivated the old cap is handled where it
belongs, in the fetch: _fetch_art_bytes now walks an attempt chain — original
-> /front-1200 midpoint -> the original sized thumbnail — so a flaky
archive.org degrades to the old 1200px behavior, never below it.
Tests updated to the new contract (+3 chain tests: native-first, flaky
degrades to 1200 not 250, full chain ends at the thumbnail). 623 metadata +
1267 art-path tests pass.
Caught live by the new lookup timing ("Genius track lookup took 242.4s"):
the 429 handler slept the backoff (30/60/120s) in the CALLING thread and then
re-raised anyway — the import pipeline waited 2x120s per track for lookups
that still failed. Worse, the pre-flight backoff wait also slept while
HOLDING the global Genius API lock, so every other Genius caller queued
serially behind the nap.
Now the backoff is a gate: a 429 opens a 30s->60s->120s window and re-raises
immediately; any call inside the window raises GeniusRateLimitedError on the
spot. The error subclasses requests.RequestException, so every existing
caller (the import's source lookups catch RequestException and skip; the
worker's per-item guards) already handles it as a one-line skip — lyrics and
Genius tags are garnish, nothing is allowed to WAIT for them.
Tests: backoff window fails fast (<0.5s vs the old full-window sleep), a 429
opens and escalates the gate without sleeping, the error is a
RequestException (the no-call-site-changes hinge), success decays the gate.
Measured during a live album download: ~4m15s per track in post-processing
(normal is ~20s), with the time vanishing silently inside embed_source_ids —
up to 5 MusicBrainz calls per track crawling against a degraded musicbrainz.org
while the MB enrichment worker kept eating the same ~1 req/s per-IP budget.
Only Spotify/Last.fm/Genius were in the yield set; MusicBrainz, Deezer, iTunes,
Discogs etc. kept grinding through downloads.
Policy (new core/enrichment/yield_policy, tested):
- downloads active -> ALL enrichment workers yield (post-processing touches
every metadata source). listening-stats (local-only) and repair
(user-scheduled) intentionally keep running.
- discovery active -> the API-contention five yield (spotify/itunes/deezer/
discogs/hydrabase) — discovery never paused anything before, despite the
pause helper literally defaulting to label='discovery'.
- user overrides and user-paused bookkeeping keep their existing semantics;
the dashboard yield_reason label now says WHICH foreground work caused it.
Observability (the 4-minute silence can never come back):
- every source lookup is timed; >2s logs a warning NAMING the source and
duration (core/metadata/source.py _call_source_lookup)
- the pipeline always logs "Metadata enhancement took X.Xs" per track
7 policy tests (incl. the motivating case: MB yields to downloads, keeps
running during discovery); 277 pipeline/enrichment tests pass.
The lock-in pass caught it before it shipped anywhere: the pill styling set
display:inline-flex on the status cells — which are <td>s — knocking them out
of table-cell layout and corrupting the row grid. The pill is now a centered
pseudo-element painted BEHIND the text (z-index -1 inside the cell's own
stacking context), so the cell's box model is untouched. State colors stay as
CSS vars on the td and cascade into the pseudo.
Also covers the secondary live-progress writer discovered in the same pass:
it stamps legacy download-downloading / download-complete classes instead of
data-state — both vocabularies now get the same pills (accent + breathe while
downloading, green when complete).
A user reports ~0.7 MiB/s RSS growth; the one theory offered so far
(connection leak) was debunked, so instead of guessing: measure. New
core/diagnostics/memory_tracker wraps tracemalloc behind three GET endpoints
the user can drive from a browser:
/api/debug/memory/start begin tracing + baseline snapshot (idempotent)
/api/debug/memory/report top allocation sites by GROWTH since the baseline
(?top=N), with traced totals + process RSS so we
can see how much of the real growth tracing
accounts for; 15-frame tracebacks name the caller
/api/debug/memory/stop end tracing, free trace bookkeeping
Opt-in by design — tracemalloc shadows every allocation while active, so it
never runs by default. RSS via psutil with a /proc fallback.
Tests: report-without-tracking returns a hint (not an error); a real
start->hog->report->stop roundtrip attributes a genuine 5MB allocation to the
test file (fun fact encoded in the test: 'x'*1000 constant-folds into ONE
shared string and traces as ~40KB — the hog must allocate at runtime); the
stat formatter is duck-typed and unit-tested.
The status cells were the last plain-text corner of the revamped modal, and
they're the most alive data in the app during a run. The renderer now stamps
data-state on the download-status cell and toggles .row-working on the row
(visual-only hooks; zero logic change). CSS turns both status columns into
state-colored pills — accent while searching/downloading, amber processing,
green completed, red failed, orange quarantined — and ONLY the actively-
working states breathe (opacity, compositor-only). The working row carries the
same accent edge treatment as hover, but earned by real work instead of the
mouse. prefers-reduced-motion respected.
- entrance: soft rise + settle, one-shot spring
- header light-sweep: the dashboard's signature strip (same keyframes, same
transform-only technique) drifting across both modal headers
- progress sheen: a light band scanning the FILL — it lives inside the fill's
clip, so zero progress shows nothing and motion is gated by real progress
- hero stats become glass chips with per-state color identity (found green,
missing amber, downloaded accent) and a top light-edge each
- download modal's close X matches the discovery one (circular ghost, rotates)
- press feel on every pill button (active scale)
- all of it honors prefers-reduced-motion; only transform/opacity animate
Both modals were functionally perfect but visually dated — flat dark panels,
heavy table grids, the discovery modal still wearing its legacy RED border.
Pure CSS override layer appended last in the cascade; markup and JS untouched.
Same design language as the dashboard pass, theme-aware via --accent-rgb:
- deep glass surface with an accent light-edge along the top
- progress bars -> rounded inset tracks with gradient accent fill + glow
- tables -> micro-label sticky headers, calm hairline rows, accent hover
glow with an inset edge bar, themed thin scrollbars, accent checkboxes
- download modal: the two stacked progress bars become side-by-side glass
cards; tracks toolbar with a pill selection counter; glass footer with
pill buttons (gradient primary, ghost secondary, soft-red danger)
- discovery modal: red border killed, kicker typography header, circular
rotating close button, carded progress + table, matching pill footer
CI builds with GHA layer caching (cache-from/to: gha), and the nightly RUN's
cache key is just the instruction text — the layer could pin a months-old
"nightly" across releases, silently defeating the channel's whole purpose.
Referencing COMMIT_SHA (already passed by docker-publish.yml) in the RUN makes
every new commit bust that one layer while everything above it stays cached.
YouTube now gates downloadable formats behind JS challenges (nsig); yt-dlp
needs a JavaScript runtime (Deno — its only default-enabled one) to solve
them, and its STABLE channel can lag months behind a breaking YouTube change.
Users hit "Requested format is not available" on every stream and music-video
download with no hint why. Neither piece is fixable via requirements.txt:
Deno isn't a Python package, and a version floor can only ever resolve stable.
- Dockerfile: install Deno in the runtime image (official installer,
auto-detects amd64/arm64, build fails early via `deno --version` if it
breaks; unzip added for the installer) and build with the yt-dlp NIGHTLY
channel (`pip install -U --pre "yt-dlp[default]"`) on top of requirements
- core/youtube_client.py: one-time startup warning when deno isn't on PATH,
naming the exact failure it causes and the install command — instead of
letting users debug a cryptic yt-dlp format error
- requirements.txt: annotate the yt-dlp line with the stable-lag caveat, the
nightly upgrade command, and the Deno requirement
- README: Deno + nightly notes in Prerequisites and the Python (No Docker)
install section; Docker bundles both automatically
The tile's liveness was wired to sync:progress / discovery:progress — both
ROOM-scoped (only clients watching a specific playlist receive them), so the
dashboard tile would basically never light. And the scheduled auto-sync runs
as an automation, reporting on automation:progress — the wrong tile.
The 1s sync emitter now also sends an UNSCOPED sync:active heartbeat while any
playlist work is running anywhere: manual per-playlist syncs (sync_states),
the UI-triggered mirrored pipeline (playlist_pipeline_progress_states), and
scheduled auto-sync pipelines (running automations whose action_type is
playlist_pipeline / sync_playlist / refresh_mirrored). Emitted only while
active; the tile's 6s freshness decay handles the off. The dashboard listens
for the heartbeat alongside the (kept) room-scoped signals.
The three bento tiles had signature background animations that were pure
decoration. Each now SURGES while its subsystem is actually working, driven by
the live socket events — idle keeps the exact calm look they always had:
- Auto-Sync: the EQ bars dance fast + brighter, the playhead sweeps quicker
and the pulse dot races while a sync/discovery pipeline is running
(sync:progress / discovery:progress)
- Tools: the gear spins up 4x and brightens while a tool, scan, db-update or
repair job is running (tool:* / scan:media / repair:progress, with a shape-
tolerant "actually running" check so the 1s idle pushes don't light it)
- Automations: the flow nodes + line signals pulse at 2.5x while an automation
is firing (automation:progress)
Tiles carry .is-live while the last matching event is <6s old; a 2s interval
handles decay (no rAF, no per-frame JS).
GPU pass on the same tiles, same visuals:
- hero playhead animated `left` (layout + paint every frame, 9s loop) -> a
full-width strip whose 1.5px line is a static background, transform-only
- flow-node pulse animated background + box-shadow x3 nodes -> bright state
painted once on a pseudo, opacity breathes; added to reduced-motion kills
Audit of every dashboard animation. Already good and untouched: orb canvas
(cached glow sprites, no shadowBlur, stops on tab-hide/page-switch/scroll),
shimmer scan, sidebar orbs, embers, rl-blink (all transform/opacity), and the
reduce-effects global kill-switch. The offenders were infinite animations of
paint-bound properties — each repaints its region every frame, forever:
- avatar halo: animated box-shadow on every active bar -> the bright state is
painted once on a wrap pseudo and only its OPACITY breathes (the wrap exists
because the avatar clips overflow)
- rate-limited warn: animated filter:brightness -> a white-wash pseudo whose
opacity breathes
- active-fill glow: animated box-shadow -> static glow at the old midpoint,
breathing moved to the tip's opacity
- header sweep: animated background-position across the full-width band (on
all four headers sharing the class) -> a real child strip translated inside
an overflow-clipped wrap; transform+opacity, zero paint
- orb canvas: renders at ~20fps while fully asleep (drift is at crawl speed —
invisible) instead of 60fps for the hours the dashboard sits idle
Visual parity throughout; peak-flash (event-driven, 0.65s one-shot) keeps its
box-shadow since its duty cycle is negligible.
1-3 tiny accent sparks per socket update drift up from each bar's fill tip,
scaled by the real (unclamped) rate — motion strictly means API calls are
happening right now. Self-removing DOM nodes with a per-bar live cap of 6,
suppressed during cooldown and under reduced-effects mode. The taste-risk one
of the set: revert this commit alone if it reads as noise.
The payload has carried daily_budget {used, limit, exhausted} forever and the
dashboard rendered none of it. The avatar disc now wears a conic progress rim
that fills as the day's real-API budget is spent — green to 70%, amber to 95%,
red after — and flips purple once the worker has bridged to Spotify Free for
the rest of the day (using_free now included in the emit payload). Tooltip
carries the exact used/limit numbers.
A banned service used to just tint red. The payload carries the seconds
remaining (rl_remaining), so the bar now locks into a cooldown state: the live
VU dims, a red column drains away as the ban ticks down (largest remaining
seen is latched as the denominator — only remaining is sent), and an m:ss
timer counts to recovery. The moment the ban expires the track flashes green
('recovered') and the VU takes the stage back. 'Back in 4:00', not
'something's red'.
A thin accent marker sticks at each bar's recent maximum, holds ~1.2s, then
falls a few percent per update until it rests on the live fill — exactly how a
hardware VU meter's peak LED behaves. A traffic burst stays readable for a few
seconds after it's over instead of vanishing with the next 1s sample. Hidden
while it sits on the fill so idle bars don't carry a stray line.
The inactive->active transition (caught at the existing 30-frame state
refresh) now jolts the orb: a random-direction velocity kick with a briefly
lifted speed cap so it actually darts, a fast shallow size wobble (~1s decay),
and three sparks. A worker waking up reads as 'it sprang into action' instead
of just getting brighter.
Zero active workers + no pulses in flight for 75s eases the whole header into
a drowse (~4s): nucleus dims to embers, logo and orbs fade ~35-45%, spokes and
links fade ~70%, drift slows to a quarter speed (velocities keep integrating
so motion stays continuous). The first sign of work wakes it in ~0.3s with
three staggered rings blooming out of the nucleus. Idle and busy finally look
DIFFERENT — the contrast is what makes activity read as alive.
Each inbound pulse draws 3 fading ghost positions behind its head. The path is
parametric (eased t), so the tail is the same easing evaluated slightly in the
past — no position history, and it naturally stretches as the pulse accelerates
into the nucleus. Makes the energy flow legible at a glance.
A telemetry pulse used to just vanish on arrival. Now it makes contact: a small
expanding ring at the rim where it hits (pulse's own color, ~0.5s fade) plus
two debris sparks splashing back along the approach direction — the nucleus
reads as absorbing the work instead of deleting it. Capped pool (24), reset
alongside sparks/inflows on page switches and collapses.
Each worker orb gets a slow z-oscillation (-1 back .. +1 front); orbs draw in
painter's order with the hub pinned at z=0, so the cluster visibly swings
behind and in front of the nucleus instead of drifting on a flat plane. Depth
scales size ±18% and dims the back arc; the physics stay 2D. Effect eases out
during the hover-expand morph so orbs land on their buttons at natural size.
On page load every orb sat at the canvas origin: orbs are created at (0,0) and
the random scatter skipped orbs that weren't visible yet — which on load is all
of them, since the header hasn't been laid out when init() runs. The whole
cluster then drifted in from the top-left corner.
scatterOrbs() -> centerOrbs(): spawn the cluster dead-center of the canvas,
positioning every orb regardless of visibility (a few px of jitter on purpose —
the separation force ignores pairs closer than 0.1px, so a perfect stack would
never split). enterOrbState() also re-centers right after resizeCanvas(), so
activations get the true center even when init ran against a hidden 0x0 header
— and returning to the dashboard replays the center-bloom intro.
"Liked Songs" isn't a real Spotify playlist — no playlist URI exists for it;
the web UI invents the virtual id 'spotify:liked-songs' and Spotify serves the
collection via the saved-tracks endpoint. The playlist DETAIL endpoint special-
cases that id, but the mirrored refresh path resolves stored ids through
get_playlist_by_id, which fed the virtual id straight into sp.playlist() ->
"http status: 400 ... Unsupported URL / URI" on every sync cycle, silently.
get_playlist_by_id now special-cases the virtual id at the client seam (every
by-id resolver benefits, not just the mirror adapter): it builds the Playlist
from the existing get_saved_tracks() pagination, with the real owner name and
track count. New LIKED_SONGS_PLAYLIST_ID constant owns the magic string.
Safety: get_saved_tracks swallows fetch errors into [] — indistinguishable
from "no likes" — and the virtual playlist is only ever offered when likes
exist. An empty result therefore resolves as a FAILED refresh (None) instead
of a valid-looking empty playlist a mirror sync might propagate by clearing
the server-side copy.
Tests: virtual id resolves from saved tracks and never touches the playlist
endpoint, real ids still do (regression), the mirrored adapter seam returns a
full PlaylistDetail, and empty saved-tracks -> None. 473 passed across the
playlist/mirror/spotify families.
On path-mapped setups (Docker mounts etc.) the scan checked a bare
os.path.isfile() on the raw DB path — false for EVERY track — while the apply
handler resolves container/host mismatches. With a cover-art mode set, the
cover_action kept the album past the "anything to do?" gate, so every album
produced a finding with an empty tracks list whose apply could only ever fail
with "No tracks to re-tag in finding".
- the scan now resolves each track path with the same resolver the apply
handler uses (resolve_library_file_path) before reachability checks and
current-tag reads; plans carry the resolved path
- a finding can never be created with zero tracks — cover-action albums with
no usable tracks are skipped, with a debug log of why (unreachable/unmatched
counts) and the counts surfaced in the finding description
- unmatched-but-reachable tracks now get an art-only plan (empty db_data) so
album cover art covers ALL the album's files, not just source-matched ones —
apply_track_plans already treats empty db_data as a pure cover embed and
counts a failed cover download as skipped, never failed (now locked by tests)
- cover-only findings are titled "(cover art, N track(s))" instead of the
puzzling "(0 track(s))"
Tests: +5 (mapped paths resolve into plans, cover-with-nothing-reachable
creates no finding, unmatched -> art-only plan, art-only plan embeds cover,
failed cover download -> skipped). 87 passed across retag/repair/tag_writer.
Since the per-listener stream sessions refactor (Phase 3b), every browser gets
its own stream session — but the 1s 'tool:stream' socket broadcast still read
the legacy GLOBAL state (the DEFAULT session no real browser uses), so it told
every client "stopped" forever. The frontend skipped HTTP polling whenever the
WebSocket was up, so it only ever saw that wrong broadcast: the backend prep
downloaded the track, moved it into the session's stream folder and sat at
"ready" while the mini player showed nothing. Proxy users whose WebSockets
don't connect fell back to HTTP polling (session-correct) and streamed fine —
which is why this hid so well.
Fix: stream status is inherently per-listener, so stop pretending a global
broadcast can carry it —
- web_server.py: remove the 'tool:stream' emit from the tool-progress loop
(the broadcast thread has no request context; it can only ever see DEFAULT)
- media-player.js: the status poller always polls /api/stream/status (resolves
the caller's own session from the cookie); drop the dead broadcast handler
- core.js: unwire the 'tool:stream' socket listener
Observability fix that made this undebuggable: core/streaming/prepare.py used
getLogger(__name__) — outside the soulsync.* namespace where handlers attach —
so every prep log line (including failures) vanished from app.log. Moved to
get_logger("streaming.prepare") + a regression test locking the namespace.
34 streaming tests pass; ruff clean; web_server compiles; JS syntax-checked.
A single enriched against the deluxe gets every source ID pointing at the deluxe,
so the organizer filed it as e.g. track 2 of a 10-track album. Root cause: the
canonical resolver only ever scored the editions already linked — the correct
single was never even a candidate, and the misfit deluxe scored so low (0.1,
below the 0.5 floor) that nothing got pinned and the priority-walk grabbed the
deluxe anyway.
Fix, in three tested layers:
- resolve_canonical_for_album gains a fetch_alternates seam: when no linked
edition clears the floor, it scores the source's OTHER editions of the same
release and re-picks by best fit (dedup, injected, pure).
- default_fetch_alternates lists the artist's editions and keeps the same-release
ones (edition-blind name match: Deluxe / - Single / [Remastered] all collapse),
returning their tracklists. Favors recall; the scorer is the precision gate.
- _resolve_source does the misfit check inline: it fit-scores the walked edition
and only on a clear misfit searches for a better edition, then persists the pin
on apply (Track Number Repair + future runs agree). Cost-neutral and behavior-
identical for well-fitting albums (no extra API calls); strict_source and the
#758 manual lock are never overridden.
Tests: +4 resolver (expand/no-expand/dedupe/back-compat), +7 alternates (name
matcher + fetcher over fake APIs + cap), +3 organizer end-to-end (misfit->single
+pin, well-fit->no-expand, strict->no-expand). 300 passed across the reorganize
+ canonical family, lint clean.
User report: "Not authenticated with Spotify" daily + workers paused. The
log was misleading — is_spotify_authenticated() returns False for five
distinct reasons (no creds / rate-limit ban / post-ban cooldown / no
cached token / probe failure), and all five API call sites logged the
same bare "Not authenticated with Spotify". So a routine rate-limit ban
read as a logout, and the real cause (logged only at DEBUG) was invisible.
New pure describe_spotify_unavailable() maps the real state to a clear
message (priority matches is_spotify_authenticated): not-configured →
rate-limited ("ban ~Nm left (not a logout)") → post-ban cooldown →
no-token ("not connected — re-authenticate") → "auth check failed (token
refresh may have failed)". A side-effect-free client method
(_auth_unavailable_reason) gathers the live state (reads the cached token,
no API probe) and the 5 sites log it.
Now a daily ban is identifiable as a ban, and a genuine logout is
identifiable as one — so reports like this are diagnosable from the log
alone. 7 tests pin the priority/messaging. Full suite clean.
User request: the re-tag diff card shows old→new metadata per track but not
the file it applies to, so a wrong match is hard to spot before applying.
The finding already carries each track's file_path (details.tracks[].file_path
from the scan) — the renderer just wasn't showing it. Now each changed track
displays its filename beneath the title, so you can eyeball that the metadata
about to be written actually belongs to that file. Skipped when the label is
already the filename (track had no title). Pure frontend display change.
#798 follow-up. The worker's 500/day budget is a REAL-API ban shield, but
when it was hit the worker paused outright — even for a Spotify-Free user
with the uncapped free source available. So "I'm on Spotify Free" still
got capped overnight. The intuition is right: if it's ever using Spotify
Free, the budget shouldn't apply.
Fix: spent budget now becomes a third "use free" trigger (alongside
no-auth and rate-limited). When the real-API budget is exhausted and the
free source is available, the worker switches to free (uncapped) for the
rest of the day instead of pausing, then reverts to real-first on the
daily reset.
- should_use_free_fallback gains a budget_exhausted arg (free activates on
no-auth OR rate-limited OR spent-budget).
- the worker sets _budget_exhausted_use_free on ITS OWN client (a separate
instance from the search client — verified, so user searches still use
real auth), and clears it when the budget resets; _free_active() honors
the flag.
- get_stats() using_free reports the budget-bridge too, and the dashboard
bubble shows "Running (Spotify Free)" instead of "Daily Limit Reached"
(budgetStuck = exhausted AND not bridging).
A no-free user still pauses on the budget (nothing to bridge to). A pure
free-only worker never increments the budget at all. New gate test pins
the budget_exhausted trigger. Full suite clean.
All retry features introduced by this branch are now strictly opt-in.
With every setting at its default, behaviour is identical to before the
branch — tracks that fail AcoustID/integrity are quarantined and left
for manual review, same as before.
Users who want the new retry pipeline enable it in
Settings → Downloads → Retry Logic.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a file is quarantined (AcoustID / integrity / bit-depth), the source
is moved away and _mark_task_quarantined sets _quarantine_entry_id on the
context. A second post_process_matched_download call with the same
file_path (caused by a monitor re-poll or concurrent dispatch before the
context is cleaned up) then hit the race guard — "source file gone, no
known destination" — and overwrote the in-flight retry with a failed status.
Fix: check _quarantine_entry_id before firing the race guard. If it is
set, the file is legitimately in quarantine and this is a stale duplicate;
return silently so the quarantine retry already in flight can proceed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The existing fallback (pipeline.py:1084) only ran inside
post_process_matched_download_with_verification — i.e. when a file *was*
downloaded and AcoustID retries were fully exhausted. If the retry *search*
itself found zero valid candidates (source returned nothing, or all failed
HiFi validation), the task was marked not_found and the fallback was never
reached, even though the quarantine already held N version-mismatch entries.
Fix: add try_version_mismatch_fallback to TaskWorkerDeps; in the "no valid
candidates" path of task_worker, invoke it before marking not_found when
is_quarantine_retry. Wired in _build_task_worker_deps via a new helper
(_try_version_mismatch_fallback_for_worker) that calls
try_accept_version_mismatch_fallback directly with the track's title and
artist and a reprocess lambda over _post_process_matched_download_with_verification.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Reinstate the Soulseek dependency (quality profile only affects Soulseek
downloads) that was dropped while fixing the empty-tile bug. Gate the whole
collapsible tile (#quality-profile-tile) as a unit instead of the inner group,
so it fully shows (Soulseek active + downloads tab) or fully hides — no empty
expandable shell. switchSettingsTab runs updateDownloadSourceUI after the
data-stg tab filter, so this gate is authoritative on tab switches.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Some tracks don't exist on the sources in the wanted cut — every copy is, say,
the instrumental. The retry engine correctly rejects each (version mismatch) and
gives up, leaving the track missing. New opt-in fallback: once a track's AcoustID
retries are fully exhausted, if every quarantined candidate for it failed the
SAME version mismatch (same matched version, e.g. all instrumental) and there are
>= N of them, accept the best (first-tried = oldest = highest-confidence) one.
Safety rules (core/imports/version_mismatch_fallback.py):
- Version mismatches only. Audio/artist mismatches (different recording) and
integrity/duration failures (truncated/wrong file) never participate.
- All qualifying entries must share the same matched version; a mix
(instrumental + live) is ambiguous → no acceptance.
- Re-import bypasses ONLY the AcoustID gate; integrity/duration/bit-depth still
run, so a truncated or genuinely wrong file is never let through here.
- Reuses the existing quarantine approve_quarantine_entry + re-verify dispatch.
Wired at the AcoustID give-up point in the verification wrapper. Two new
post_processing settings surfaced in the Retry Logic tile (default off):
accept_version_mismatch_fallback + version_mismatch_min_count.
Pure decision core + orchestration covered by tests (11). Acceptance logged at
WARNING with track + matched version.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Soulseek-only JS gate fought the settings tab filter for control of the
tile's display: gating the inner group left an empty expandable shell, gating
the wrapper hid the whole tile depending on activeSources/tab timing. Remove
the JS gate entirely and let the tab filter (data-stg="downloads" on
#quality-profile-tile) own visibility — identical to the working Retry Logic
tile. The tile now reliably shows on the Downloads tab.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Quality Profile tile expanded to an empty body: settings.js
updateSourceVisibility toggled only the inner #quality-profile-section
(Soulseek-only + downloads-tab gate), leaving the new collapsible tile's
header/body visible with hidden contents. Wrap the tile in
#quality-profile-tile and gate that wrapper as a unit instead, so the whole
tile shows (Soulseek active) or hides (otherwise) — no empty shell.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Settings reorg (Downloads page):
- Move the retry controls (retry next-best candidate, exhaustive retry,
retries-per-query) out of the Post-Processing tile into a new collapsible
"Retry Logic" tile on the Downloads tab (data-stg=downloads), collapsed by
default. Also decouples them from the post-processing master toggle, which
previously hid them when post-processing was disabled. Config keys are
unchanged (still post_processing.*); settings.js binds by element id so the
DOM move needs no JS change.
- Wrap the existing Quality Profile group in a matching collapsible tile,
collapsed by default.
Sidebar (reduce-effects):
- The perf PR (#793) gated .nav-button hover/active-hover behind
body:not(.reduce-effects), removing the highlight entirely in reduce-effects
mode. Restore the highlight there using only cheap properties (flat
background + border-color; the base already reserves a 1px transparent
border so there's no layout shift) while keeping the expensive gradient /
translateX transform / multi-layer box-shadow off.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cross-script alias bridge (#442/#586) silently returned [] for some
artists ("Sawano Hiroyuki"). Root cause: the mb-only escape — built exactly
for the case where local string similarity is ~0 (romaji↔kanji) but MB's own
score is decisive — inspected scored[0], the COMBINED-score leader. When an
unrelated same-script decoy outranks the real artist on combined score (decoy:
sim 0.82 + mb_score 83 → combined 0.82, just under the 0.85 bar; real '澤野弘之':
sim 0 + mb_score 100 → combined 0.30, sorted last), the gate saw the decoy's
mb_score 83 (< 95), failed both paths, and cached an empty alias result.
Verification then scored the kanji artist 0% against the romaji expected name
and quarantined every correct file.
Evaluate the MB-SCORE leader independently of combined ranking for the mb-only
escape, and pull aliases from whichever entity actually passed (combined leader
for the combined path, MB-score leader for the mb-only path). The unambiguity
check now compares the top two raw MB scores. Same-script and single-result
paths are unchanged (regression-guarded by the existing #442/#586 tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cached-first retry (8d98b755) abandoned a source after a single query:
the first run returns as soon as ONE query starts a download, so
cached_candidates held only that query's results. On a quarantine retry the
whole source was then excluded from re-search (via searched_sources), so the
later queries (e.g. "artist + album") never hit that source again — it jumped
to the next source after one query instead of exhausting all queries per
source.
Track searched QUERIES (searched_queries) instead of whole sources. A
quarantine retry now skips only the already-run queries (their candidates are
walked via cached-first) and still searches the not-yet-run queries against the
same source. Budget-exhausted sources (exhaustive mode) stay excluded, so the
source switch still fires when a source is genuinely spent.
Removes the now-dead searched_sources state (written but no longer read).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each AcoustID/integrity quarantine retry re-ran the FULL search (all queries,
all sources) before picking the next-best candidate — so a track that failed
verification a dozen times re-queried Soulseek a dozen times (~3 min/cycle in
the field). The next-best pick was already sitting in cached_candidates.
Now the monitor flags the re-queue as a quarantine retry; the worker walks the
already-found candidates first (skipping used + budget-exhausted sources) and
hands them straight to the download path — no search. A source is searched
exactly once: once its candidates are cached, later quarantine retries exclude
it (searched_sources) so the hybrid chain falls through to a not-yet-searched
source instead of re-querying the spent one. Fresh downloads and the monitor's
dead-connection/stuck retries clear searched_sources and search fresh, so the
only re-search is for a genuinely new source or a dead peer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In exhaustive retry mode, a source that spent its whole per-source budget
(query_count × retries_per_query) gave up and failed the track outright —
never trying the other configured sources. For tracks where Soulseek has a
deep pool of wrong peers (e.g. an AcoustID title mismatch every copy shares),
the budget tripped long before HiFi/Tidal/… were ever reached.
Now, when a source's budget is spent, the monitor marks it exhausted on the
task and re-queues so the worker excludes it from the next hybrid search,
falling through to the next source in the chain. Each new source spends its
own fresh budget. The task only fails once no fallback source remains (or the
absolute total ceiling trips) — single-source mode still fails immediately,
since there's nothing to fall back to.
task_worker folds the exhausted-source set into both the orchestrator search
exclusion and the hybrid-fallback source list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an opt-in exhaustive mode to the quarantine-retry path. Default
behaviour is unchanged: a single global cap (MAX_QUARANTINE_RETRIES=5).
When post_processing.retry_exhaustive is on, each source gets its OWN
retry budget sized as query_count x retries_per_query. Soulseek peers
collapse to one 'soulseek' bucket; streaming plugins keep their name.
The worker now records query_count on the task; the budget scales with
the track's real query count. Loop protection is threefold: per-source
cap, used_sources exhaustion (the natural terminator), and an absolute
ceiling (MAX_TOTAL_QUARANTINE_RETRIES=100).
New settings (config + WebUI): retry_next_candidate_on_mismatch (master),
retry_exhaustive, retries_per_query (default 5).
Tests: 6 new cases covering per-source budgeting, source separation,
Soulseek-peer bucketing, query_count default, and the absolute ceiling.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a downloaded file is quarantined because AcoustID verification or the
integrity/duration check fails, the task no longer dead-ends as failed — it
re-runs the worker on the next-best candidate, skipping the quarantined source.
Reuses the monitor's existing transfer-error retry machinery (used_sources +
cached_candidates + worker re-dispatch), just triggered from the post-process
verification wrapper's two quarantine branches instead of only on transfer
errors. Universal across sources (Soulseek, HiFi, Tidal, etc.) since all
batch/sync downloads funnel through post_process_matched_download_with_verification.
- monitor.requeue_quarantined_task_for_retry(): marks bad source used, resets
task to searching, resubmits worker. Guards: manual picks, cancelled tasks,
missing source id, and a MAX_QUARANTINE_RETRIES=5 loop cap.
- Opt-out via post_processing.retry_next_candidate_on_mismatch (default on).
- Manual quarantine approve is unaffected (_skip_quarantine_check='all' bypasses
the checks, so no quarantine flag, so no retry).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Users manually match an album to the regular edition, but enrichment/
repair keeps treating it as the deluxe (missing songs, renumbered tracks).
Root cause: an album has TWO identities — the enrichment match
(spotify_album_id, which manual-match sets and the worker already honors)
and a SEPARATE canonical version pin (canonical_album_id, added by #777).
The canonical pin is what track-number repair / reorganize / missing-track
detection actually read, and library_manual_match never wrote it — so it
was resolved independently and landed on the deluxe edition.
(So #777 did NOT solve #758: it added canonical pinning, but manual
matches didn't write the pin.)
Fix: a manual ALBUM match on a canonical-recognised source now also pins
AND locks the canonical version to the chosen release:
- new canonical_locked column (same migration pattern as the other
canonical cols).
- set_album_canonical(..., locked=False) gains an atomic WHERE-clause
guard: an auto write can't overwrite a locked pin; a manual write
(locked=True) always wins. get_album_canonical exposes `locked`.
- library_manual_match pins canonical for album matches via the pure
should_pin_manual_canonical(entity_type, source).
The auto resolve job already skips already-pinned albums, so the lock is
protected on two fronts; the new guard also covers any future
re-resolution. A new manual match still overrides.
18 tests: the pure gate (+ a sync-invariant test vs _ALBUM_ID_COLUMNS)
and the DB lock seam (auto can't clobber a manual lock; manual overrides;
auto-over-auto still works). Additive — locked defaults False, so the
auto path is unchanged unless a manual lock exists. Full suite clean.
#798 follow-up. The Spotify enrichment worker's daily budget and post-ban
cooldown both exist to protect the REAL authenticated API from bans. But
the worker bridges to the no-creds Spotify Free source during a ban (a
different, anonymous path), and those guards weren't free-aware:
- the budget guard slept the worker when the daily cap was hit, blocking
free work for no reason,
- free-served items still incremented the budget counter, draining the
real-API cap with calls that never touched the real API (so you'd
return from a ban with budget already spent), and
- the post-ban cooldown slept the worker even when serving via free.
Fix: compute free_serving = client._free_active() once per loop
(defensively wrapped → False on error → original behavior). When
free_serving:
- skip the daily-budget guard,
- skip the post-ban-cooldown guard,
- don't increment the daily budget.
So the budget is now strictly a cap on REAL Spotify API usage; free runs
unthrottled by it (the free client keeps its own inter-call pacing).
The decision input (_free_active) is already pinned by the gate-model
tests (free True exactly when rate-limited+Free / Free-primary, False when
authed+healthy). Full suite clean (only pre-existing soundcloud /app env
failures remain).
#798 follow-up. When the real Spotify API is banned but the worker keeps
matching via the no-creds Spotify Free source, every status surface still
read the literal rate_limited=True flag and showed "Rate Limited /
waiting Nm" — so the dashboard bubble looked paused/stuck even though the
worker (visible in Manage Workers) was actively matching.
- spotify_worker.get_stats() adds a `using_free` flag: rate_limited AND
is_spotify_metadata_available(). Computed ONLY when rate-limited, where
is_spotify_authenticated() returns False without an API probe, so the
2s status loop pays no quota cost.
- Dashboard bubble (enrichment.js): when using_free, the bubble is
'active', the tooltip says "Running (Spotify Free)" and "Now: X (via
Spotify Free)" instead of "Rate Limited / Waiting Nm". Clicking it
pauses (works) rather than hitting the resume-blocked toast.
- Manage Workers (enrichment-manager.js): status pill shows "Running
(Spotify Free)"; the warning banner is replaced with a calm "matching
via Spotify Free until the ban lifts" note.
The flag flows through both feeds (the /api/enrichment/spotify/status
poll and the WebSocket enrichment:* push) since both serialize
get_stats(). Genuinely-stuck (no-free) workers still show "Rate Limited".
#798 follow-up. The enrichment worker's own loop already bridges to the
no-creds Spotify Free source during a rate-limit ban (its guard checks
is_spotify_metadata_available()). But the resume button's pre-check
(_spotify_resume_pre_check) blocked resume on ANY rate-limit with no
awareness of Free — so a Free-opted-in user who got rate-limited was
locked out of restarting the worker, unable to fall through to the free
API.
Fix: the resume guard now mirrors the worker. Block only when
rate-limited AND nothing can serve (plain auth, no Free) — where resuming
would just sleep out the ban. When Free is available it serves during the
ban (is_spotify_authenticated() is False while banned, so
is_spotify_metadata_available() reports the free source), so resume is
allowed and the worker bridges via Free, then returns to real auth once
the ban lifts. Stays real-API-first; Free is only the bridge.
The rule is pinned in a pure helper should_block_rate_limited_resume()
next to the other gate functions, with 3 tests. Full suite clean (only
pre-existing soundcloud /app env failures remain).
A mis-grouped library track sits under a 'Various Artists' / '[Unknown
Album]' record while the file itself is correctly tagged. Write Tags
reads the DB and stamped that junk over the file — destroying the
correct tags. (Rematching never helped: a match only stores a source-ID
pointer, it never changes the local name/title the writer reads.)
Guard: never replace a real file value with a placeholder. Added at both
seams so preview and write agree:
- build_tag_diff marks such a field protected/no-change (the preview no
longer shows a wrong overwrite, and has_changes reflects reality),
- write_tags_to_file reads the file's current values and skips the
placeholder-over-real fields, preserving the file.
Field-agnostic and direction-safe: the guard fires ONLY when the DB
value is a placeholder AND the file holds a real one. A legitimate value
still writes — including a genuine 'Various Artists' album artist on a
real compilation, where the file has no conflicting real value, so the
guard doesn't fire. Every write_tags_to_file caller writes DB->file as a
correction, so blocking placeholder-over-real is correct for all of them
(the download post-process uses a different path, embed_source_ids).
23 new tests (placeholder detection, the guard fn, build_tag_diff on the
screenshot-#2 scenario, end-to-end FLAC write preserving real values and
still overwriting with real ones). 113 existing repair/retag/tag tests
pass.
The post-scan reconcile previously ran AFTER the worker's 'finished'
signal, which flips db_update_state status to 'finished'. Automations
wait for a scan by polling that status, so they stopped waiting before
the reconcile ran — and the dashboard/Tools card showed "Completed" then
flipped to "Reading file tags…". For incremental scans this was
invisible (sub-second); for a full refresh it was a real gap (a chained
automation would fire minutes before the IDs were filled).
Fix: the worker now routes completion through _emit_finished(), which
runs self.post_scan_hook (the reconcile) FIRST, then emits 'finished'.
The hook is injected by the web layer (it owns path resolution). So:
- status stays 'running' through the reconcile,
- the reconcile pushes its phase ("Reading file tags for N new tracks…")
and per-track progress through the SAME db_update_state callbacks the
scan already uses — so automations, the dashboard card, and the Tools
page all see it for free and wait for it,
- 'finished' is emitted exactly once, AFTER the reconcile — race-free, no
status blip a poll could catch,
- best-effort: a hook exception never blocks 'finished', so a scan can't
get stranded as perpetually 'running'.
Both scan entry points (_run_database_update_task, _run_deep_scan_task)
set the hook before run()/run_deep_scan(); the redundant post-run calls
are removed.
5 ordering tests pin the contract (hook-before-finished, finished still
fires without a hook, hook exception doesn't block finished, hook gets
the worker). Full suite clean (only pre-existing soundcloud /app env
failures remain).
Extends the manual "Import IDs from File Tags" backfill so newly-scanned
files get their embedded provider IDs pulled into the DB automatically —
no button press needed to keep up with new music.
How it works:
- insert_or_update_media_track now returns 'inserted' / 'updated' / False
(truthy-compatible; existing `if track_success` callers unaffected) so
the scan worker can tell a genuinely new row from an update.
- DatabaseUpdateWorker collects the ids it newly INSERTED this run
(self._new_track_ids) across all insert paths (Plex/Jellyfin/deep).
- After run()/run_deep_scan(), web_server calls _reconcile_after_scan(),
which gap-fills embedded IDs for just those new tracks. Runs as a
post-scan pass (the scan loop itself is untouched/fast — the media
server API never exposes these custom IDs, so the file must be read
once regardless; batching at the end keeps it out of the hot loop and
best-effort so it can never abort a scan). A progress phase ("Reading
file tags for N new tracks…") surfaces the full-refresh tail.
Shared engine:
- New reconcile_library() in core does the paging + lazy parent-map
loading (only loads albums/artists actually referenced — cheap when
scoped to a few new tracks) + per-page commits. BOTH the manual button
and the scan hook call it, so there's one tested orchestration, no
duplication. The backfill job was refactored onto it.
Same hardened safety: gap-fill only, atomically guarded against
overwrite, schema-introspected, idempotent. Scoped to new arrivals for
incremental/deep; full refresh re-inserts everything as new (recovering
the IDs a full-refresh wipe destroys).
+10 reconcile tests (reconcile_library scope/idempotency/progress/stop +
the engine). Full suite clean (only pre-existing soundcloud /app env
failures remain).
Files SoulSync (or MusicBrainz Picard) already tagged carry Spotify /
iTunes / MusicBrainz / Deezer / Tidal / AudioDB / Genius / Last.fm IDs in
their metadata. Enrichment workers gate their queues on
{provider}_match_status IS NULL, so reading those IDs back and gap-filling
the {provider}_id + match_status='matched' columns lets the workers skip
the API lookup entirely — big API savings on an already-tagged library.
New manual job in Tools -> Database & Scanning ("Import IDs from File
Tags"): scans every library file, reads embedded IDs, fills any that are
missing in the DB. Background job + progress card, mirroring the
write-tags-batch pattern.
core/library/embedded_id_reconcile.py (pure + tested):
- plan_reconcile(): gap-fill plan for a track + its album + artist. Only
empty id columns are planned; a disagreeing embedded id is a conflict,
never applied.
- apply_reconcile_plan(): one guarded UPDATE per id column —
WHERE id=? AND (col IS NULL OR col=''). The guard makes the fill atomic:
if an enrichment worker matched the same entity between our read and
this write, the UPDATE affects 0 rows instead of clobbering it. Columns
are introspected so a schema missing a provider's columns is skipped.
- reconcile_track_row(): per-track orchestration (id extraction, plan ->
apply, keeping the in-memory parent maps fresh for sibling tracks).
Job hardening: paged track scan (bounded memory), per-page commits (don't
starve concurrent workers), per-file try/finally (one bad file can't abort
the run), counters from real rowcount.
Scope: 19 column-fills across 8 providers. MB *recording* (track) id is
left out (UFID frame the reader doesn't surface; Vorbis key ambiguous) —
MB album+artist are covered. Amazon/ASIN deliberately excluded (ASIN is a
different namespace than the worker's amazon_id). All target columns
verified against the live schema.
Purely additive: new module, two new endpoints, one new Tools card —
no existing behavior changed. 20 unit tests (incl. the concurrency guard).
Full suite clean (only pre-existing soundcloud /app env failures remain).
AcoustID returns a recording's title/artist in their ORIGINAL script
(e.g. "久石譲" for Joe Hisaishi) while SoulSync's expected metadata is
romanized/English. A correct download then fails verification on two
walls: the title can never clear the 0.70 similarity bar cross-script,
and the only skip path that ignores the title required a near-perfect
0.95 fingerprint plus a resolved alias. Result: every non-English
artist trips it. Two complementary fixes, per the reporter's two ideas.
Graceful fix (automatic):
- New pure core/matching/script_compat.py detects when two strings are
in genuinely different writing systems (CJK/Hangul/Cyrillic/Greek/
Arabic/Hebrew/Thai vs Latin). Accented Latin (Beyoncé, Sigur Rós)
stays Latin — no false trigger.
- acoustid_verification.py: when the EXPECTED artist and the matched
artist span scripts AND the artist is confirmed via the existing
MusicBrainz alias bridge, SKIP instead of quarantine, without the
0.95 floor (the 0.80 trust floor already gates the fingerprint).
- Deliberately narrow: keyed on the ARTIST spanning scripts + being
confirmed. A same-script artist with only a cross-script title keeps
the stricter 0.95 floor, so the #607 wrong-file protection (Kendrick
R.O.T.C, low-fingerprint Japanese-title) is untouched.
Per-request toggle (manual escape hatch):
- New "Skip AcoustID verification" checkbox in the download-missing
modal beside "Force Download All".
- skip_acoustid threads request -> batch -> per-track track_info ->
download context (same path as _playlist_folder_mode), landing on
the existing _skip_quarantine_check='acoustid' bypass. No new
mechanism; only the AcoustID gate is bypassed (integrity/bit-depth
still run).
Tests:
- tests/matching/test_script_compat.py — script-boundary cases.
- test_acoustid_skip_logic.py — Joe Hisaishi SKIPs at 0.85; unconfirmed
cross-script artist still FAILs; same-script low-fingerprint still
FAILs.
- test_downloads_candidates.py — toggle injects the bypass; absent
toggle keeps verification.
Full suite: 5169 passed; only pre-existing soundcloud /app env failures
remain. Zero regressions.
Per the cleaner model: the free source only runs for users who explicitly picked
'Spotify Free' — not for every connected user. _free_wanted() is now just
_free_selected() (dropped the has-credentials auto-trigger). So:
- Plain 'Spotify' user, rate-limited -> waits out the ban as before (no surprise
background scraping, no ToS exposure for people who never chose free).
- 'Spotify Free' user, no auth -> free serves.
- 'Spotify Free' user who also connects an account -> official when healthy,
free bridges only during a rate-limit, then switches back.
Rewrote the metadata-source help text as a plain per-source list with a clear
note on how Spotify Free + a connected account interact. Gate tests updated to
pin the opt-in behavior (plain-Spotify ratelimit = no bridge; Spotify-Free
ratelimit = bridge).
The Settings dropdown reverted 'Spotify Free' because _isMetadataSourceSelectable
reads _lastStatusPayload.spotify.free_installed, but that payload comes from the
WebSocket status:update push (_build_status_payload) — which sent the raw spotify
dict. The availability flags were only added to the GET /status endpoint, so the
frontend never saw free_installed and bounced the selection.
Extract _spotify_status_with_availability() (metadata_available + free_installed)
and use it in BOTH _build_status_payload (WebSocket) and get_status (HTTP poll),
so they can't drift. Now 'Spotify Free' is selectable when SpotipyFree is
installed.
Consistency fix: Spotify Free is now its own entry in the metadata-source
dropdown (alongside Spotify / iTunes / Deezer / MusicBrainz) instead of a
side-toggle. Stored as fallback_source='spotify' + spotify_free=true so all
downstream 'spotify' routing and the spotify_* columns are unchanged.
Refined gate model (no toggle):
- Connected user (has credentials) -> official; bridges to free AUTOMATICALLY
during a rate-limit ban (no opt-in needed).
- No-auth user -> must pick 'Spotify Free' in the dropdown; then free serves.
- Never opted into Spotify (no creds, didn't pick it) -> free never runs, so no
surprise scraping. _free_wanted() = has_credentials OR picked-spotify-free is
the guard.
- AUTHED + healthy -> official always; free never opens.
UI: dropdown gains 'Spotify Free (no credentials)' (selectable when the package
is installed — surfaced via status.free_installed, since selecting it is the
opt-in and can't depend on having selected it); load/save map the dropdown value
to the (fallback_source, spotify_free) pair; old checkbox removed.
Gate model pinned by 6 scenario tests (connected/healthy, connected/ratelimited
bridge, no-auth picked, no-auth not-opted-in, package-missing). 117 tests green.
When Spotify Free is enabled, it now also bridges an official rate-limit ban for
authenticated users instead of stalling — search already did this (the gate
opens on no-auth OR rate-limit); this extends it to the enrichment worker.
- spotify_worker: the rate-limit guard now sleeps only when free CAN'T cover
(is_spotify_metadata_available() is False). Purely additive — with Spotify
Free off, that's False during a ban and the worker sleeps exactly as before.
Verified: toggle OFF + rate-limited -> sleeps (original); toggle ON -> bridges.
- Reframed the Settings toggle so connected users know it also covers rate-limits
("Use Spotify Free when Spotify is unavailable or rate-limited").
The official auth path is untouched; free never runs while authed Spotify works
normally.
Surfaces the opt-in Spotify Free source so it's usable end-to-end:
- Settings: 'Enable Spotify Free (no credentials)' toggle that saves
metadata.spotify_free (load + save wired). Clear best-effort/limitations note.
- config-status: adds spotify.metadata_available (configured OR free-available),
keeping the configured flag = has-credentials so the Connections indicator
stays honest. Search source picker shows Spotify when metadata_available.
- status payload: adds spotify.metadata_available; the Settings primary-source
selector now allows picking Spotify when authed OR free-available.
Verified gate composition: OFF by default (no surprise scraping); ON + no auth +
installed -> available & serving; AUTHED -> official always wins (free never
runs); missing package -> gracefully unavailable. JS + integrity + 111 tests green.
Adds an opt-in no-creds Spotify metadata path: SpotifyClient serves SpotipyFree
data (real Spotify IDs) when metadata.spotify_free is enabled AND SpotipyFree is
installed AND there's no real Spotify auth. The data lands in the SAME spotify_*
columns, so the enrichment worker, search, and #775 lookups work UNCHANGED —
they just receive free-sourced data. The worker's only change is its availability
gate.
- core/spotify_free_metadata.py: SpotifyFreeMetadataClient + normalize_artist +
pure gate fns (should_use_free_fallback / should_offer_spotify_metadata /
spotify_free_installed).
- SpotifyClient: _free_enabled (opt-in, default OFF) / _free_available /
is_spotify_metadata_available / _free_active + in-client routing for
search_artists/tracks + get_album/artist/track_details/album_tracks/artist_albums.
- 3 scoped availability gates use is_spotify_metadata_available(): enrichment
worker loop, search resolve_client, watchlist. The ~40 discovery/user-library
sites stay auth-only (no sprawl, no user-data risk).
Authed users are untouched (gate closed when auth healthy). UI surfacing comes
next. Pure gates + normalizer tested; orchestrator resolve test added.
The #799 uniqueness-guard added a source_id_conflict(self.db, ...) call to the
MusicBrainz worker's artist path. Two TestWorkerAliasEnrichment fixtures build
the worker via __new__ and set only .database, not .db, so the new call raised
AttributeError and the artist was marked 'error'. Mirror the third fixture
(which already sets worker.db). Production always sets self.db in __init__ —
test-only gap exposed by the new code path.
A leftover `.sync-tab-server { flex: 1.4 !important }` from the old equal-width
pills tab strip leaked past the brand-chip restyle (its !important beat the
chip's flex:0 0 auto), so the active Server Playlists pill spanned the whole row
instead of fitting its label. Dropped just that declaration — the tab now behaves
like every other chip; its bespoke gradient + the rest of the rule are untouched.
Enumerates all 64 flag combinations and asserts should_rediscover matches the
verbatim pre-refactor inline logic for every case except the one intended fix
(manual_match beating a stale wing_it_fallback). Guarantees the auto Playlist
Pipeline behaves identically post-refactor — no regression.
A manually-fixed mirrored track silently reverted to 'Wing It' after re-running
discovery. Two compounding causes:
- extra_data is MERGED on save (update_mirrored_track_extra_data), and the
manual-fix DB write (web_server.py) didn't clear the prior wing_it_fallback
flag — so a track fixed after being a Wing It stub kept wing_it_fallback=True.
- the Playlist Pipeline pre-scan checked wing_it_fallback BEFORE manual_match
(if/elif), so the stale flag won: the track was re-discovered and, on a miss,
fell back to Wing It — discarding the user's pick.
Fix: extracted the pre-scan gate into core.discovery.manual_match.should_rediscover
(manual_match checked FIRST = authoritative, regardless of leftover flags), and
the manual-fix write now also clears wing_it_fallback/unmatched_by_user. Behavior
is identical for every other branch — only the manual-vs-wing-it ordering changes.
Tested at the seam incl. the exact regression (wing_it_fallback + manual_match
both set -> skip). 227 discovery tests green.
Ships the source-id cleanup to all users: a marker-gated one-time migration in
MusicDatabase init clears any source id (deezer/spotify/itunes/musicbrainz/
discogs/audiodb/qobuz/tidal) shared across differently-named artists — the
enrichment-corruption signature. Same-name cross-server duplicates are left
untouched (DISTINCT-name check). Cleared rows re-derive correct ids on the next
enrichment pass; the now name-guarded workers won't re-corrupt.
Runs once (CREATE TABLE _source_id_dedupe_v1 marker), idempotent, per-column
try/except so a missing column can't abort it. Test forces a re-run and asserts
corruption is cleared while a legit same-name dup survives.
Two complementary fixes to stop distinct artists ending up with the same source
id (the near-name collisions: ODESZA/odessa, Blance/Blanke, Lady A/Lady Gaga,
plus MusicBrainz's combined-score weak matches like Grant/Amy Grant):
- core/worker_utils.accept_artist_match() / source_id_conflict(): one shared,
tested gate. Rejects artist matches below 0.85 (stricter than the 0.80 used
for album/track titles, since short artist names false-positive easily) AND
refuses to store a source id a DIFFERENTLY-named artist already holds. A
same-named holder (one act across two media servers) is still allowed.
- Routed every artist-match worker through it: deezer, qobuz, tidal, discogs,
itunes, spotify (its scorer now uses the 0.85 threshold), audiodb, and
musicbrainz (conflict guard only — its matcher is combined-score, so the
guard is the net that catches its weak-name matches).
Centralizing in worker_utils avoids the copy-paste that let the original
album/track overwrite bug live in four workers at once. 17 new gate tests.
core/maintenance/dedupe_source_ids.py + scripts/dedupe_source_ids.py: find
source-id clusters held by differently-named artists (the enrichment-corruption
signature) and clear the id + match-status on those rows so the now-name-checked
workers re-derive each correctly on the next enrichment pass. Same-name
duplicates (one artist across two media servers) are left untouched.
Dry-run by default; --apply to write. 8 seam tests cover detection (corrupt vs
legit), dry-run safety, apply behaviour, and the no-op case.
The blind 'correct the parent artist's source id from an album/track match'
logic was copy-pasted into four enrichment workers; the Deezer fix only covered
one. AudioDB, Qobuz, and Tidal had the identical bug and would corrupt their own
id columns (and re-corrupt after any cleanup).
All three now gate the correction on a name match between the result's artist
and the parent artist (audiodb reads result['strArtist']; qobuz/tidal thread the
result artist name in from their callers, as Deezer does). Regression tests
cover mismatch-skips and match-corrects for each.
Root cause of the duplicate deezer_id corruption: when enriching an album or
track, _verify_artist_id 'corrected' the parent artist's deezer_id to the
search result's primary-artist id whenever they differed — with NO name check.
For a collaboration/compilation track (e.g. one our library credits to Jorja
Smith that lives on Kendrick Lamar's curated 'Black Panther' album), the result
resolves to Kendrick's album, so Kendrick's id (525046) got written onto Jorja,
Vince Staples, SOB X RBE, etc. — many artists ending up with the same id.
Now the correction only fires when the result's primary-artist NAME matches the
parent artist (the album/track-artist path now mirrors _process_artist, which
already name-checks). Mismatches are logged and skipped as collab/compilation.
Note: this prevents new corruption; existing wrong ids in a library aren't
auto-repaired (per-artist enrichment preserves an existing deezer_id).
After the duplicate-id ambiguity guard, an owned artist reached via a source
link (e.g. Kendrick's Deezer link) fell back to the bare source-only view
instead of the rich library view, because the URL path carries no name and the
duplicated id alone can't be matched.
get_artist_detail now resolves the source artist's name (reusing the #775
link-resolver's per-source artist fetch) when the id lookup is ambiguous and no
name was passed, then retries the library upgrade by name. So an owned artist
lands on the full library view; a genuinely-unowned one still renders
source-only (now with its name pre-resolved). Unique ids are unaffected.
A pasted Deezer artist link (or any Deezer-source artist click) opened the
wrong artist's header: deezer_id 525046 is stamped on 4 library rows (Kendrick
+ 3 others — an enrichment-corruption bug), and the library-upgrade lookup did
WHERE deezer_id=? LIMIT 1, grabbing an arbitrary row (Jorja Smith) while the
discography loaded fresh from Deezer (Kendrick) — a Frankenstein page.
find_library_artist_for_source now detects when a source id maps to >1 library
artist and refuses to guess: it skips the id-based upgrade (still allowing the
name fallback), so the caller renders the source artist directly — landing on
the correct artist. Unique ids are unaffected (no regression).
The underlying enrichment bug that writes one source id onto multiple artists
is separate and still worth a follow-up.
Spotify/Apple/MusicBrainz/Deezer artist links now resolve via each source's
get-by-id (get_artist / Deezer get_artist_info), shaped to the artist card and
rendered as an artist result that opens the artist detail page through the
existing flow. Album/track link handling is unchanged; bare IDs still rejected.
Follow-up to the bare-ID footgun: a bare number like 525046 carries no
source and no entity type, so it resolved to whatever album happened to own
that id (a user pasting Kendrick's Deezer artist id got an unrelated album).
Now the resolver accepts provider URLs (and the explicit spotify: URI) only;
a bare/unrecognized string is rejected and the dropdown surfaces a hint to
paste a full link. URL parsing + album/track resolution are unchanged.
New 'Link / ID' input on the Search page: paste a Spotify / Apple Music /
MusicBrainz / Deezer URL (or a bare ID) and it's looked up directly on the
owning source — no fuzzy search, no scoring.
- core/search/by_id.py: source-agnostic parser (URL domain/path or bare-ID
format -> source,kind,id; numeric IDs fan out, first hit wins) + per-source
get-by-id dispatch + adapters projecting each provider's dict onto the
standard album/track card shape.
- /api/enhanced-search/by-id: thin additive route over resolve_identifier.
- Frontend: dedicated input that adopts the resolved source as active and
renders through the existing dropdown + download/import flow.
Purely additive — existing files are insertion-only; the resolver runs only
behind the new route. 29 seam tests cover parsing, shaping, fan-out, and
not-found.
The album-bundle path COPIES slskd's completed files into private staging (then
on to the library) but never removed slskd's originals, so they piled up in the
download folder. (copy, not move, is correct for the torrent/usenet bundle paths
— those clients keep seeding — so the shared copier can't just always delete.)
Add an opt-in remove_source to copy_audio_files_atomically that deletes each
source ONLY after it copies successfully (never on a failed stage), and set it
for the Soulseek path only. Torrent/usenet keep their originals.
Tests: keeps source by default / removes when requested / keeps on failed copy.
The scoring best-of only helps if the right candidates were returned. File/CSV
titles ('Artist - Title') made the search query carry the artist prefix; add
canonical-title search queries so the correct tracks are actually found, then
the scorer best-of matches them. Additive (extra queries only when the title
canonicalizes differently).
#768 added canonical_source_track to the live-sync matcher and the playlist
editor reconcile, but NOT to the two paths that actually run for file/CSV
mirrored playlists: the discovery worker (core/discovery/playlist.py) and the
DB-only matcher (core/discovery/sync.py). YouTube playlists are cleaned at
ingest, so they matched; file playlists fed the raw 'Arctic Monkeys - Do I
Wanna Know?' title into search+scoring and never matched the library's clean
'Do I Wanna Know?' → reported missing / shown as 'extra'.
Add a conservative canonical best-of to both: score with the raw title AND the
canonicalized one, keep the better. canonical_source_track only strips an
'<artist> - ' prefix when it equals the artist, so it can only add a candidate.
Tests: _canonical_best_score seam (file-style match / clean title scored once /
keeps original when better).
toggleNotifPanel positions the panel inline from the bell's rect (panel.style.
right/bottom). The bell isn't flush to the right edge on mobile, so that inline
right offset + near-full-width pushed the panel off-screen left. The existing
mobile rule set right:12px without !important, so it lost to the inline style.
Now anchor both sides with !important (left+right+width:auto) so it always fits.
The visualizer is fixed at the desktop sidebar's edge; on mobile it floated over
the page content whenever music played, even with the off-canvas sidebar closed.
Hide it unless .sidebar.mobile-open (sibling selector, 3-class + !important to
beat the .active/.viz-* display rules). When the drawer opens it shows again.
The downloads page is a two-column desktop layout (main list + fixed 366px batch
panel) with NO mobile rules at all. Phone-only:
- .adl-layout stacks to a column; .adl-batch-panel goes full-width, swaps its
left border for a top border, and flows in the page (no independent scroll).
- .adl-header + .adl-controls stack so the filter pills get full width.
- .adl-filter-pills wrap instead of overflowing; cancel/clear buttons flex to fit.
- Hide the floating mini-player while the expanded Now Playing modal is open
(it has z-index 99998 vs the overlay's 10001, so it floated over the modal).
General fix (desktop too), via sibling selector on the overlay's open state.
- Artist hero image: drop the max-width:40vw cap on mobile (overrides the base
rule) so the image isn't artificially shrunk.
Existing mobile rules made it full-screen + stacked the body, but left the
desktop layout inside untouched. Phone-only (max-width:768px):
- album art scales (min(220px, 66vw)) instead of fixed 220px
- left/right columns full width; track info, action + util rows centered
- controls row gap tightened to fit a phone
- queue + lyrics panels: drop the 40px desktop side padding that crushed content,
give them a touch more vertical room
All phone-only (max-width:768px), all in mobile.css — desktop untouched.
- artist hero: drop the 100px image-container cap; artist name -> 1.6em centered
block; bio max-height:fit-content; center hero action buttons + match-status
chips (moved here from base rules so desktop stays as-is).
- #6 enhanced-view track table: a 6+ col table clipped to one visible column on
a phone. Drop table layout -> each row is a flex line (play . title . duration
. actions); secondary columns fold into the existing mobile actions sheet.
- #7 mini media player: was pinned at desktop coords (right:132px; width:340px)
and overflowed. Full-width bar sitting just above the bottom global search.
- #8 page heroes (tools-maintenance / watchlist / discover): trim desktop-sized
padding + margins that wasted space on mobile.
- #9 sync header: Auto-Sync / Library Match / Sync History didn't fit; stack the
header + wrap the buttons.
The real path for discovery-modal syncs (Spotify-Public/Tidal/Deezer/Qobuz/
YouTube/iTunes-link/ListenBrainz/Beatport) is _start_source_sync ->
_submit_sync_task, which called _run_sync_task WITHOUT a sync_mode — so it used
the 'replace' default and never consulted the setting. (My earlier fix only
covered /api/sync/start, which these endpoints don't use — hence the log still
showing POST /api/spotify-public/sync/start ... mode: replace.)
_submit_sync_task now resolves the configured default via normalize_sync_mode
and passes it through, so all source syncs honor Settings > Playlist sync mode.
start_playlist_sync validated the resolved mode with 'if sync_mode not in
(replace, append): sync_mode = replace' — a pre-existing clamp two lines below
the config read I added, which silently downgraded a configured 'reconcile' to
'replace'. So config=reconcile resolved correctly then got clobbered, and every
sync ran replace regardless of the setting (and incognito didn't help — it's
backend, not cache).
Replace the hand-rolled clamp with a pure normalize_sync_mode(requested,
configured) helper (VALID_SYNC_MODES includes reconcile) so the resolution is
testable and can't silently drop a mode again. Regression tests cover
reconcile-from-config, request-overrides-config, and unknown->replace.
The reconcile setting never took effect: startPlaylistSync always sent
sync_mode (defaulting to 'replace' from the per-playlist <select>) AND clamped
any non-replace/append value back to 'replace' — so 'reconcile' could never be
sent and the global Settings value was always overridden. The per-server Plex
reconcile code was never even reached; replace ran and re-pushed the poster.
- Per-playlist select now defaults to 'Sync mode: default' (empty) which defers
to Settings > Playlist sync mode, and gains a 'Reconcile' option for an
explicit per-sync override.
- startPlaylistSync sends '' (not 'replace') when no explicit choice, so the
backend uses the configured default; clamp now allows reconcile.
(Other callers already sent no sync_mode, so they pick up the setting too.)
Replace mode (default) deletes + recreates the server playlist every sync,
which wipes its custom image, description, and identity. Add an opt-in
'reconcile' sync mode that edits the existing playlist in place — adds the
tracks now in the source, removes the ones gone — without destroying the
object, so the user's custom art/description survive.
- Pure planner plan_playlist_reconcile(current, desired) -> {add, remove}.
- Per-client reconcile_playlist: Plex addItems/removeItems on the same object;
Navidrome Subsonic updatePlaylist delta (songIdToAdd / descending
songIndexToRemove); Jellyfin add + remove-by-PlaylistItemId on /Playlists/{id}/Items.
- sync_service: reconcile branch with a replace FALLBACK (if a server's in-place
edit is unavailable/fails, sync still succeeds destructively — logged loudly).
- Default stays 'replace' (no behavior change). New Settings > Playlist sync mode
picker (replace/reconcile/append) backed by playlist_sync.mode; per-request
sync_mode still overrides.
- Reconcile skips the post-sync source-image push so a custom poster isn't
re-clobbered (the bug).
Tests: planner (add/remove/dedupe/order/empty) + reconcile-or-replace dispatch
(success / false-fallback / exception-fallback / no-method). Per-server in-place
API calls need dev validation against real Plex/Jellyfin/Navidrome.
NOTE: opt-in only; default behavior unchanged.
Consistency follow-up: the Filler picked art via metadata source-priority +
its own prefer_source knob, ignoring metadata_enhancement.album_art_order —
the explicit cover-art source list that post-process embed and the Library
Re-tag job now use. So 'cover art sources' meant two different things.
Prefer select_preferred_art_url (the configured order) first; fall back to the
existing prefer_source / source-priority loop when no order is configured
(the default) — non-breaking, existing behavior unchanged for those users.
Help text updated. Test: configured order wins + skips the source loop.
User feedback (Sokhi): after changing cover-art sources, re-tag should
re-download fresh covers from THEM. The job took cover_url only from the
matched metadata source's album image, ignoring the user's configured
cover-art order. Now prefer select_preferred_art_url (the same
metadata_enhancement.album_art_order the post-process embed honors), falling
back to the source image when no order is configured (non-breaking).
'replace' cover mode already force-refreshes art on every matched album, and
the embed replaces existing art (no duplicate pictures) — so 'replace' + a
configured art order = fresh covers from those sources. Help text updated.
Tests: prefers configured source URL / falls back to source image when unset.
Harden the previous fix: setPlayingState(true) misses resume/play calls that
bypass it (lines that just do 'if paused, play()'). Move the resume onto the
audio element's 'play' event, which fires on every playback start regardless
of code path. Keep the resume in npInitVisualizer for the first-play case
(context is created suspended after the 'play' event already fired). Drop the
now-redundant setPlayingState hook.
The visualizer calls createMediaElementSource(audioPlayer), which permanently
reroutes the shared <audio> element's output through npAudioContext. That
context is created from an async play().then() callback (outside the user
gesture), so browsers start it 'suspended' under the autoplay policy — and the
only resume() lived in the visualizer loop, which runs when the Now Playing
modal opens, NOT on play. Result: the element advances (looks like it's
playing) but its audio drains into a suspended context = no sound, everywhere.
Add npEnsureAudioContextRunning() and call it on every play start
(setPlayingState(true)) plus right after the context is created in
npInitVisualizer. Resuming an already-running/absent context is a safe no-op.
The mlm_save endpoint already fetches+validates the library track, so capture
its file_path and persist it on the manual match. This makes matches created
via the Manual Library Match tool re-resolvable after a rescan re-keys the
track (#787), matching the durability the Find & Add path now has.
Find & Add on the playlist-sync page only wrote sync_match_cache, which is
DELETEd wholesale after every DB scan — so the source->library pairing (and
the user's manual matches) reverted to 'extra'/red-dot on the next shallow
scan. The three match stores (sync_match_cache, manual_library_track_matches,
discovery extra_data) were disconnected and all pointed at tracks.id, which a
rescan re-keys (esp. Jellyfin/Navidrome GUIDs).
Unify the match so it's one durable fact, recorded once, honored everywhere:
- Find & Add also writes a durable manual_library_track_matches row (one-way;
the manual-match tool has no playlist to act on, so no reverse). Carries the
library file path.
- New library_file_path column (idempotent migration) + find_track_id_by_file_path:
re-resolve a stale library_track_id after a rescan re-keys the track, and
self-heal the row.
- The sync compare display's override lookup now falls back to the durable
manual match (resolve_durable_match_server_id) when sync_match_cache misses —
so the pairing persists across a scan instead of reverting to a red dot.
Purely additive: only adds matches when the cache returns nothing.
Tests: durable resolver (valid / stale-reresolve+self-heal / no-match / not-in-
playlist / missing-methods), file_path persistence + find_track_id_by_file_path.
Password managers (Bitwarden/1Password/LastPass) treat this app's many API-key/
token/secret fields as login forms and re-scan the whole, constantly-mutating DOM
on every change — pegging the main thread for seconds and making hover/click/
scroll feel laggy. Two mitigations (measured to make the app usable with the
extension enabled):
- Tag all inputs with data-bwignore / data-1p-ignore / data-lpignore so the
managers skip them (no autofill detection work).
- Rate-monitor equalizer: skip DOM writes while it's off-screen (offsetParent
null). All pages stay mounted, so updating the hidden grid still triggered the
managers' MutationObserver on every backend rate-monitor event for no benefit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-on to 07801aeb (the orphaned-file delete committed alone because the
git add aborted on the already-removed pathspec). Removes the two RetagDeps
tests in test_lyrics_reembed_from_sidecar (the dataclass was deleted with the
old Retag Tool) and a no-placeholder f-string in test_library_retag_job.
Removing the old Retag Tool (d91e6a38) deleted core/library/retag.py but left
its tests behind. tests/library/test_retag.py imported the gone module at
module top, so pytest aborted collection of the ENTIRE suite (CI red on every
run). tests/test_lyrics_reembed_from_sidecar.py had two RetagDeps tests for the
same removed dataclass (lazy imports — would fail at runtime once collection
proceeded).
Delete the orphaned test file + drop the two dead RetagDeps tests (the rest of
the lyrics-sidecar file is unrelated and stays). Also drop a pointless
f-string in test_library_retag_job. Suite now collects all 5008 tests.
A bare host like '192.168.1.5:8080' or 'qbittorrent.lan:8080' (no scheme)
is what users naturally type, but requests then raises 'No connection
adapters were found for ...' — it can't pick an http/https adapter, and a
bare host:port even gets misparsed as scheme=host. This surfaced as the
generic 'qbittorrent probe failed' with a 'login error: No connection
adapters were found' in the logs.
Add normalize_client_url() in torrent_clients/base: default a missing scheme
to http:// (+ trim trailing slash), and route all three adapters'
_load_config through it. Transmission normalizes the base before appending
/transmission/rpc.
Tests: normalizer unit cases + per-adapter regression (bare host -> http://).
Note: usenet adapters (sabnzbd/nzbget) share the same pattern and need the
same treatment in a follow-up.
Follow-up hardening to #789. The selection was keyed purely by folder name,
so renaming a music folder in Navidrome silently reverted the scan to all
libraries. Now persist the folder id (stable across renames) as the primary
key alongside the name (kept for display + back-compat), and restore by id
first with a name fallback. Self-heals on reconnect: pre-id installs and
drifted/renamed names get the id + fresh name written back, so the settings
dropdown keeps highlighting the right folder.
Tests: restore-by-id-after-rename (+ name heal), name-fallback self-heals id,
no-drift writes nothing.
The saved music-folder selection was silently dropped on every reconnect.
_setup_client's restore step called the public get_music_folders(), which
starts with ensure_connection() — but we're already inside ensure_connection()
at that point (_is_connecting=True, _connection_attempted not yet set), so the
re-entrant call bailed and returned []. The restore matched nothing,
music_folder_id stayed None, and the per-call musicFolderId filters all
no-op'd → scans imported every library regardless of the user's choice.
Surfaces after any restart or settings save (reload_config resets the state).
Split get_music_folders() into the public method (does the connection check)
and a non-reentrant _fetch_music_folders() seam; the restore now calls the
seam directly (connection is already established + ping succeeded by then).
Regression + seam tests added.
Two measured, universally-beneficial fixes (kept after determining the rest of
the earlier perf work was chasing a Bitwarden extension that pegged the main
thread, not real app bugs):
- .main-content had a linear-gradient background. A gradient on the scroll
container is re-rastered across the whole scrolled area every scroll frame
(the compositor can't translate a cached tile): ~25% dropped frames -> <1%
once flattened to a solid color (visually identical, was rgb 10->15->11).
- The explorer wheel-zoom listener was a non-passive listener on `document`,
which disables compositor (async) scrolling app-wide so every wheel/trackpad
scroll runs through the main thread. Scoped it to the explorer viewport.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- depth setting (light = core tags + matched source ids; full = same
multi-source enrichment cascade a fresh download gets, run additively
via embed_source_ids). Threaded through scan/finding/auto-apply and the
repair_worker fix handler.
- source now defaults to 'auto' (= your source priority / active source)
instead of blank.
- give native <option> popups a solid dark background (were white-on-white).
- tests for full-depth full_meta payload + enrich invocation + light no-op.
The job was the odd one out — auto_fix=False, no dry_run setting, so it never
showed the 'Dry Run' badge the other jobs do (the badge keys off
settings.dry_run === true). Aligned it to the standard pattern:
- auto_fix=True + dry_run setting defaulting True. Default behavior is unchanged
(findings only, nothing written) AND it now shows the Dry Run badge.
- Turning dry_run off makes the scan auto-apply in place (result.auto_fixed),
no finding — the opt-in 'just retag it' mode.
- Extracted a shared apply_track_plans() used by both the scan auto-apply and
the repair_worker fix handler (handler now resolves Docker paths then
delegates — one code path, no duplication).
Tests: dry_run=False auto-applies + writes + no finding; existing dry-run
finding/skip/apply tests still green. 410 passing.
Two fixes:
- The retag-tool-card removal accidentally ate the </div></div> that closed the
Metadata & Cache grid + section, so the Management section nested inside it.
Restored the close — Management is a sibling section again. (div balance back
to 1998/1998.)
- Moved the Metadata Updater card from 'Database & Scanning' into 'Metadata &
Cache' where it belongs.
Closes the kettui gap — the orchestration was unproven. Injected-fake seam
tests (temp sqlite + real empty track files, no metadata APIs / no real tag
writes):
- embed_known_source_ids: builds the right canonical id_tags from flat db keys,
honors the musicbrainz embed gate, no-ops when there's nothing to write.
- library_retag scan: produces a detailed finding with the per-track old->new
diff + stamped source ids, and skips an album that's already correct.
- _add_source_ids: per-source key mapping.
- _fix_library_retag apply: writes each track's payload, and reports failure
when files are unreachable.
476 tests pass; ruff clean.
The old per-download Retag Tool was limited (only native-pipeline downloads,
100-group cap, manual per-group) and did the wrong thing — it moved/reorganized
files instead of just tagging. It's superseded by the new Library Re-tag job
(whole-library, in-place) + the enhanced-library 'Write Tags' button.
Removed: the post-download record_retag_download ingestion hook (stops writing
retag_groups on every download), core/library/retag.py, the web_server state +
deps + /api/retag/* endpoints + the tool:retag WebSocket emit, the dashboard
card + both modals (index.html), the core.js socket handler, and the tools-page
wiring + help entry (wishlist-tools.js). Updated the import-pipeline test.
Verified: web_server parses, app + core imports OK, 392 tests pass, no live
references to removed symbols.
Left as inert (harmless) for a careful follow-up sweep: the retag_groups/
retag_tracks tables + their DB CRUD methods (no longer written/read), and the
now-orphaned retag JS helper functions (no entry point/wiring/socket calls them;
interspersed with wishlist functions, so not blind-deleted).
write_tags_to_file wrote the core fields + cover but never the source IDs
(Spotify/iTunes/MusicBrainz) the import post-process embeds. Added a focused
source.embed_known_source_ids() that writes ALREADY-KNOWN ids (from db_data)
via the canonical, Picard-compatible frame writer the import uses
(_write_embedded_metadata) — no API re-fetch, frames correct by construction.
write_tags_to_file now calls it whenever db_data carries id keys.
Fed from both paths: the enhanced-library 'Write Tags' button now carries the
track's spotify/itunes/musicbrainz ids, and the Library Re-tag job stamps the
matched album/track source ids onto each track. So both now write the full tag
set, not a subset.
Wire library_retag into the repair findings UI: a 'Re-tag' type badge, an
'Apply Tags' fix button, and an expandable detail that shows, per track, every
tag that would change as old -> new (plus source/mode/cover-action summary and
any unmatched tracks). So the dry-run finding is actually reviewable before you
apply it — the rich details_json the job stores now surfaces in the card.
New 'Library Re-tag' repair job (default-OFF, opt-in; weekly when enabled):
- Scans every source-matched album (spotify/itunes/deezer/musicbrainz album id),
pulls fresh metadata + tracklist from that source, reads each local track's
current tags, and uses the planner to compute per-field diffs.
- Dry-run by design: scan only CREATES findings — nothing touches a file. Each
finding is highly detailed: per-track old->new for every changed field, the
source used, the mode, a cover-art action, and any unmatched tracks, plus a
summary description. Settings: mode (overwrite | fill_missing), cover_art
(replace | fill_missing | skip), source override.
- Apply handler (_fix_library_retag in repair_worker): writes each track's
planned tags in place via tag_writer.write_tags_to_file (+ batch-embeds cover,
refreshes cover.jpg). Only adds/overwrites planned fields — no moves/renames/
re-match. Resolves Docker paths; read-only/unreachable files counted, never
crash. Media-server-only / unreachable tracks are skipped.
Registered in the job list + fix dispatch. The old per-download Retag Tool is
left untouched alongside this for now.
The testable core for the new library-wide re-tag job. Given a source album's
metadata + tracklist and the library tracks' current file tags, it:
- matches source tracks to library tracks (disc+track number, then title sim),
- computes the per-field diff (old -> new) for the dry-run finding,
- builds the minimal write_tags_to_file payload — only fields that actually
change under the chosen mode (overwrite vs fill-missing), so applying never
touches unrelated/unchanged tags.
No IO/network/DB — 10 unit tests cover matching, both modes, blank-source
fields, and the album-artist/track-count payload mapping.
The version-button modal renders from VERSION_MODAL_SECTIONS (the curated
highlight reel), separate from the WHATS_NEW detailed log. Its top entries were
stale (2.5/2.6.0 era), so promote the 2.6.6 highlights to the top per the file's
release process: Artist Map v2, self-explaining recommendations, the cover-art
filler file-embedding, and a Recent Fixes & Performance roundup (qBittorrent
5.2.0, organize-by-playlist #780, nav/scroll perf #783, dashboard mobile).
- Bump _SOULSYNC_BASE_VERSION 2.6.5 -> 2.6.6 (the single source of truth that
propagates to the UI, backups, and the update check).
- Add the 2.6.6 What's New block (qBittorrent 5.2.0 login fix, Cover Art Filler
on-disk detection + file embedding + stricter matching, recommendations
explainability + Discover section, organize-by-playlist #780, nav/scroll perf
#783, dashboard mobile polish).
- Finalize the 2.6.5 block: it shipped in tag 2.6.5 but was left flagged
unreleased (so its notes never displayed) — stripped the flag + dated it per
the file's own release convention.
Verification found a non-additive edge: embed_album_art_metadata uses FLAC
add_picture(), which APPENDS — so applying to an album where some tracks already
had art would have added a duplicate embedded picture. The apply now checks each
file and skips any that already carry art (shared _audio_has_art helper), so it
only ever ADDS art to files missing it. Test covers the skip (no re-embed).
Previously the filler only flagged albums whose DB thumb_url was empty and, on
apply, only updated that DB thumb_url — so albums whose files had no embedded
art and no cover.jpg (but whose DB row had a URL) were never found, and even
'applying' art never touched the files. That's the reported 'doesn't scan all
albums' gap.
New core.metadata.art_apply (reuses the post-processing standard so the user's
album_art_order is honored):
- album_has_art_on_disk(): cheap-first check — folder cover.jpg/folder.jpg
sidecar, then embedded art in a representative track (FLAC/ID3/MP4/Vorbis).
- apply_art_to_album_files(): embeds via embed_album_art_metadata + writes
cover.jpg via download_cover_art; only ADDS art (never rewrites the user's
tags); read-only/unwritable files are skipped + counted, never crash.
Scan now examines every titled album and flags it when art is missing in the DB
OR on disk. Apply embeds into the album's audio files + writes cover.jpg in
addition to the DB thumbnail (media-server-only albums fall back to DB-only).
Tests cover sidecar/embedded detection, the cheap-first short-circuit, and the
apply orchestration (embeds each file + cover.jpg; read-only failures counted).
The title/artist fallback search took results[0]'s artwork unconditionally, so
a loose full-text match returned the wrong album's cover (the 'new sources give
incorrect art' reports). Now it pulls a few results and only accepts one whose
title matches (subset, to allow Deluxe/Remaster) AND whose artist matches
exactly — the artist being the strong guard against wrong covers. Falls back to
an exact title match when a result carries no artist.
The album's own stored source-id path is unchanged (that id is authoritative).
Tests: wrong-artist rejected, skips wrong result for a matching one, + unit
coverage of the matcher (deluxe/feat/stopwords accepted, wrong artist/title
rejected).
qBittorrent 5.2.0 changed /api/v2/auth/login to return HTTP 204 (No Content)
on success instead of HTTP 200 with body 'Ok.'. The adapter required the body
to equal 'Ok.', so every login on 5.2.0+ failed with 'HTTP 204 body=' — the
connection probe and all torrent actions were broken.
Treat login as successful on the SID auth cookie and/or a success body: 'Ok.'
(<=5.1) or an empty HTTP 204 (>=5.2.0). Still reject bad creds, which
qBittorrent reports as HTTP 200 + 'Fails.' (not a 4xx).
Tests: 204-empty -> success, SID-cookie+empty-body -> success, 'Fails.' (even
with a stale cookie) -> failure.
The #rate-monitor-section equalizer had breakpoints but two narrow-bar gaps:
- The status pill ("Not configured", "Yielding") is wider than a thin
equalizer column and spilled over neighbours — now capped to the bar width
with the label truncating via ellipsis.
- Wrapped rows were left-aligned (orphan bar stranded) with no vertical gap —
now centered with a row-gap so multi-row layouts read intentionally.
Plus smaller value/name fonts at <=480px so tiny bars stay legible.
PR #783 reordered transports to websocket-first for faster connects. Reverting
to the polling-first default: it's the most compatible behind reverse proxies
that don't forward WebSocket upgrade headers (common self-hosted setups), where
websocket-first silently breaks real-time updates. The connect-time gain isn't
worth the connectivity risk. Everything else from #783 (scroll-pause, content-
visibility, dashboard parallelization, settings fixes, reduce-effects) kept.
- Stale-cache check (playlistTrackCacheIsStale) compared raw track_count to the
filtered/cached track list, so any playlist with local or unavailable tracks
always looked 'stale' and refetched + re-mirrored on every modal open. Now it
compares the upstream snapshot_id (stored at cache time in the shared fetch
choke point), and returns not-stale when no snapshot is available — explicit
invalidation on refresh still handles real changes.
- organize_download: guard executor.submit so a refused job cleans up the batch
instead of stranding it in 'analysis' (holding a limited analysis slot).
- Removed the dead, deprecated, unused mirrorSpotifyPlaylistTracks.
resolve_mirrored_playlist tried the mirrored-playlists primary key FIRST for
any all-digit ref. Deezer upstream ids are all-numeric, so a Deezer playlist id
was mistaken for the PK and the organize-by-playlist toggle resolved a wrong row
(or nothing) — the toggle silently wouldn't save / 'Open in Mirrored' missed.
Resolve by (source, source_playlist_id) first, fall back to PK only when the
source lookup misses. Thread the batch/wishlist source through the download-path
callers so numeric upstream ids resolve correctly there too. Spotify (base62
ids) is unaffected.
Seam tests: numeric Deezer id resolves by source (not PK), spotify alphanumeric
by source, PK fallback still works, profile-scoped, empty refs -> None.
The error/health state was jarring: a red ring flickering at sin(time*12) plus
stress speeding up the heartbeat, which read as the whole glow flickering. Now
it's all gradual: stress no longer changes the heartbeat speed, the red tint is
softened (never full alarm-red) and eases in/out via a small accumulating
errorHeat bump + smooth decay, and the warning ring is a single soft ring that
breathes slowly (sin*1.4) at low alpha instead of strobing.
On mobile, worker-orbs is disabled so the enrichment buttons render as real
buttons. They were a ragged centered flex-wrap with the wide 'Manage Workers'
pill jammed inline. Now (<=768px, scoped to #dashboard-page so Settings etc.
are untouched): the 44px icon buttons spread evenly across the full width in an
auto-fit grid, and the Manage Workers pill gets its own full-width row.
The expanding heartbeat ring read as a heavy circular pulse. Now: the nucleus
barely breathes (size oscillation cut ~70%), the glow holds steady instead of
pulsing, the logo no longer visibly throbs, and the heartbeat ring is a single
very-faint halo that only appears when workers are actually busy. The red
error-warning ring is unchanged (still punchy, since it only fires on real
failures).
- New 'Recommended For You' carousel section on the Discover page (between the
hero and Your Artists), so recommendations aren't buried behind a hero modal
button. Reuses the recommended-card markup/CSS, the watchlist add handler, and
primes the modal cache so 'View All' opens instantly in sync.
- Re-frames the now-stale copy: recommendations are library-wide (the similar-
artists worker feeds the whole library), not watchlist-only.
- Shows the real explanation from the backend's 'because' field —
'Because you have X & Y' (with a full-list hover tooltip) instead of just a
count — in both the section cards, the modal cards, and the hero subtitle.
- Cards lazy-enrich their images via the same endpoint the modal uses.
Adds get_recommendation_sources() — for each recommended similar artist it
resolves the polymorphic similar_artists.source_artist_id back to the display
names of the user's OWN artists (library + watchlist) that list it, by matching
against every provider-id column on both tables. The /api/discover/similar-artists
endpoint now attaches a 'because' array per recommendation so the UI can show
'because you have X, Y, Z' instead of just a count.
Seam tests cover: library + watchlist resolution across different provider-id
columns, dedup + name-sort, max_per cap, orphan source omission, profile scoping.
The hub now reads as a health gauge on top of the activity gauge. A new
decaying errorHeat (0..1) is bumped by onStatus whenever a worker reports a
real error increment, and cools over ~6s. While stressed the nucleus blends
toward red, its heartbeat quickens (agitation), and a fast-flickering red
warning ring appears — so a glance distinguishes 'busy and healthy' from
'something's actually failing'. Since 404s are classified as not_found now,
this only lights up on genuine failures (timeouts, 5xx).
The worker's WARNING observability proved the '38 errors' were almost all
MusicMap returning 404 (artist has no map page) — a genuine not-found, not a
fetch failure. But iter_musicmap_similar_artist_events flattened every
RequestException to status_code 502, and the worker maps 400/404 -> not_found
/ everything-else -> error, so these inflated the error count.
Surface the real HTTP status from the exception's response (404 stays 404),
falling back to 502 only when there's no response (timeout/connection drop,
which is correctly still an error eligible for retry).
Regression tests: 404 -> 404 (not_found), timeout -> 502 (error), 500 stays
error, plus an end-to-end worker check that a 404 result marks 'not_found'
and stores nothing.
Status pushes land every ~2s, so the previous fixed 'drain 2/frame' fired a
whole window's worth of pulses in a fraction of a second then went quiet.
Now each orb sets a release rate when a status arrives (pending / ~2s, with
a floor so a lone event still shows within ~0.75s) and the loop drips pulses
out via a fractional accumulator — so a busy worker streams a steady line up
its spoke and a slow one sends the occasional single pulse.
The inbound pulses are now event-driven instead of a random trickle:
- core.js forwards every enrichment:<id> WebSocket status to a new
window.workerOrbs.onStatus hook (extra listener, UI handlers untouched).
- onStatus diffs the cumulative stats counters (matched/not_found/repaired/
synced/scanned, and errors) between pushes and queues one pulse per real
item processed (worker's brand colour) or error (red). First sample only
sets a baseline so we never dump the whole backlog at once.
- tick() drains a couple of queued pulses per frame so bursts stagger up
the spoke; cap of 8 queued per update prevents flooding on big jumps.
- Falls back to the old ambient trickle for any orb that hasn't received a
status yet, so nothing goes dead if the socket is quiet.
Bonus perf: an idle/slow worker now emits almost nothing instead of a
constant random stream of particles.
Navigation & sidebar feedback:
- Show legacy pages optimistically on pointerdown + CSS :active so the
sidebar reacts instantly instead of waiting for the click/router cycle.
- Defer heavy per-page init via requestIdleCallback so a page becomes
scrollable before its init work runs.
Scroll smoothness:
- Cache particle canvas dimensions (no forced reflow per navigation).
- Pause particle + worker-orb canvas redraws during active scroll so the
scroll gets the full frame budget.
- content-visibility:auto on discover shelves and search/wishlist/library
list items to skip off-screen layout.
Dashboard:
- Run the independent initial loads in parallel (Promise.all) instead of
six sequential awaits, collapsing the reflow cascade.
Settings:
- Wire input listeners once instead of rescanning the ~960-node subtree
on every visit.
- Suppress auto-save while the form is programmatically populated on load,
fixing a spurious full save (4 POSTs + backend service re-init) that
fired on every Settings visit.
Reduce Visual Effects = full performance mode:
- Also halts particles, worker orbs and all filters; hides the static
sidebar aura circles that looked broken without their blur/animation.
Global search bar hidden on settings/help/issues/import pages.
Performance:
- Bake one soft glow sprite per colour into an offscreen canvas and blit
it with drawImage instead of allocating a radial gradient every frame.
This was the hot path: sparks + inbound pulses + every orb glow each
built a gradient per frame (100+/frame at 60fps). Colours quantised to
8-step buckets to bound the cache (imperceptible tint shift, keeps the
rainbow path from minting a sprite every frame).
- Cache each orb's button element at init so the 30-frame active-state
check no longer re-runs querySelector.
- Net: the pulses/glows look identical, far fewer allocations per frame.
UI:
- Enrichment manager modal topbar icon now uses the SoulSync logo
(trans2.png) instead of the helix emoji, matching the dashboard button.
- Nucleus logo now fits to the pulsing radius using the image's natural
width/height, so it no longer stretches to a square.
- Manage Workers button swaps the helix emoji for the SoulSync logo
(trans2.png) inside the existing accent badge.
The Manage Workers hub now draws /static/trans2.png (the SoulSync mark)
at its center instead of a plain colored core, scaled to the pulsing
radius and brightening slightly with energy. Energy-reactive glow, rings
and inbound pulses still surround it. Falls back to the drawn core while
the image loads.
Three upgrades to the Manage Workers nucleus:
- Energy-reactive: hub size, glow, heartbeat speed and ring count all
scale with how many workers are actually running. Calm + dim when idle,
big/bright/fast with 1-3 radiating rings when busy. The animation now
reads as a live gauge.
- Inbound pulses: active workers fire colored particles along their spokes
into the hub, so it visibly collects their output (eased to accelerate
on arrival; cleared on collapse so they don't snap).
- Orbital rotation: worker orbs get a tangential nudge around the nucleus
so the cluster slowly revolves like an atom instead of drifting randomly
(active orbs orbit a touch faster).
The 'Manage Workers' orb now acts as a central nucleus instead of just
another drifting particle:
- Settles at canvas center with strong pull + heavy damping (no jitter)
- Drawn larger and brighter with a slow breathing pulse, white core
highlight, and an expanding heartbeat ring
- Wired to every worker orb with full-length spokes (a traveling pulse
runs along each), so it visually reads as the center managing the cluster
- Other orbs repel off it, leaving a clean halo around the nucleus
The screenshot said it all — the orbs collapse into the floating particle cluster
after 7s idle, but the Manage Workers pill just sat there static. It wasn't in
worker-orbs.js's WORKER_DEFS. Added .em-manage-btn (purple) so it collapses into
a floating orb with the others and reveals on header hover — now it behaves like
the rest of the cluster instead of an out-of-place static button.
The modal opened with a plain pop — out of place next to the worker orbs. Now it
springs up from the bottom (toward the Manage Workers button) with the SAME easing
the orbs reveal with, then the worker rail assembles one-by-one: each chip springs
in staggered (scale 0.4→1) with a brief pulse of its own brand colour. Mirrors the
orb motion language AND walks your eye across every worker + its live state dot /
coverage bar as they land — cool + informative. Respects prefers-reduced-motion.
Verified against live data: 1312/1313 stored similars carry a metadata source id,
but 1 slipped through name-only (a match on a source with no id column, e.g.
discogs). Enforce the standard: process_artist now SKIPS any similar whose match
doesn't map to a storable id column (spotify/itunes/deezer/musicbrainz) instead
of writing a useless name-only row. Regression test covers discogs-match + no-id
cases. Now 100% of newly-stored similars are actionable.
The kettui move: 38/79 fetches errored on the first live run, but they were
logged at DEBUG only — invisible in app.log, so the cause (rate-limit vs
no-providers vs bug) is unprovable. process_artist now returns a (status, count,
detail) triple carrying the error reason (status code + message / exception),
and the worker logs the first 15 errors per session at WARNING (rest DEBUG) +
keeps _last_error. No blind pacing tweak — let it run, read the real reason, then
fix the proven cause. Seam tests updated + assert the reason is captured.
THE root cause of 'orb frozen, click does nothing visibly': when a socket is
connected, the orbs don't poll — update*Status() bails on socketConnected and
relies on server pushes. similar_artists was missing from BOTH the server emit
loop (_emit_enrichment_status_loop's workers dict) and the client dispatch
(core.js socket.on('enrichment:<id>')), so the orb never received status → never
updated. Clicks DID pause the backend (modal showed paused), but the orb visual
was frozen. Added the worker to the emit loop + the socket.on handler.
Root cause of 'click does nothing': I flip-flopped between inline onclick and
addEventListener. A cached index.html with my inline onclick + fresh JS with
addEventListener = the click fires the toggle TWICE (pause then resume) = no net
change. Now identical to AudioDB/Deezer/etc.: NO inline onclick on the button,
single addEventListener('click', toggle) in the init. One handler, one fire.
The addEventListener wiring evidently wasn't firing the toggle (orb showed
running but clicking didn't pause). Switched the button back to an inline
onclick=toggleSimilarArtistsEnrichment() — identical to the Amazon orb, which
works — and exposed the fn on window so the inline handler always resolves.
Toggle logic unchanged (active ? pause : resume).
Stop diverging — match toggleAmazonEnrichment/toggleSpotifyEnrichment verbatim:
contains('active') ? pause : resume. A paused orb isn't 'active', so a click
resumes it (same as every other worker). My earlier 'paused'-class variant was
what broke unpausing.
Class-based toggle had a hole: the orb may lack the 'paused' class even when the
backend is paused (before the first 2s status poll, or worker fallback), so a
click would PAUSE the already-paused worker (no-op) → 'clicking doesn't unpause'.
Now the toggle reads /status first and does the opposite of the real paused
state, so a paused worker always resumes on click.
The orb was excluded from worker-orbs.js's WORKER_DEFS list, so it never got the
shared 'collapse to floating orb after 7s idle / reveal on header hover'
animation (worker-orb-hidden / worker-orb-reveal) every other orb has. Added its
container (.similar-artists-enrich-button-container, purple) to the list.
- Orb wouldn't pause when the worker had finished its library: the toggle keyed
off classList.contains('active'), but a done worker sits in the green
'complete'/idle state, so clicking tried to resume (no-op). Now it pauses
unless already paused → pausable in any state.
- Switched from inline onclick to addEventListener (matches spotify/itunes/etc.,
the majority pattern) instead of the amazon/discogs inline style.
- get_stats now reports PERSISTENT counts from the DB (matched/not_found/pending
+ a progress.artists breakdown) instead of in-memory session counters, so the
dashboard orb tooltip and the Manage modal agree (was showing 0 vs 14 after a
restart) and it survives restarts — same approach as the other workers.
- Orb tooltip reads progress.artists ('Artists: 14 / 15 (93%)') like the rest.
- Worker now defaults to ON (running) instead of opt-in-paused; still honors a
saved pause across restarts. It self-paces (~3s/artist) and backs off on
MusicMap outages, so the orb spins/active like the others when there's work.
10 seam tests pass.
SoulSync standalone matches library tracks without Plex fetchItem,
reports missing counts correctly, and skips server playlist writes.
Automation re-syncs when the mirror grows; after sync finishes, starts
organize download (organize-by-playlist) or wishlist processing.
UI: Spotify URL playlist-folder controls, organize toggle layout in the
discovery modal, reload organize preference when reopening Download Missing.
Co-authored-by: Cursor <cursoragent@cursor.com>
Adds the dashboard status bubble (the small icon row) for the Similar Artists
worker, alongside the modal entry. Mirrors the per-source bubbles: MusicMap logo,
purple accent, spinner + active/complete/paused states, hover tooltip, and a 2s
status poll against /api/enrichment/similar_artists/status. Click toggles
pause/resume. Tooltip shows matched/pending (the worker has no artist/album/track
phases). 74 JS integrity tests pass.
Closes the gap where similar artists only existed for WATCHLIST artists: a new
background worker populates them for the whole LIBRARY, slotting into the
existing enrichment-worker pattern (bubble + Manage Enrichment Workers modal,
status/pause/resume, matched/not_found/pending/errors).
Per source-matched library artist → get_musicmap_similar_artists(name, 25)
(the same matcher the artist-detail page uses: fetches MusicMap names, matches
each to the user's source chain — primary + active fallbacks — returns only
matched artists) → store via add_or_update_similar_artist keyed by the artist's
metadata source id, the SAME key the watchlist scanner + artist map use, so the
two cooperate (idempotent upsert + retry_days window).
- core/similar_artists_worker.py: pure seams (pick_source_artist_id,
map_payload_to_store_kwargs, process_artist) + the threaded worker; skips
artists not yet source-matched; classifies not_found vs transient error
(retry after 30d).
- DB migration: similar_artists_match_status / _last_attempted on artists
(mirrors every other source worker's tracking columns).
- Registered in EnrichmentService + instantiated in web_server, DEFAULT-PAUSED
(opt-in) like Amazon — MusicMap is scraped/outage-prone + this is library-wide.
- SERVICE_ENTITY_SUPPORT['similar_artists']=('artist',) so the modal breakdown
('artists with / without similars') + Retry work; manual-match (inapplicable
to a relationship) is gated out via relationship:true.
- 10 seam tests; existing 80 enrichment tests still pass.
Note: keys under profile 1 (single-profile setups); multi-profile is future work.
The fixed hamburger (top:16 left:16, 44px, z9999) sat on top of the map's back
button on mobile. Push .artmap-nav-left right by 52px on <=760px so the back
button clears it.
- Toolbar wraps on phones (<=760px): back + title + stats and the compact tools
stay on row 1, the search drops to its own full-width row below so nothing gets
crushed. Brand text hidden, stats truncate with ellipsis.
- Island nav + canvas height now MEASURE the toolbar height instead of assuming
~50px, so the taller wrapped header doesn't overlap the nav or clip the canvas.
64 JS integrity tests pass.
- Info panel becomes a bottom SHEET on phones (<=760px): slides up when you tap
a bubble, doesn't steal map width (islands frame full-width via _artMapReservedW
= 0 on mobile). Grip/handle to dismiss; a floating menu FAB opens it to the
dashboard + top-artists. Desktop stays the right sidebar.
- Genre sidebar hidden on mobile (the top-left quick-jump nav handles genre
switching; no room for a sidebar).
- Touch tap now selects a bubble (card in the sheet) instead of opening the modal,
matching desktop click; ignores taps that were drags.
- Resize/orientation: debounced reflow that re-styles the panel for the new
breakpoint, recomputes canvas size (minus sidebar/toolbar), and re-frames the
focused island / fit. 64 JS integrity tests pass.
- Watchlist button now reflects real state: shows 'On watchlist' (filled) vs
'Watchlist' (outline), confirmed per-artist via /api/watchlist/check, and
flips instantly when you add/remove (cached in _watchSet so it stays correct
as you browse). Uses the artist's source id, works on any map.
- Debounced hover-select: the card only swaps to a bubble you've settled on for
~0.8s, so sweeping toward the panel no longer keeps changing the card on
bubbles you pass over. Clicking a bubble selects it instantly (bypasses the
debounce, pins the card) instead of auto-opening the modal — Details button
still opens it.
- Fix: panel started at top:0 and covered the navbar; now it starts below the
.artist-map-toolbar (measured) so the toolbar stays clear. 64 tests pass.
The persisted Standard/Enhanced preference was re-applied on every artist load
BEFORE the data came back — so for an artist not in the library (source-only, no
Enhanced view) it still flipped to Enhanced, which showed an empty Enhanced pane
and never rendered the discography.
Now the preference is applied inside loadArtistDetailData, after we know the
artist's status (data.artist.server_source). Only library artists honour a saved
'enhanced' choice; source-only artists always stay on Standard (discography).
A polished detail panel on the right of every map (never collides with the genre
sidebar; islands now frame in the space left of it):
- Header dashboard: view title, Artists / Watchlist / Genres stat tiles, and a
watchlist-coverage bar for the current genre/view.
- Top-artists list: the current island's biggest artists, clickable (shows
their card + ripples them on the map).
- Rich artist card on hover/click: large art (from the decoded bitmap), genre
chips, popularity bar, connection count, watchlist/discovered badge, and
actions — Explore from here, Details, Watchlist toggle, Open artist page.
Card stays pinned (no auto-revert) so you can reach its buttons; a back
button returns to the list.
64 JS integrity tests pass.
Bubbles now rise up into position (water-surfacing) with a soft ease-out-back
settle and alpha fading in a touch faster than scale. Stagger is continuous
radial + a deterministic per-bubble jitter so they fill in organically instead
of popping in visible rings/segments. 64 JS integrity tests pass.
Root cause of the 'loads as placeholder orbs, only pops in after a zoom' bug:
streamed images were cached in _artMap.images but written into the buffer via
the per-node composite path, which didn't reliably refresh in one-island /
overflow mode — so covers stayed as placeholders until a zoom forced a full
rebuild that picked up the cached bitmaps.
Now that each map's buffer is small (one focused island, or a small explore
map), a throttled FULL rebuild on image arrival is cheap and always bakes every
cached image. Dropped the composite call from the stream; art fills in by itself
as it loads. 64 JS integrity tests pass.
- Soft genre-hued halo glows behind the focused island (cached per-hue sprite →
one drawImage, no per-frame gradient) so it reads as a place on the water.
- Hover-pop: hovered bubbles scale up + get a bright hue ring + glow, even on
static genre islands (drawn on top), so hover always feels tactile/responsive.
- Genre quick-jump: click the genre name in the nav for a dropdown of every
genre island — jump straight to one instead of only prev/next.
- Decluttered: dropped the redundant in-world island titles in one-island mode
(the nav bar already names the genre, and they could clip off the top).
64 JS integrity tests pass.
The AcoustID section in the README was a one-liner that did not
explain how to get the API key or why the retag action sometimes
appears to do nothing.
Expand the section with:
- A short paragraph pointing users at acoustid.org/new-application
and the Settings entry where the key belongs.
- A note cross-referencing issue #704: the retag path treats an
existing MUSICBRAINZ_TRACKID tag as a short-circuit, so removing
the cached tag is the documented workaround. This gives a
definitive answer until the retag path itself is fixed.
No code or behavioural change.
- Focused islands now render from the high-res buffer (one cheap crisp blit)
instead of redrawing every bubble each frame for the bob. In one-island mode
the buffer already covers just that island at high resolution, so this is
crisp AND cheap — kills the genre lag. Bob/shove stay live only for small
views (zoomed-in subsets, explore) where per-frame redraw is cheap.
(Overflow threshold 650→140; the loop parks once the island bakes.)
- Fewer bubbles per island (maxPerIsland 500→300) — less cramped, lighter bloom.
- Island nav bar moved from bottom-center to top-left (clears the genre sidebar
+ toolbar). 64 JS integrity tests pass.
Two things from feedback:
1) Toolbar search now queries the metadata source for ANY artist (like the
discover page) and launches an exploration on click — instead of only
filtering the current map's nodes (which showed nothing for off-map names).
2) Genre + watchlist maps now frame ONE genre island at a time, with prev/next
nav (and ← / → keys) through the genres. This sidesteps the persistent
'renders small/sparse' bug entirely: only the focused island is visible, so
the buffer covers a small region at HIGH res (crisp covers, no more shrunk
images) and the live layer handles just ~hundreds of bubbles (bob works, no
overflow). Each island blooms in (drop-in-water) on focus. Explore stays
multi-island (it's small). A bottom nav bar shows genre name + i/N.
Streaming caches off-island images silently (no redraw) so navigating is
instant. 64 JS integrity tests pass.
Fixes the genre-map 'renders small/sparse after the reveal, zoom fixes it' bug.
Root cause: tighter islands (Phase D) raised the fit-zoom so nearly every bubble
crossed the live-size threshold → the buffer excluded them all (thought they
were live) but the live layer is capped, so only ~600 of 1800 drew until a zoom
rebuilt the partition.
Fix: _artMapRebuildBuffer now counts would-be-live bubbles; if more than the
live layer can draw (>450), it sets _liveOverflow and bakes EVERYTHING into the
buffer (full, correct render). The live layer + bob only take over once zoomed
in enough that few bubbles qualify. So the overview is always complete,
regardless of zoom. Trade-off: very large maps (genre 1800) render from the
buffer (no per-bubble bob, slightly softer when deeply zoomed until the
zoom-rebuild sharpens) — correctness over flourish on the crowd.
Also: whole animation loop capped at ~30fps (reveal/ripple/bob all read fine at
30) to cut the churn on dense maps; a pending rebuild (dirty) always draws so the
throttle can't skip the post-reveal bake. 64 JS integrity tests pass.
Addresses the perf + tooltip feedback:
- Hover constellation no longer clips per node every frame (images are already
pre-masked circles) — that per-node ctx.clip() was the hover-lag culprit once
the ambient loop forced continuous redraws. Now a plain drawImage + arc tint.
- Ambient buoyancy loop runs at ~30fps when idle (full 60 only during
reveal/ripple), halving redraw cost on dense zoomed-in maps while keeping the
bob smooth.
- Gloss highlight gated to bubbles >=12px on screen (skips the dense swarm) —
halves per-frame drawImage cost when zoomed in.
- Tooltip photo now paints from the already-decoded bitmap into a canvas
instead of a fresh <img src> reload — fixes the blank photo when sweeping
across dense zoomed-in bubbles (the <img> was churn-reloading faster than it
could decode). 64 JS integrity tests pass.
Clicking (or tapping) the map now drops a water ripple: a hue-tinted ring
expands from the point AND nearby bubbles get shoved radially outward at the
wavefront, then settle back as it passes and decays (_artMapNodeDisplacement —
a gaussian bump at the expanding front, world-space radial push). Ripples emit
from the clicked bubble's centre in its genre hue (or the bare click point),
and still open the artist after a beat. Replaces the old single purple ring.
Note: the physical shove acts on live-layer (zoomed-in) bubbles; at the far-out
overview the ring shows but the tiny baked bubbles don't move. 64 tests pass.
- Ambient bob: bubbles gently float (sine offset, phase varies by position so
they move in a wave, not in unison). Driven by a persistent rAF loop that runs
only while bubbles are on screen + the tab is visible, and parks when zoomed
out (_liveCount==0) or the map closes — so an idle overview costs nothing.
- Glassy specular highlight (cached sprite, cheap drawImage per bubble) so
bubbles read as glossy orbs at every size.
- Tighter island spacing (water gap 7*nodeR → 3.5*nodeR) so the settled
overview is more substantial, not thin-spread — addresses the 'mini version'
feel after the reveal ripples fade.
- Ambient resumes on zoom and on tab re-focus; stops cleanly on close.
64 JS integrity tests pass.
Islands now bloom in like drops on water instead of a flat fade:
- Each island reveals in turn (staggered by island order); within an island,
bubbles fade + scale (0.55→1, ease-out) outward from the centre by radial
distance — a drop-in-water bloom. Genre titles fade in just after.
- A hue-tinted water ripple ring expands from each island centre as it blooms
(_artMapDrawRipples — reused by click ripples in Phase E).
- During the reveal the static buffer is bypassed so EVERY bubble can animate
(live layer, cap 2200); when the bloom ends it bakes into the buffer once and
steady-state returns to the cheap two-layer path.
- aAlpha folds into the global draw-alpha multiplier so fades compose cleanly.
64 JS integrity tests pass.
All three maps (watchlist / genre / explore) now lay out as genre 'islands' on
the water via one shared engine (_artMapLayoutIslands):
- Group artists by primary genre (long tail folds into 'Other'; max 14 islands).
- Each island is a FILLED disc of covers packed centre-out (no empty donut
hole), most-popular nearest the middle, focal artists sized up + centre-most.
- Islands spread by golden spiral + push-apart with generous water between.
- Clean floating genre TITLE above each island (hue-tinted, glow) instead of
the old giant translucent label bubble.
- Per-genre accent hue tints member-bubble borders so clusters read as a family.
- Discovery edges (watchlist→similar, center→ring1→ring2) remapped to the new
node ids so the hover constellation still works across islands.
Replaces the per-artist donut clusters from the screenshots. Shared helpers:
_artMapGroupByGenre, _artMapPackDisc, _artMapRemapEdges, _artMapFitToContent.
64 JS integrity tests pass.
Addresses the screenshot feedback (mix of detailed covers + blank dots, lag,
'weird' load):
- Pre-mask each album image into a circle ONCE at load (a canvas), so every
draw is a plain drawImage instead of a per-frame ctx.clip(). Clipping was
the live-layer stutter — hundreds of clips per frame. Now free.
- Draw album art at nearly every on-screen size (only sub-2.2px fall back to a
dot), instead of detailed-vs-blank-dot tiers. Consistent 'sea of covers'.
- Reveal is now a clean ease-out-cubic fade of the whole map (buffer blit +
live layer ramp together via _drawAlphaMul) — dropped the bouncy per-node
pop that read as 'weird'. The real island ripple bloom comes in Phase C.
64 JS integrity tests pass.
Foundation for the water/ripple redesign. Splits rendering into:
- Static far-field buffer: small/distant bubbles, baked once (cheap blit).
- Live overlay layer: every bubble big enough to read (radius*zoom >= LIVE_PX)
redrawn each frame in world space, so it can scale/bob/ripple. Viewport-
culled + capped at 600 draws.
The partition is frozen at buffer-build zoom (_liveBuildZoom) so the two sets
stay exact complements even mid-zoom — no flicker, no double-draw.
Adds an idle-capable rAF loop (_artMapStartLoop/_artMapStepAnimations) that runs
only while something animates and stops when still. First payload: a reveal —
the far field fades in globally while live bubbles pop outward from the camera
centre (ease-out-back, staggered by distance). Wired into all three loaders.
Bonus: live bubbles now draw full-res at the current zoom instead of through the
4096px-capped buffer, so zoomed-in artwork is crisp (addresses the earlier
low-res complaint structurally). Engine only — the island layout, ripple
choreography and click physics build on this in B–E. 64 JS integrity tests pass.
The streaming fix in Phase 4 still rebuilt the ENTIRE offscreen buffer (~1500
nodes) on each image wave, and any hover/pan during streaming hit that same
dirty flag — so interacting while images loaded redrew the whole world over and
over (the 'laggy until all images load' jank).
Now each arriving image composites ONLY its own node into the existing buffer
(_artMapCompositeNode) and does a cheap rAF-coalesced blit — no full rebuild.
The per-node draw is extracted into _artMapDrawNodeToBuffer so the full rebuild
and the incremental compositor share identical drawing (can't drift). Falls back
to a full rebuild only if the buffer isn't built yet. Pan/hover stay at
blit-speed the entire time images stream in.
All three maps (watchlist/genre/explore) now paint instantly with placeholder
circles and stay fully interactive (pan/zoom/hover/click) while images stream
in throttled ~280ms waves and sharpen the map in place. Replaces the old
blocking 'await all N images then paint' loaders — the headline 'feels slow'
fix. Focal/large nodes fetch first; a per-open load token cancels stale streams
when you jump to another artist, so rapid click-through never piles up fetches.
- Images: decode adaptively (focal/watchlist nodes ~256-384px, small nodes
~112-150px) instead of a flat 128px — crisp where it matters, memory still
bounded (~150-250MB, not 6GB). Fixes the low-res look.
- Hover constellation: drop the activation delay 800ms → 220ms (it felt 'gone'
because nothing happened for nearly a second), and draw the connection lines
as a wide-faint halo + crisp core (a real glow) with no per-frame gradients
or shadowBlur — stays cheap.
- Backdrop: subtle cached radial glow + vignette behind the map for depth
instead of a flat fill (one cheap fillRect/frame).
JS clean; 64 integrity tests pass.
Perf telemetry was the giveaway: after the buffer cap, rebuild + draw were both
~10ms, yet fps stayed 1-3 and the browser 'locked'. Cheap draw + locked system =
memory/GPU thrash, not drawing.
Cause: artist images load at up to 1000×1000, and a dense map holds ~1500 of
them — ~1500 × 1000² × 4B ≈ 6 GB of decoded ImageBitmap memory. The browser GCs/
evicts textures constantly → systemic lag the canvas timers don't see.
Fix: decode straight to a 128px avatar via createImageBitmap resize options
(nodes render tiny anyway). ~1500 × 128² × 4B ≈ 100 MB instead of 6 GB. Falls
back to full decode on engines that ignore the resize opts.
This is the one that should actually make it smooth. Perf overlay stays on 'd'.
Perf telemetry from the genre map (2004 nodes) proved it: the offscreen buffer
was 7465×10240 (76 megapixels) — rebuilt in ~979ms on every zoom and blitted at
~150ms/frame (3 fps), with the constellation overlay piling on top. The buffer
renders the WHOLE world, and the size cap was 10240px.
Cap the max buffer dimension to 4096 (MAX_BUFFER_PX). On the dense genre map
that's ~12MP instead of 76MP → ~6x faster rebuild and blit, and more nodes drop
under the LOD dot threshold so the rebuild also draws fewer image-clips. The cap
only binds on large worlds; small watchlist/explorer maps don't reach it and
stay full-resolution.
Tunable; perf overlay ('d' → app.log) stays so we can confirm the new numbers.
The on-canvas overlay text can't be copied (and can't be grabbed mid-freeze), so
when perf mode is on ('d'), the frontend now also POSTs the render timings to
/api/discover/artist-map/perf ~1.5x/sec, which logs them as [ARTMAP-PERF] in
app.log. Lets the bottleneck be diagnosed from the server side with no manual
copying.
- REVERT the spatial-grid hit-test I added in Phase 1. It inserted each node
into every grid cell its bounding box overlaps; the genre map's huge cluster
nodes span an enormous number of cells, so the first hover/click triggered a
multi-second synchronous build → 'can't hover or click' freeze. Back to a flat
O(N) single-pass hit-test (no per-move sort) — sub-ms even for thousands of
nodes, can't lock up.
- Keep the safe Phase 1 wins (render coalescing, tooltip de-churn, solid-stroke
connection lines).
- Add a perf overlay toggled with 'd' on the map: shows node/edge counts, the
offscreen buffer size + scale, zoom, and the last buffer-rebuild + draw times.
So we can measure the real drag/zoom bottleneck (buffer rebuild) instead of
optimising blind.
JS clean; 64 integrity tests pass.
The Explorer prompt accepted any loose text and explored whatever you typed.
Now it's a proper picker: type -> debounced search of the metadata source
(reuses /api/discover/build-playlist/search-artists — Hydrabase if active,
Spotify if configured, else the active metadata source) -> shows real artist
results with images -> click one to explore that resolved artist. Enter picks
the top match (never explores raw text); Escape/Cancel/backdrop close.
Pure frontend: rebuilds _showArtistMapSearchPrompt() (same Promise<name|null>
contract, so the caller is unchanged), reusing the playlist-builder's search
endpoint + picker styling. No backend change.
Persist organize_by_playlist on mirrored playlists and run playlist-folder
downloads from the auto-sync pipeline instead of the global wishlist phase.
Register SoulSync library rows after playlist-folder post-processing, route
failed organize batches to the wishlist correctly, and skip sync-time
unmatched wishlist only when organize download handles retries.
Invalidate stale playlist track caches on refresh (Spotify and Deezer ARL),
re-mirror on refetch, and improve standalone playlist modals (re-analysis,
Open in Mirrored). Add filesystem missing-track detection and tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
Investigating 'each mode is different / not good enough' showed the engine is
already shared across all three modes (watchlist/genre/explore) and already does
LOD rendering, eased camera, and debounced zoom-rebuilds — so the inconsistency
was perception driven mostly by the (now-fixed) lag, not separate engines.
This phase surfaces more real data the map already has: the hover tooltip now
shows each artist's live connection count (computed from the map edges), shown
consistently across all three modes. Cheap (only recomputed when the hovered
artist changes, after Phase 1's de-churn). Additive + safe.
JS syntax clean.
Kills the hover/move lag on dense maps. Root causes were in the live
mouse/render path, not the layout:
- Render coalescing: _artMapRender() now just requests a single rAF; the actual
draw (_artMapDraw) runs at most once per frame. A burst of mousemove/pan/
animation calls no longer triggers many full-buffer blits per second.
- Tooltip de-churn: only rebuild the tooltip innerHTML (and reload its image)
when the hovered artist changes; a plain mousemove just repositions. Was
rebuilding innerHTML + a new <img> every pixel of movement.
- Spatial-grid hit-test: bucket nodes into a coarse world grid and test only the
cell under the cursor, instead of sorting + scanning every node each move.
Grid rebuilds only when the node set changes.
- Constellation lines: draw all connection lines as ONE solid-stroke path
instead of creating a fresh linear-gradient object per line every frame —
that per-frame gradient churn was the main 'connected lines' lag.
No layout/data/click changes; behaviour identical, just frame-bound. Pure
frontend; JS syntax clean.
- Verified end-to-end: fetch_public_playlist_full pulled all 236 tracks of the
test playlist via SpotipyFree (the library handles the client-auth that 429'd
the raw approach). Name + tracks correct.
- requirements.txt: declare spotipyFree>=1.1.2 as a normal pip dependency (like
spotDL, also MIT — aggregation, not vendored) + websockets (a transitive dep
SpotipyFree/spotapi needs that pip doesn't pull automatically). Code still
soft-imports + falls back to embed, so it's never a hard runtime requirement.
- meta fetch uses limit=1 (name/owner only) so we don't pull the whole list
twice. 9 tests green.
The in-house anonymous-token path is blocked by Spotify (429 without the web
player's rotating client-auth). Switch the full-fetch to SpotipyFree — the
maintained no-creds spotipy drop-in spotDL uses, which tracks that machinery.
- core/spotify_public_api.fetch_public_playlist_full now uses a SpotipyFree
client (playlist + playlist_items + next), normalising the spotipy-shaped
items to the embed scraper's shape. Injectable client_factory keeps it
unit-testable without the library or network. Dropped the dead in-house
token/pagination code.
- Licensing: SpotipyFree is GPL-3.0, so it is NOT bundled/required (SoulSync is
MIT). Optional, user-installed: the import is soft, and on ImportError (or any
failure) fetch_spotify_public falls back to the embed scraper (~100). So the
shipped project stays cleanly MIT and the link path never regresses.
- requirements.txt: documents it as a commented optional extra
(pip install SpotipyFree) with the GPL/MIT rationale.
- 9 tests: normalisation, pagination past 100, library-missing -> raises (->
fallback), and the embed-fallback orchestration.
Needs a live click-through with SpotipyFree installed to confirm the exact
class/method names match (SpotipyFree.Spotify / playlist / playlist_items).
The full-fetch's logs used a bare module logger that app.log doesn't capture,
so we couldn't see whether the API path succeeded or why it fell back. Route
them to 'soulsync.spotify_public' and log: token found?, embed parsed?, the
API HTTP status on a non-200, and pagination result. Lets us see the exact
failure (e.g. 401 vs 429) on the next link-tab test.
Live debugging the 'shows 100' report:
- The full playlist page no longer embeds an accessToken, and get_access_token
/ server-time now 403/404. The EMBED page (open.spotify.com/embed/playlist/{id})
still ships a usable anonymous token. Was fetching the wrong page -> no token
-> raised -> embed fallback (100). Now reads the embed page for the token.
- Confirmed live: token extraction + embed parse work; the token is accepted by
the Web API (429 rate-limit, not 401). Could not show >100 from here because
the test IP got rate-limited from probing; needs a clean-IP click-through.
While in there, made it more robust against the rate-limiting that's clearly in
play:
- Refactored scrape_spotify_embed -> reusable parse_embed_html.
- fetch_public_playlist_full now does ONE embed fetch for token + name + first
page (no separate metadata call = fewer requests = less 429 surface), then
paginates the API. If the API is unavailable/rate-limited, it keeps the embed
page's tracks (<=100) instead of raising — so the result is always >= today's
behaviour, never worse.
- 12 tests incl. the new API-fails-but-embed-tracks-survive path.
Caveat unchanged: rides Spotify's undocumented embed-page token; degrades to the
embed fallback, never crashes.
The no-auth 'add by link' path scrapes Spotify's embed widget, which only ever
contains ~100 tracks and can't paginate — so big public playlists got
truncated. This adds an in-house anonymous fetch that pulls the FULL list:
- core/spotify_public_api.py: reads the anonymous web-player accessToken Spotify
already embeds in its own open.spotify.com page HTML (no app credentials, and
no rotating TOTP secret for us to maintain), then paginates
/v1/playlists/{id}/tracks 100 at a time until the whole playlist is pulled.
Returns the embed scraper's exact shape. Pure helpers + injected http_get so
it's unit-testable without the network.
- core/spotify_public_scraper.fetch_spotify_public(): tries the full fetch for
playlists; on ANY failure (or for albums) falls back to scrape_spotify_embed.
Worst case == today's behaviour, so the link path can't regress.
- web_server: the link-tab endpoint and the authed flow's last-resort scrape
now both go through fetch_spotify_public.
Scoped entirely to the spotify_public_* (no-auth) path — the authenticated
playlist sync is untouched. 11 tests (token extraction, normalisation,
pagination past 100, and the embed-fallback orchestration).
Caveat: rides Spotify's undocumented page-embedded token — expected to break
when they change their page; it degrades to the embed fallback, never crashes.
Needs a live click-through to confirm the token path works end to end (can't
hit Spotify from the test env).
The onclick-coverage guard only scans the split modules + a hardcoded extras
list, so it flagged openEnrichmentManager() (defined in the new, loaded
enrichment-manager.js) as undefined. Add enrichment-manager.js to the scanned
non-split files. The function genuinely exists and is loaded via its script tag.
Global priority previously set order only; per-worker pin also re-queued the
group's failed items. Made global consistent: setting a group globally now also
resets that group's not_found -> pending on every supporting worker, so each
worker sweeps ALL pending + failed of the group before moving on. Toast reports
total re-queued. Workers that don't enrich the group are skipped.
- Rebuilt the modal header: gradient top bar with a glowing 🧬 icon chip,
gradient title + subtitle, and styled refresh/close — replaces the flat bar.
- Global 'process first everywhere' control in the header: Artists/Albums/
Tracks/Auto applies to every worker at once (workers that don't enrich a
group are skipped via the 400 the endpoint already returns). Sets order only.
- Match rows: replaced the loud accent-gradient artwork placeholder with a
subtle neutral chip showing the entity glyph; real images layer over it and
remove themselves on error, so missing/broken art never leaves ragged gaps.
- Removed overflow:hidden from .em-row.
Frontend only; JS syntax clean.
Addresses three pieces of UI feedback:
- Fix entity order: enrichment coverage was rendering by object-key order
(albums first). Now sorted canonically artist → album → track via
_emOrderEntities, used everywhere.
- Combine 'Processing order' and 'Enrichment coverage' into a single set of
entity cards: each card shows coverage (segmented matched/not_found/pending
bar + %) AND is the click target to pin that group to enrich first, with
live 'Now' / pinned 'First 📌' / 'Done' states and per-worker accent. Drops
the two redundant sections (and the old chain/stats renderers).
- Richer match rows: status stripe down the left edge (red=not found,
amber=pending), larger rounded artwork with a gradient placeholder, parent
context (artist/album), and a subtle slide-on-hover.
Frontend only; JS syntax clean.
Aligns the 'process this group first' behaviour with intent:
- Pinning a group now also re-queues that group's previously-failed
(not_found -> pending) items, so the worker processes ALL unmatched in the
group (pending + missing), not just never-tried ones. Safe from loops: each
is attempted once, still-unmatched return to not_found, and the pending-only
worker hook won't re-pick them. Toast reports how many were re-queued.
- The left rail now shows each worker's current group while running
('Running · albums'), so you can see what every service is on at a glance.
Frontend only; reuses the tested /priority + /retry endpoints.
- #1 Unconfigured-source banner: when a source has enabled=false, show a
notice that browsing works but matches/retries won't run until it's set up.
- #2 Rate-limit detail: when rate_limited, surface 'resumes in ~Xm' (from the
status payload) instead of just a pill.
- #3 Richer rows: unmatched items now show parent context — an album's artist,
a track's album — via a parent expression in the query (+ test).
- #4 Bulk select: per-row checkboxes + a bulk bar to retry several at once
(capped concurrency), reusing the /retry item endpoint.
- #5 Remember last worker: selection persists in localStorage and is restored
on open; openEnrichmentManager(workerId) supports future deep-linking
(bubbles left on their pause-on-click behaviour).
- #6 Keyboard nav: ArrowUp/Down moves focus between rows; actions are native
buttons (Enter/Space) and Escape closes — list isn't poll-refreshed so focus
is stable.
53 enrichment tests green; JS syntax clean.
Per-worker processing-order override + UI polish.
Feature — pin an entity group to enrich first:
- Each worker normally runs artist -> album -> track. A user can pin one
group (artist/album/track) to run first from the modal; the worker keeps
that group first until it's exhausted, then resumes the normal chain.
- core/worker_utils.py: read_enrichment_priority() (reads
<service>_enrichment_priority each loop, live) + priority_pending_item()
(shared, whitelisted query returning the worker's expected item shape;
Spotify/iTunes get album_individual/track_individual via a type map).
- A guarded ~6-line hook at the top of all 11 workers' _get_next_item.
CRITICAL: when nothing is pinned (default) the hook returns immediately,
so default enrichment order is byte-identical to before. Discogs (no track)
and Genius (no album) only honor their supported entities.
- core/enrichment/api.py: GET/POST /api/enrichment/<id>/priority (+ config_get
hook); POST validates the entity against what the source enriches.
- 14 new tests (helper shapes, exhaustion, route get/set/clear/validate).
UI:
- Refined hero header: identity + inline status left, single Pause right,
'now enriching' quiet sub-line; overall coverage % moved into the stats
section ('82% matched · 1,203 of 1,460'). Hero gently pulses while running.
- New processing-order strip: artist→album→track steps showing the live phase
(pulsing 'now'), pinned group ('first' + 📌), and done/remaining; click a
step to pin it, click again for auto.
py_compile clean across all 11 workers; 52 enrichment tests green.
Fixes a correctness bug and adds bulk re-queuing.
- Bug: per-row 'Retry' used clear-match, which sets an item to not_found
with last_attempted=NULL. The worker only retries not_found items where
last_attempted < (now - 30d), and 'NULL < cutoff' is false in SQLite, so
those items were never re-queued. Fixed by resetting match_status to NULL
(pending), which every worker's queue picks up on the next pass.
- New POST /api/enrichment/<id>/retry with scope 'item' | 'failed'
(failed = re-queue every not_found item of an entity type), backed by a
pure whitelisted build_reset_query + MusicDatabase.reset_enrichment().
- UI: per-row Retry now hits /retry; a 'Retry all failed' bulk button appears
when the current entity has not-found items (confirm + count toast); a hint
line explains retry/match/auto-retry behaviour.
- 11 new tests (38 enrichment tests total, all green).
Dashboard 'enrichment bubbles' could pause/hover but offered no way to
*manage* a worker. This adds a full management modal opened from a new
header button, covering all 11 enrichment sources.
Backend (testable core helper + seam tests; no live-DB dependency):
- core/enrichment/unmatched.py: pure, whitelisted SQL builders for the
unmatched browser. service/entity validated against a support map (never
interpolated raw); search + pagination bound as params; tracks join albums
for artwork; limit capped at 200.
- database/music_database.py: get_enrichment_unmatched() +
get_enrichment_breakdown() (the breakdown splits matched/not_found/pending,
which the existing get_stats().progress lumps together).
- core/enrichment/api.py: GET /api/enrichment/<id>/{unmatched,breakdown} on
the existing blueprint + a db_getter hook.
- web_server.py: wire db_getter=get_database.
- tests/enrichment/test_unmatched.py: 19 tests across builders, DB methods,
and Flask routes.
Frontend (vanilla, matches app conventions):
- webui/static/enrichment-manager.js: worker rail with live status + coverage
micro-bars, accent-themed detail panel (hero header, segmented matched/
not_found/pending stat cards, current item, pause/resume), and a searchable
paginated unmatched browser with inline manual match (reusing
search-service + manual-match) and retry (clear-match re-queues).
- Polish: entrance/exit motion, scroll-lock, Escape, refresh control,
flicker-free polling (in-place updates), skeleton loaders, relative
timestamps, per-worker accent theming, real dashboard logos reused at
runtime (with the same invert/circle treatment), responsive rail.
- index.html: header button + script include. style.css: full styling.
Reuses existing pause/resume, status, and manual search+assign endpoints.
Backend tests green (19 new + 11 existing enrichment tests).
Per-track import does heavy synchronous server-side enrichment (metadata,
art, lyrics) that can take 60-90s/track, far longer when external sources
are degraded. The React apiClient (ky) had no timeout, so ky's default 10s
aborted the import-process request client-side even though the server
completed the import (200) and moved the files. The import loop then counted
the aborted call as an error, so the bar stayed at 0 and flipped to 'Failed'
while files imported fine.
Give the two import-process calls (album/process, singles/process) an
explicit 5-min timeout. Scoped to import only -- every other endpoint keeps
the 10s default; bounded, not disabled. Server behavior unchanged.
Adds a test asserting both calls pass the long timeout.
- B023: default_fetch_tracklist built a per-item lambda closing over the loop
variable `it`. Replaced with a module-level _item_get(item, key, default)
helper (takes the item as a param — no closure). Behavior unchanged; the
dict/object normalization test still passes.
- S110: the two best-effort guards in the canonical job (skip-already-pinned
read, estimate_scope active-server read) now carry `# noqa: S110 — <reason>`,
matching the repo's existing convention for intentional swallow-and-continue.
ruff check passes on all canonical files + tests; 30 affected tests green.
The canonical source_selection setting was rendering as a free-text box — easy
to typo an invalid mode. Added a generic choice mechanism so it's a dropdown:
- RepairJob.setting_options: {key: [allowed values]} (default {} — opt-in).
- CanonicalVersionResolveJob declares source_selection's three modes.
- repair_worker.get_all_job_info() includes setting_options in the job payload.
- enrichment.js renders a <select> (options prettified, current value selected)
for any key listed in setting_options; everything else renders by value type
as before. The save path already reads <select>.value as a string, so no
change needed there.
Generic — any future job can get dropdowns the same way. Jobs that don't
declare setting_options are untouched (empty dict -> existing input rendering).
Tests: source_selection exposes the 3 options and its default is one of them.
23 repair-job/worker + canonical tests pass (other jobs unaffected).
Per request, pack each finding with everything available WITHOUT extra API
calls (kettui: reuse what's already fetched, read the album row we already
loaded, degrade per-field, keep it tested):
- Pinned release's track titles — already fetched during scoring, so free
(capped at 60 to bound details_json).
- From the album row (free): year, DB track count, total duration, genres-free
context, and the album's currently-linked source IDs.
- file_track_titles (your library's titles) for a side-by-side with the release.
- Artist + album thumbs (artist via the guarded lookup) and names.
_describe_pin now renders: "Artist — Album (year)", the fit breakdown, "Currently
linked: … → pinning X", "Beat: <alternatives>", and the release tracklist — so
the card is judge-able at a glance, and the structured fields are in details for
a richer UI.
NOT included (would cost an extra per-album API fetch, left as opt-in): the
*release's* own year/type/cover/URL from get_album_for_source, vs the library's.
Tests: _describe_pin rich-render (year/linked/tracklist), resolver release-titles,
orchestration free-context fields. 94 canonical + reorganize regression pass.
Findings now carry artist_thumb_url alongside album_thumb_url (same key the
track-repair findings use, so the findings UI already renders it).
Fetched via a guarded _lookup_artist_thumb() — checks the artists table has a
thumb_url column first and swallows any error — rather than adding ar.thumb_url
to the shared load_album_and_tracks SELECT. The shared-loader approach was
tried first and REVERTED: it crashed reorganize on schemas whose artists table
has no thumb_url column (caught by 40 orchestrator tests). The lookup only runs
for albums that actually resolve, so it adds no cost to the no-source-id
short-circuit majority.
Tests: orchestration test asserts artist_name + album_thumb_url + artist_thumb_url
flow through. 47 canonical + 104 canonical/reorganize regression tests pass.
Live-run feedback: "Best-fit release: deezer (665666731), score 1.0" is too thin
to trust/accept. Each finding now explains WHY:
- score_release_detail() exposes the per-signal breakdown (count/duration/title)
instead of just the blended score.
- resolve_canonical_for_album returns an enriched result: the breakdown,
file_track_count vs release_track_count, and a `candidates` list of every
source it scored (so a finding can show what the winner beat).
- resolve_and_store adds album/artist/thumb context from the row it already
loaded (no extra query). Storage still only reads source/album_id/score.
- The job builds a real description via _describe_pin(), e.g.:
"Pin deezer release 665666731 (confidence 100%).
Fit to your library: 11 files vs 11 tracks on this release — track count
100%, durations 100%, titles 100%.
Beat: spotify 65% (17 tk)."
and a clearer title ("Pin deezer as canonical: <artist> — <album>").
Tests: resolver enrichment (breakdown + candidate comparison fields), and
_describe_pin (judge-able text incl. the beaten alternatives, and honest "n/a"
for a missing signal). 42 canonical tests pass.
Note: the description string carries the judge-able info regardless of UI; how
the findings tab renders the extra details keys (thumb image, candidates table)
is still UI-dependent and unverified.
Feedback from the live dry-run: the job was pinning whichever source best fit
the files regardless of which source it was, which was surprising — users
expect it to respect their active metadata source. Made it a per-job setting
instead of a baked-in policy.
source_selection (default 'active_preferred'):
- active_preferred — use the active/primary metadata source's release when the
album has an ID for it AND it clears the score floor; otherwise fall back to
the best-fit among the other sources. Respects the configured source but
self-heals when that link is clearly broken (below floor / no ID).
- active_only — only ever the active source; never considers others.
- best_fit — previous behavior: whichever source matches the files best.
resolve_canonical_for_album gains mode + primary_source; the orchestration
threads the primary source through; the job reads source_selection from its
settings. Note: active_preferred respects the active source as long as it clears
the floor, so it will NOT override a deluxe-vs-standard mismatch on the primary
(#767-Bug2) — that's what best_fit is for; the choice is now the user's.
Tests: per-mode coverage in test_canonical_resolver.py (active_preferred uses
primary when it fits, falls back when primary is below floor, keeps primary even
when another fits better; active_only pins primary / never falls back; best_fit
unchanged), orchestration default-mode test, and the setting default. 39
canonical tests pass.
The staged design doc for this branch (#765 + #767-Bug2): the
match-your-files canonical rule, the additive/dormant rollout, and the
stage-by-stage plan the 6 implementation commits followed. Kept on the branch
as its reference; not relevant to dev/main.
The populate trigger that turns the (until now dormant) feature on. Until a user
enables and runs this job, no album has a canonical -> both read sides (Stages
3-4) fall back -> zero behavior change. So the whole feature ships safely off.
- core/repair_jobs/canonical_version_resolve.py — "Resolve Canonical Album
Versions". Iterates the active server's albums, skips ones already pinned, and
calls the tested resolve_and_store_canonical_for_album per album. Opt-in
(default_enabled=False) and dry-run-by-default: resolving compares an album's
candidate releases across sources (metadata-source API calls, once per album),
so it's deliberately user-triggered. Dry run reports a finding per album it
would pin; live mode stores. Registered in _JOB_MODULES.
- core/metadata/canonical_resolver.py — resolve_and_store gains store=True; the
job's dry run passes store=False to resolve-without-writing.
Tests: tests/test_canonical_version_job.py (6) — registered, opt-in + dry-run
defaults, live resolves+stores (auto_fixed), dry run creates findings without
persisting, already-pinned albums skipped. Registry loads all 19 jobs cleanly.
145 tests across the full feature + reorganize/track-repair/DB regression pass.
_resolve_album_tracklist gains a Fallback -1: if the album has a pinned
canonical (source, album_id), use it before the existing 6-level cascade — so
Track Number Repair resolves the SAME release the Reorganizer does (Stage 3) and
the two stop contradicting each other (#765, the Spotify-4 vs MusicBrainz-3
conflict).
Gated + additive: the entire existing cascade is untouched for albums without a
canonical, so this job's all-01-album rescue (which relies on the MusicBrainz/
AudioDB fallbacks for albums with no DB source ID) is fully preserved — that's
the regression we explicitly refused to take in a reactive fix.
New helper _lookup_canonical_from_db() mirrors _lookup_album_ids_from_db
(file-path -> track -> album), returns None when no DB / no match / columns
absent / unresolved.
Tests: tests/test_track_repair_canonical.py (4) — returns canonical when pinned,
None when unresolved / file untracked / no DB. Existing track_number_repair
tests still pass (no regression).
_resolve_source now prefers the album's pinned canonical (source, album_id) when
set, before the source-priority walk. So once an album's canonical is resolved,
reorganize agrees with Track Number Repair (Stage 4) and stops mislabelling a
standard album as deluxe (#767-Bug2).
Gated + side-effect-free: only changes behavior for albums that already carry a
canonical (none do until the populate step runs), an explicit user source pick
(strict_source) still wins over the canonical, and a failed canonical fetch
falls through to today's priority walk. So this stage is behavior-neutral until
canonical is populated.
Tests: tests/test_reorganize_canonical_source.py (4) — canonical preferred over
priority, fetch-failure falls back, strict_source ignores canonical, no-canonical
unchanged. 113 reorganize-orchestrator/tag-source/unknown-artist tests still pass
(no regression).
Completes Stage 2's populate path. Still dormant — no consumer calls it yet.
- resolve_and_store_canonical_for_album(db, album_id, ...): loads the album's
source IDs + its tracks' (duration_ms, title) from the DB via the SAME
loader the Reorganizer uses (load_album_and_tracks + _extract_source_ids), so
the canonical is chosen over exactly the source IDs the reorganizer sees;
scores off the DB track rows (the library's view of the files — no per-file
disk reads), resolves the best fit, and persists it. Returns the stored result
or None when unresolved.
- default_fetch_tracklist(): production fetcher wrapping
get_album_tracks_for_source, normalising to {title, track_number, duration_ms}
(duration best-effort; sec->ms; absent -> scorer leans on count+title).
Design note: chose LAZY resolution (Stages 3-4 consumers call this when they hit
an album with no canonical) over a standalone backfill repair job — no new
scheduling/UI surface, resolves only when a tool actually needs it, and stays
gated (NULL canonical = today's behavior).
Tests: tests/test_canonical_orchestration.py (5) — end-to-end on a real temp DB
(11 files pick the 11-track release over a 17-track deluxe and persist it),
no-source-ids -> None, missing-album -> None, and default_fetch_tracklist
normalization (dict items, seconds->ms) + failure -> None. All canonical +
DB-migration tests green.
Turns the Stage-1 scorer into an end-to-end resolver + persists the result.
Still DORMANT — no consumer reads it yet, so zero behavior change.
- core/metadata/canonical_resolver.py — resolve_canonical_for_album(): builds
candidate releases from the album's per-source IDs (in source-priority order),
fetches each tracklist via an INJECTED fetch_tracklist (so it's unit-testable
without live APIs), scores them with pick_canonical_release, and returns the
best-fit {source, album_id, score}. Skips sources with no id / failed fetch;
returns None when there are no files, no candidates, or nothing clears the
confidence floor.
- database/music_database.py — set_album_canonical() / get_album_canonical()
write/read the Stage-1 columns. get returns None when unresolved, which every
consumer will treat as "fall back to today's behavior".
Tests: tests/test_canonical_resolver.py (7) — best-fit beats priority, priority
breaks true ties, skips missing-id/failed-fetch sources, None on
no-candidates/no-files/below-floor, score rounding. tests/test_canonical_db.py
(4) — set/get round-trip incl. timestamp, unresolved -> None, overwrite,
missing-album -> False. 34 canonical + DB-migration tests pass.
Remaining for Stage 2 (the trigger): read on-disk file durations/titles for an
album, gather its source IDs, call the resolver, store — wired via a backfill
repair job + an enrichment hook. Then Stages 3-4 wire the Reorganizer and Track
Number Repair to READ the pinned canonical.
First stage of the canonical-album-version fix (#765 + #767-Bug2). Pins ONE
canonical (source, album_id) per album, chosen by best-fit to the user's actual
files, so the Reorganizer, Track Number Repair, and tagging stop re-resolving
independently and contradicting each other.
Ships DORMANT — nothing reads or writes the new data yet, so zero behavior
change. Later stages populate (Stage 2) and consume (Stages 3-4) it.
- core/metadata/canonical_version.py — pure scorer (the testable heart):
score_release_against_files() rates a candidate release by track-count fit +
duration alignment (greedy nearest within ±3s) + title overlap, dropping and
renormalizing missing signals so it never crashes on sparse metadata.
pick_canonical_release() takes candidates in source-priority order, picks the
best fit, breaks ties toward the earlier (higher-priority) candidate so the
choice is DETERMINISTIC — that determinism is what makes every tool agree
(#765), while count/duration fit picks the right EDITION (#767-Bug2). A
confidence floor (default 0.5) means a low-confidence guess is never pinned.
- database/music_database.py — additive, nullable columns on albums
(canonical_source / canonical_album_id / canonical_score /
canonical_resolved_at), guarded by the existing PRAGMA-table_info pattern.
NULL = unresolved = every consumer falls back to today's behavior.
Tests: tests/test_canonical_version.py (11) — edition discrimination (11 files
-> standard, 17 -> deluxe), deterministic priority tiebreak, duration
disambiguation on count ties, graceful degradation (no durations / counts only /
fuzzy titles), confidence floor, empty-input safety. tests/test_canonical_
columns_migration.py (4) — fresh DB has the columns, they're nullable w/ NULL
default, migration is idempotent, and it ALTERs them onto an old albums table.
60 DB/schema regression tests still pass.
A source row with no art of its own (e.g. a YouTube source, which provides
none at mirror time) now borrows the cover from its MATCHED server track, so
both sides of the sync editor show an image.
The endpoint already had a borrow fallback (_server_art_map), but it matched by
an exact normalized "{artist}|{title}" key — so a YouTube-shaped row like
"Arctic Monkeys - Do I Wanna Know?" never matched the library's "Do I Wanna
Know?" and stayed blank even though the server had the cover. This borrow is
keyed off the ACTUAL source<->server pairing the reconcile already computed, so
it works for those rows once #768's canonical matching pairs them.
Done in the pure reconcile_playlist (final pass), so no frontend change is
needed — the editor already renders source_track.image_url. Guarded so it only
fills an EMPTY source image (Spotify/CDN art is never overwritten) and only when
the matched server track actually has a thumb.
Composes with the rest: #766 made the server cover URL work, #768 made the
YouTube row match, this makes the matched source row borrow that cover — so an
artless YouTube row matched to a Navidrome track with art shows on both sides.
Tests: tests/test_playlist_reconcile.py (+4) — artless source borrows the
matched cover; source with its own art keeps it; unmatched source has nothing to
borrow; borrow skipped when the server track has no thumb. 15 reconcile + 59
sync/navidrome tests pass.
The sync editor renders server covers as <img src="/api/navidrome/cover/{id}">,
but no Flask route ever served that path — so every Navidrome cover 404'd, on
every album, art or not. The source (left) side then went blank too: a source
row with no native art (e.g. YouTube, which provides none at mirror time) falls
back to borrowing the matched server track's cover — i.e. that same dead route.
So both sides collapsed to nothing.
Fix:
- New NavidromeClient.build_cover_art_url(cover_id) — builds the absolute,
Subsonic-authenticated getCoverArt URL (base_url + token/salt), keeping
credentials server-side. Uses a FIXED cover-art salt so the URL is
deterministic for a given (server, password, cover_id): a rotating salt (as
in _generate_auth_params) would make every request a unique URL → image-cache
miss every time + a dead, never-reused cache row per fetch. Token auth doesn't
require a unique salt, and the password is never exposed (only its salted md5).
- New route /api/navidrome/cover/<cover_id> — resolves that URL and streams the
image through the shared image cache (same pattern as /api/image-proxy), with
a private max-age so the browser caches by the stable route URL.
Effect: server side works for any album that has art in Navidrome; matched
source rows with no native art now borrow the (now-working) server cover.
Unmatched YouTube rows stay blank — no image exists anywhere to show.
Tests: tests/test_navidrome_cover_url.py (8) — URL structure + salted-token auth
(never the raw password), determinism (same id -> same URL so the cache hits;
different id/password -> different URL), optional size, and the not-connected /
no-id / no-credentials guards.
Caveats: not executed against a live Navidrome (no server in CI) — the URL
builder is unit-tested; the route's cache→HTTP→bytes round-trip is read-verified
only. Scope is the sync editor's Navidrome route; Plex/Jellyfin server-cover
branches and any other modals using a different mechanism are untouched.
The reorganize preview (dry run) was physically creating destination album
folders, littering the library with empty dirs and making "changes" before the
user ever hit Apply.
Cause: preview_album_reorganize calls build_final_path_for_track purely to
COMPUTE the destination path string — but that shared helper has 9 os.makedirs
side effects (it's also the live download/import path builder, where creating
the dir is correct). So computing the preview path created "Lenka (Expanded
Edition)/" on disk.
Fix: build_final_path_for_track gains create_dirs=True; all 9 makedirs now route
through a gated helper. The reorganize PREVIEW passes create_dirs=False, so a
dry run computes the exact destination path with zero filesystem side effects.
Everything else keeps the default True:
- the download/import post-process flow (still writes files into the dir),
- retag,
- the reorganize APPLY path — verified it goes through post_process_fn (the real
pipeline → build_final_path_for_track with create_dirs=True), so live moves
still create their destination dirs. The gate only silences the dry run.
Tests: tests/imports/test_import_paths.py — create_dirs=False computes the
correct path (matching the reported "01 - The Show.flac") but writes NOTHING to
disk (not even the Transfer root); create_dirs=True still creates folders; both
yield an identical path. Updated two reorganize-orchestrator test doubles to
accept the new kwarg. 148 reorganize/paths/retag/pipeline tests pass.
Does NOT fix the second half of #767 (Expanded Edition picked over the standard
album). That is NOT a reorganizer bug: the library album row was linked to the
deluxe release at enrichment time (its stored spotify_album_id/itunes_album_id/
deezer_id points at "Lenka (Expanded Edition)"), and the reorganizer faithfully
reorganizes to whatever the album is linked to. The real fix is in album
enrichment's edition preference — tracked separately.
Three compounding bugs hit tracks whose source metadata is YouTube/streaming-
shaped — title "Artist - Song", artist "Official Artist"/"Artist - Topic"/
"ArtistVEVO" (reported: "Arctic Monkeys - Do I Wanna Know?" by "Official Arctic
Monkeys"). Server-agnostic — affects Plex/Jellyfin/Navidrome, not just the
reporter's Navidrome.
Bug A — the match fails. The confidence scorer and the editor's reconcile both
compared the raw "Artist - Song" title against the library's clean "Song"; the
length-ratio penalty + floor drove it to ~0.18 (NO-MATCH), so the track showed
unmatched while its server copy showed as an orphan "extra". New pure
core/text/source_title.py (clean_source_artist / strip_artist_prefix /
canonical_source_track) strips the channel/video decoration, applied at BOTH
matching seams: services/sync_service._find_track_in_media_server (tries raw
then canonical, keeps the best) and the editor reconcile. Conservative: a title
prefix is stripped only when it equals the artist, so "Self-Titled", "Jay-Z",
and "Marvin Gaye" (by another artist) are untouched, and the canonical form is
an additional best-of candidate so it can only help.
Bug B — manual matches never persisted. get_server_playlist_tracks built the
per-source entry WITHOUT source_track_id, so "Find & add" posted an empty id
and _persist_find_and_add_match returned early. The match reverted to "extra"
on reload and re-adding looped. The editor's 3-pass matcher is now lifted to a
pure, tested core.sync.playlist_reconcile.reconcile_playlist that includes
source_track_id (the frontend at pages-extra.js:1836 already reads + sends it).
Bug C — manual match duplicated + delete wiped all copies. "Find & add" always
inserted, so linking a source to an already-present server track appended a
duplicate (pos 72, 73...); remove filtered out EVERY entry with the target id.
New pure core.sync.playlist_edit (plan_playlist_add: link-don't-duplicate when
the target is already present; remove_one_occurrence: drop a single copy) wired
into the Plex/Jellyfin/Navidrome add + remove branches.
Tests (extreme): tests/test_source_title.py (35), tests/test_playlist_reconcile.py
(11 — incl. the reported case, parity for override/exact/fuzzy/extra, and
duplicate-server handling), tests/test_playlist_edit.py (12). 286 matching/sync
tests still pass.
Caveats: the sync_service change and the add/remove/editor endpoints are
read-verified, not executed against a live media server (none in CI). The pure
cores they call are exhaustively unit-tested; output-shape parity of the
reconcile lift is covered. Delete removes the first matching copy (duplicates
are identical, so harmless).
Tracks NOT in the library were matched to a DIFFERENT song by the SAME artist
and reported with high confidence instead of as missing — e.g. "Dani
California" -> "Californication" (Red Hot Chili Peppers), "Under The Bridge"
-> "Around the World".
Root cause: _calculate_track_confidence scores 0.5*title + 0.5*artist. A
same-artist comparison always yields artist = 1.0, so the title score is the
only thing that can tell two of an artist's songs apart — but that score is a
SequenceMatcher CHARACTER ratio, which over-credits unrelated titles that
share a long substring ("californi…" = 0.67) or just a stopword ("the" =
0.62). With the flat 0.5 artist term, anything clearing the weak 0.6 char
floor lands at ~0.81-0.83, well over the 0.7 sync threshold. Reproduced on
dev: both reported pairs score 0.81/0.83.
Fix: new core/text/title_match.py:titles_plausibly_same, called in
_calculate_track_confidence right before the floor. It accepts a pair only
when it's near-identical char-wise (>=0.85, so typos / punctuation / casing
like "Beleive"->"Believe", "HUMBLE."->"Humble" still match) OR the titles
share at least one significant (non-stopword) word. Two different songs by the
same artist share no content word, so they're rejected and the real track is
correctly reported missing. ("the" is a stopword — that's what leaked "Under
The Bridge"/"Around the World".)
Scoped deliberately: the word-overlap test fires ONLY when at least one side
has 2+ content words. For single-word titles there is no other word to share,
so it defers to the existing char floor — otherwise legitimate stylized
spellings ("Grey"/"Gray", "Tonite"/"Tonight", "4ever"/"Forever") would become
new false-negatives. Verified those still match. The few single-word variants
that do score low (Ok/Okay, Thru/Through) were already rejected by the
pre-existing length-ratio penalty, not by this gate.
Both reported false positives now score 0.33/0.31 -> missing. Does NOT address
the harder case of two different same-artist songs that DO share a content
word (e.g. "Believe"/"Believer") — pre-existing and unworsened. Any residual
error fails safe: a false-missing is re-downloaded/wishlisted, vs the old
behavior which silently substituted the wrong song.
Tests: tests/test_title_match_guard.py (14) — pure-guard unit tests + a
13-pair battery driving the REAL _calculate_track_confidence (genuine matches
stay >=0.7, same-artist different songs drop below), plus an explicit
no-regression test for stylized single-word spellings. 292 matching/sync tests
pass.
The manual-import routes (album + singles) call post_process_matched_download
directly. When the pipeline quarantines a file — integrity / AcoustID / FLAC
bit-depth — or hits the race guard, it sets a context flag and RETURNS
NORMALLY (it only marks the task failed + notifies when there's a task_id,
which manual imports don't have). So the inner pipeline raised no exception,
and routes.py counted `processed += 1` for a file that had just been moved to
ss_quarantine, not the library. Result: the UI shows a green "Done" while the
track silently vanished — exactly the #764 report (Coldplay - Yellow.flac ->
ss_quarantine, but "Done").
The download path already handles this in
post_process_matched_download_with_verification (it reads the same flags and
marks the task failed); only the manual-import routes were missing the check.
Fix: new pure helper import_rejection_reason(context) returns a human-readable
reason for any terminal rejection (_integrity_failure_msg / _acoustid_quarantined
/ _bitdepth_rejected / _race_guard_failed) or None for a clean import. Both
manual-import routes now consult it: album_process reports the track in
`errors` instead of counting it processed; process_single_import_file returns
("error", reason) instead of ("ok", ...). Verified every move_to_quarantine
call site (4, all in pipeline.py) sets one of those flags, so no quarantine
path slips through. This also delivers the "direct display of the error" the
reporter asked for — the reason now surfaces in the response `errors` list.
Does NOT address the reverse symptom ("failed even though it moved correctly")
— not yet root-caused — nor the separate bit-depth hole on the download-path
wrapper.
Tests: tests/imports/test_import_rejection_reason.py (10) — each trigger
detected, falsy flags ignored, deterministic ordering, plus two route-level
tests driving the REAL process_single_import_file (quarantine -> "error";
clean -> "ok").
enhance_file_metadata rebuilds tags from scratch: for FLAC it calls
clear_pictures(), for MP3/MP4 it clears the whole tag block — and it does
this UP FRONT, then saves the file, long before it tries to fetch and embed
the replacement art. So every way the re-embed could come up empty left the
file saved with the original art destroyed and nothing put back:
- extract_source_metadata returns nothing -> early save, no embed
- no album-art URL / art download fails / rejected by the min-size guard
-> embed_album_art_metadata returns early without adding a picture
- art embedding disabled in config -> embed skipped entirely
- embed raises mid-enrichment -> file left cleared on disk
This is the "cover art gets corrupted/destroyed during import" half of #764
(continuation of #755); distinct from #750's truncated-cache DISPLAY bug.
Fix: new core/metadata/art_preservation.py snapshots the existing art
(the live Picture / APIC / MP4Cover objects, so they re-apply verbatim)
BEFORE the clear, and restores it before each save IFF the file currently
has none. Wired into all three exit paths in enhance_file_metadata
(no-metadata early return, the final save, and the except handler). The
restore is a strict no-op when art is already present, so the happy path —
new art embedded — is byte-for-byte unchanged: it never clobbers or
duplicates a freshly-embedded cover. embed_album_art_metadata now returns a
bool so the intent (embedded / didn't) is explicit.
Tests:
- tests/test_art_preservation.py (5) — snapshot/restore round-trips through
real mutagen FLAC + ID3 objects; restore no-ops when new art is present.
- tests/test_enrichment_art_preservation.py (4) — runs the REAL
enhance_file_metadata over a real FLAC with embedded art and asserts the
art survives on disk for missing-metadata / failed-embed / embed-raises,
and is correctly REPLACED (exactly one picture, new bytes) on success.
1019 tests pass across the metadata/enrichment/imports/acoustid suites.
The DB-update + deep-scan automation monitor used a hard 2-hour TOTAL cap
(while elapsed < 7200). It tracked progress but only used it to print a stall
warning — the only thing that actually timed out was wall-clock. So a large
library that scans for >2h while progressing fine (reported: 4781 artists) trips
the cap and the automation card flips to 'error: timed out after 2 hours' even
though the scan thread is healthy and still running (the timeout never cancels
it, which is why it keeps progressing in the logs after the 'error').
Time out on STALL, not total runtime:
- 30 min with NO progress -> error ('stalled'); catches a genuinely hung scan.
- 10 min idle -> warning (repeats); unchanged heads-up.
- 24h absolute backstop, purely a runaway-loop guard.
- An actively-progressing scan keeps resetting the idle clock, so it never
times out no matter how many hours the whole library takes.
- Progress is judged on (processed, progress, current_item) so a slow stretch
where the rounded % holds steady (but the artist keeps changing) isn't a
false stall.
The decision is extracted into a pure, testable scan_wait_action(); both the
deep-scan and full-refresh handlers share the monitor loop, so both are fixed.
Tests: tests/test_scan_wait_action.py (9) — headline regression (5h/12h total
but progressing -> 'continue', not timeout), finished/stall-warn/stall-timeout/
abs-cap thresholds, and ordering. 280 automation tests still pass.
Defense-in-depth follow-up to #760. Even with the entrypoint chown fix, if the
album-bundle staging dir ever can't be created/written (permissions, read-only
mount, disk full), the dispatch caught the plugin exception and marked the whole
batch failed — even though the album had already downloaded (the #715 symptom:
'release finishes downloading but the batch fails').
Now an OSError from the plugin is flagged fallback-eligible, so the dispatch
returns to the per-track flow instead of hard-failing. OSError covers the
staging/filesystem failure that motivated this (#760's PermissionError) and, by
Python's IOError==OSError aliasing, any propagated transient I/O error —
falling back is never worse than hard-failing, and per-track is the universal
graceful path. Programming errors (TypeError, KeyError, RuntimeError, …) are
NOT OSError and stay terminal, so genuine bugs still fail loudly — the existing
'plugin exception => failure' contract and its test are preserved.
Test: new test_dispatch_staging_oserror_falls_back_to_per_track (PermissionError
on the staging dir -> result False, phase 'analysis', not failed). Existing
RuntimeError-is-terminal test still passes. 131 album-bundle/plugin tests green.
The album-bundle staging area /app/storage is baked into the image owned by the
build-time soulsync UID. The entrypoint only re-chowned it to the runtime PUID
inside the GATED recursive chown (entrypoint.sh:43), which is skipped whenever
/app/data is already owned correctly — and /app/storage was missing from the
UNCONDITIONAL per-start chown (line 85). So on installs whose PUID differs from
the build UID and whose /app/data is already correct, /app/storage kept its
build ownership and wasn't writable, and the Soulseek album-bundle flow died
with:
PermissionError: [Errno 13] Permission denied: 'storage/album_bundle_staging'
(/app/Stream was added to the unconditional chown after this exact bug;
/app/storage slipped through.)
Add /app/storage — plus /app/MusicVideos and /app/scripts, which were also
missing — to the unconditional mkdir+chown (lines 84-85) and the writability
audit (line 92), matching the Dockerfile's pre-baked dir list. /app/storage is
now chowned to the runtime PUID on every start regardless of the gated
recursive chown. Verified with bash -n; all four dir lists are now consistent.
After an update, installs became unusable: the Amazon enrichment worker runs by
default, the default public T2Tunes proxy (t2tunes.site) was returning
503 'Amazon Music API is not initialized', and the worker treated every album
as an individual error -- logging an ERROR per item, churning network + DB
continuously across the whole library, and marking every row 'error' (a state
the retry tiers never re-attempt, so even after the proxy recovered nothing
re-enriched). The reporter couldn't reach the UI to turn it off.
Two-part fix:
1. Source-outage circuit breaker (core/amazon_outage.py, pure + tested):
- is_source_outage(exc) distinguishes a whole-source outage (HTTP 5xx,
'not initialized', connection failure, non-JSON error page) from a real
per-item miss (404, transient 400, etc.).
- On an outage the worker now leaves the item UNTOUCHED (so it's retried once
the proxy recovers instead of being permanently burned to 'error'), logs
ONCE per streak, and backs off with next_poll_delay_seconds() -- escalating
30s -> 60s -> ... capped at 30 min -- instead of grinding every 2s. It
auto-resumes the normal cadence the moment the source answers (success OR a
non-outage error both clear the streak).
- AmazonClientError now carries status_code so detection doesn't rely on
message parsing.
2. Opt-in by default (web_server.py): amazon_enrichment_paused now defaults to
True. Because enrichment depends on an external public proxy that can be
down, it stays paused unless the user explicitly enables it -- a proxy outage
can no longer take down installs that never opted in. (Behaviour change:
anyone on the old auto-on default is now paused; re-enable in Settings.)
Together: on update the worker is paused -> no flood -> UI accessible; opted-in
users are protected from future outages by the breaker.
Tests: tests/test_amazon_outage.py (12) pin the classifier across every error
surface (incl. the exact 503 'not initialized' case) and the back-off schedule
(monotonic, capped). 157 Amazon tests pass; lint clean.
Note: could not reproduce the exact 'UI fully unreachable' mechanism remotely
(WAL + 8 gthreads shouldn't hard-lock); the fix removes the flood/churn that is
the practical cause and defaults the feature off.
The Duplicate Detector's 'Keep Best' auto-selection ranked copies by highest
bitrate -> duration -> track number, with no notion of format. A FLAC whose
bitrate the library scan never populated (a common gap) therefore lost to a
282 kbps MP3: 282 > 0, so the MP3 was kept and the FLAC deleted (reported on
Havok 'Prepare For Attack', and again on Kendrick GNX).
Fix: rank by format/lossless tier FIRST, then bitrate, duration, track number.
A lossless file now always beats a lossy one regardless of the recorded
bitrate; bitrate/duration/track# only break ties within the same format.
- core/library/duplicate_keep.py (new): pure, importable pick_duplicate_to_keep
+ duplicate_keep_sort_key + format_rank_for_path (extension rank mirroring
auto_import_worker._quality_rank: flac=10 ... mp3=5 ... unknown=1).
- core/repair_worker.py: _fix_duplicates auto-pick now calls
pick_duplicate_to_keep instead of the bitrate-first max().
- webui/static/enrichment.js: the KEEP/REMOVE recommendation mirrors the same
format-first ranking so the badge matches what the backend will delete.
Parity: Python uses '.ext' keys (os.path.splitext), JS uses 'ext'
(split('.').pop()) -> identical results; both keep the first copy on a full
tie. Verified the only other dedup path (the standalone Duplicate Cleaner
automation, core/library/duplicate_cleaner.py) was already format-priority-first
and correct -- no change needed there.
Tests: tests/test_duplicate_keep.py (11 -- incl. the exact FLAC-with-missing-
bitrate vs 282 kbps MP3 case, format ranking, within-format tie-breakers, and
edge cases). 147 repair/duplicate tests still pass.
Note: why FLAC bitrate is NULL in the DB is a separate library-scan gap;
format-first ranking makes the keep decision correct regardless.
Follow-up to the preferred-art feature. Real test runs showed a source could
win on priority while handing back a small cover: Cover Art Archive is
volunteer-uploaded with no size floor, so CAA-first gave a 599x531 (Taylor
Swift) and a 600x600 (Kendrick GNX) -- front-1200 only caps the max, so a
~600px upload stays ~600px -- and Deezer/iTunes lower in the order never got a
turn.
Fix:
- Minimum-resolution guard: artwork._min_size_art_validator builds the
resolver's validate hook -- it fetches each candidate, caches the bytes (so
the winner isn't fetched twice), and accepts art only when its shortest side
>= metadata_enhancement.min_art_size (default 1000px; 0 disables). Art that's
too small is a miss, so the resolver falls through to the next source instead
of winning on priority. Unmeasurable images are accepted (don't over-reject;
fallback is still today's art). Wired into both embed_album_art_metadata and
download_cover_art.
- iTunes art upgraded to /3000x3000bb/ (was the 600px default) so it
contributes high-res when it wins.
- select_preferred_art_url gains a validate passthrough to the resolver.
- config default metadata_enhancement.min_art_size: 1000.
Effect: with an order like caa > deezer > spotify > itunes, a ~600px CAA upload
is now skipped and Deezer's ~1900px wins -- consistent big art. (Spotify art
often maxes ~640px, so it's skipped at the 1000 floor in favor of bigger
sources; lower min_art_size to ~640 to allow it.)
Tests: tests/metadata/test_art_min_size.py (6 -- incl. the real 599x531 and
600x600 cases, shortest-side logic, unmeasurable-accept, no-bytes-reject,
0-disables) + iTunes max-res upgrade test. Full metadata suite green (617).
Lets users pick which providers' cover art to use and in what priority,
generalizing the single prefer_caa_art toggle into an ordered, mix-and-match
list (Sokhi's request). Fully opt-in: default album_art_order is [], so every
existing install is byte-for-byte unchanged until the user enables sources.
How it works:
- Per album, walk the user's ordered sources top-to-bottom; the first source
that actually has THIS album's cover wins. A miss falls through to the next;
if all miss, the download's own art is kept (today's default). The worst case
is always exactly the cover you'd get today -- never wrong art, never an
error into the download.
- Connection-gated: a source is only tried when the user is connected to it
(free sources CAA/Deezer/iTunes/AudioDB always; Spotify only when
authenticated). Tidal/Qobuz/HiFi deferred (cover-URL construction + no clean
core accessor -- not shipping unverified extraction).
- Album-match validated: a source's art is used only when the album it returns
matches the requested artist+album (significant-token subset, tolerant of
Deluxe/Remastered/articles/feat./multi-artist). A loose top search hit for a
different record is treated as a miss -> guarantees no wrong-album art.
- The list supersedes the legacy prefer_caa_art toggle: when album_art_order is
non-empty it is the sole authority (add 'caa' to the list to use Cover Art
Archive), and prefer_caa_art is neutralized for both the embedded-tag art and
cover.jpg paths. With an empty list, prefer_caa_art behaves exactly as before.
Implementation:
- core/metadata/art_sources.py: pure resolver -- effective_art_order (config +
legacy back-compat) and resolve_cover_art (ordered walk + fallback,
exception-safe per source). No network/config/DB; fully unit-testable.
- core/metadata/art_lookup.py: availability gating, per-source lookups against
existing clients (Deezer/iTunes/AudioDB/Spotify search + CAA via MBID),
album-match validation, per-album caching, and select_preferred_art_url --
the single gate the pipeline calls (no-op unless an explicit list is set).
- core/metadata/artwork.py: wired into embed_album_art_metadata and
download_cover_art, gated so no configured list == current behavior.
- web_server.py: GET /api/metadata/art-sources (connected sources only).
- config/settings.py: default album_art_order: [].
- webui (index.html + settings.js): reorderable list in Core Features reusing
the hybrid-source-list pattern + real service logos (with emoji fallback);
load/save wired through the existing metadata_enhancement settings flow.
loadArtSourceOrder populates the saved order synchronously (filtered to known
sources, not availability) so a save before the availability fetch resolves,
or a temporarily-disconnected source, can never wipe the saved order.
Tests: 40 unit/seam tests (resolver ordering/fallback/back-compat, availability,
per-source extraction, album-match validation incl. wrong-album/wrong-artist
rejection, caching, exception-safety, the off-by-default gate). Full metadata
suite still green (610 passed) -- the gated integration changes nothing when no
list is configured.
Note: the settings UI (DOM-heavy, not unit-testable in the JS harness) and the
live per-source art-fetch quality are validated by manual testing.
A torrent-first (or usenet-first) hybrid download would freeze at
"Torrent searching for release 0%" and never move to the next source when
Prowlarr returned no results for the album. Reported by Cezar:
[Album Bundle] torrent flow failed for '...': No torrent results found
Cause: the album-bundle dispatch only returns to the per-track flow (which,
in hybrid mode, tries the next configured source) when the plugin's failure
outcome carries fallback=True; otherwise it marks the batch failed and stops.
Both plugins set fallback=True on their 'results found but none matched the
album' branch, but the adjacent 'no results at all' branch set only an error
and no fallback flag -- so zero results hard-failed while wrong results fell
back. Backwards, and soulseek's plugin already defaults fallback=True for
exactly this reason.
Fix: set fallback=True on the no-results branch in torrent.py and usenet.py.
The dispatch's fallback handling (return False -> per-track flow) was already
correct and is unchanged.
The only consumer of download_album_to_staging is the dispatch, which reads
the result via .get('fallback'), so the change is additive and locally
contained.
Tests: new test_torrent_album_to_staging_no_results_flags_fallback and
test_usenet_album_to_staging_no_results_flags_fallback assert the plugins now
emit fallback=True on an empty search; the existing torrent no-results test is
extended with the same assertion. Existing dispatch tests already pin
fallback=True -> per-track flow. Full downloads/plugins/adapters sweep: 690
passed.
The preview modal looked amateur and its header/footer clipped on long
playlists (wolf39's 316-track "Road trip" showed neither title nor buttons).
Root cause of the clip: .mm-list (the scroll area) was a flex child with
flex:1 but no min-height:0. Flex items default to min-height:auto, so the
list refused to shrink below its content, the modal blew past max-height,
and overflow:hidden + vertical centering pushed the header off the top and
the footer off the bottom. Now the list has min-height:0 and the hero +
action bar are flex-shrink:0, so they stay pinned and the list scrolls.
Visual revamp to match the rest of the app, using data already returned by
/api/mirrored-playlists/<id> (image_url on the playlist and each track):
- Hero uses real artwork (playlist cover -> first track art -> gradient
fallback) with a blurred art backdrop + darkening overlay, replacing the
emoji-in-a-box. Eyebrow + large title + meta line (source pill, owner,
track count, total runtime, mirrored-ago).
- Track rows gain per-track album thumbnails, two-line title/artist, album,
duration, and a sticky column header. Missing art falls back to a gradient
tile via onerror (no broken-image icons).
- Cleaner action bar: primary Discover, secondary Auto-Sync, ghost Edit/
Close, quiet danger-outline Delete.
Old .mirrored-modal-* / .mirrored-track-* / .mirrored-btn-* classes removed
from style.css and replaced with the new .mm-* set; the _escJs escaping in
the footer buttons (apostrophe fix) is preserved.
A mirrored playlist named with an apostrophe (e.g. "Road trip-The
Rolfe's") rendered dead action buttons. _escAttr HTML-escapes ' to ',
but it was used to inject the name into a single-quoted JS string inside an
inline onclick. The HTML parser decodes ' back to a bare ' BEFORE the JS
parser runs, producing an unterminated string literal -> SyntaxError -> the
whole handler fails to compile.
Two symptoms (both reproduced with the real name + the literal line-524
onclick template): clicking the X delete never ran event.stopPropagation(),
so the click bubbled to the card and opened the track preview instead; and
the preview's "Delete Mirror" silently did nothing (no DELETE request, no
log). Plain names ("Classic Rock") were unaffected, which is why it looked
intermittent.
Add a dedicated _escJs() that backslash-escapes the JS metacharacters (\, ')
first, then HTML-escapes the attribute-breaking chars - correct for a
single-quoted JS string inside a double-quoted HTML attribute. Convert all 16
inline-onclick string-argument sites to it: mirrored card (clear/Auto-Sync/
link/delete) and preview modal, plus the same latent bug in pool Fix Match /
Rematch, group bulk-toggle/rename/delete, and automation history/group/delete.
Genuine HTML-attribute usages (class/value/data-*/title/option) stay on
_escAttr where it is correct.
Tests: tests/static/test_stats_automations_esc.mjs extracts the real _escJs/
_escAttr from source and asserts apostrophe + quote/backslash/&/<> names
round-trip through HTML+JS decoding, documents that _escAttr throws a
SyntaxError for the apostrophe case while _escJs compiles clean, and pins
wolf39's exact name. pytest shim tests/test_stats_automations_esc_js.py runs
it under node --test (skips if node<22 / absent).
Several tests exercise modules (e.g. album_mbid_cache) that call get_database()
with no path, which resolves to the real database/music_library.db. Running
those writers against the live DB over a WSL-mounted Windows drive corrupted a
user's library. conftest never isolated the DB path.
conftest.py now sets os.environ['DATABASE_PATH'] to a throwaway temp file at
import time (before any test module loads). MusicDatabase resolves its default
path from DATABASE_PATH, so EVERY default-path MusicDatabase()/get_database()
call in the suite now lands in /tmp — the real DB is never opened. Temp dir is
removed at exit.
Guard tests assert DATABASE_PATH, MusicDatabase().database_path, and
get_database().database_path all resolve into the temp dir and never to
database/music_library.db. (album_mbid_cache tests, which were failing against
the corrupted live DB, now pass clean on the isolated temp DB.)
Nothing was landing in the metadata cache browser because the
metadata_cache_entities / metadata_cache_searches tables did not exist, so every
cache write no-op-ed. Root cause: _add_metadata_cache_tables short-circuited on a
marker-only guard (if the metadata_cache_v1 marker row exists, return). After a
DB corruption-recovery the small metadata table (with the marker) survived but
the large cache tables did not, so the stale marker permanently blocked the
idempotent CREATE TABLE IF NOT EXISTS and the cache was dead forever.
Guard now skips only when the marker is set AND the tables actually exist, so a
stale marker self-heals: the tables are re-created on the next init.
Tests: marker present but tables dropped -> re-created; marker + tables present
-> no-op (idempotent).
When no media server is connected, discovery/sync patches sync_service's
matcher with a database-only implementation. sync_service calls it as
_find_track_in_media_server(track, candidate_pool=...) (a per-artist candidate
cache), but the database-only override took only (spotify_track) — so every
sync raised 'database_only_find_track() got an unexpected keyword argument
candidate_pool' and aborted.
Lift the override from a nested closure to a module-level
_database_only_find_track(spotify_track, candidate_pool=None) so it (a) accepts
the kwarg for interface parity with the real matcher and (b) is importable and
unit-testable. The DB-only path queries the library directly via
check_track_exists, so it accepts but doesn't need the candidate cache. Also
dropped the dead original_find_track local.
Tests: signature includes candidate_pool; called with candidate_pool={} returns
(None, 0.0) on no match; returns the match when the DB has it.
The per-track list inside an expanded batch was a cramped flat row with a faint
title and a -2px progress-bar hack, and the nested scrollbar sat on top of the
text. Reworked:
- Each row is now a grid: track number · title (+ artist sub-line) · right-aligned
state, with hover, tabular-aligned numbers, per-row state coloring (✓ green /
✗ red / % accent / dim queued / strikethrough cancelled), and a clean full-width
progress bar beneath downloading rows.
- Track list gets right padding + a thin, subtle scrollbar so it no longer
clips titles; same thin-scrollbar treatment on the panel itself.
- Panel widened 340->366 with rebalanced side padding for more readable content.
Collapsed-panel behavior unchanged.
Takes the Active Downloads batch panel from flat cards to a glanceable,
information-rich view:
- Sticky aggregate summary strip: 'N batches · X downloading · Y queued · speed · ~ETA'.
- Segmented progress bar per batch — proportional done (green) / failed (red) /
active (accent, animated shimmer) / remaining, so the state reads at a glance
instead of one dim fill.
- Colored stat chips (✓ done · ✗ failed · ↓ active · queued) + a per-batch ETA
from a client-side completion-rate sampler (album bundles use the downloader's
own speed/size). No backend changes — Phase A is frontend-only.
- 'Now downloading' line showing the live track on active batches.
- Expand chevron affordance (rotates when open); subtle phase tinting.
- Polished empty state with quick-start links (Search / Sync / Wishlist).
Card actions (filter / cancel / open-modal / expand) and the fade/history
behavior are unchanged. ETA/speed for non-bundle batches and a retry-failed
action are Phases B/C (backend).
The 🎵 cover placeholder (and the empty provenance block) stayed visible even
when JS set hidden, because .td-thumb-ph / .td-provenance set display:flex,
which a class selector applies over the browser's [hidden] { display:none }.
Scope a winning rule (#track-detail-overlay [hidden] { display:none !important })
so toggled-off elements actually disappear — the cover shows alone when present.
Clicking a track row in the download modal now opens a polished detail modal
(its own template, webui/track-detail-modal.html, included into index.html;
behavior in static/track-detail.js): cover, title/artist/album, status badge,
in-app play, source, quality, AcoustID verdict, file location, and the
expected-vs-downloaded provenance — backed by /api/downloads/task/<id>/detail.
It adapts by status:
- completed -> play (library stream) + full provenance
- quarantined-> reason + Listen (quarantine stream) + Accept & Import + Search
- failed/not_found -> reason + Search
This absorbs the standalone quarantine chooser, which is removed (its
Listen/Accept/Search live here now, with the same Windows file-handle release
before Accept and the thin-sidecar -> Recover-to-Staging fallback). Plain
failed/not-found rows still go straight to the search modal; sync-import modal
unaffected. Status cells clear their clickable/detail state each render so a row
that flips to completed isn't left with a stale handler.
New GET /api/downloads/task/<id>/detail merges the live download task with its
library_history row (the data the Download History cards show) into one payload
the upcoming track-detail modal renders: status kind, title/artist/album,
source, quality, final location, AcoustID verdict, and expected-vs-downloaded.
Assembly + status classification live in core/downloads/track_detail.py as a
pure, importable build_track_detail()/classify_status_kind() (9 unit tests);
the endpoint is thin glue that looks up the matching history row by track.
An invalid API key (and rate limits / missing chromaprint / fingerprint
failures) all collapsed into the same None as a genuine no-match, so:
- every download showed a benign 'AcoustID: Skipped', and
- the 'Test API key' button reported a dead key as VALID
because test_api_key trusted 'no exception = valid' but fingerprint_and_lookup
swallows the error and returns None. A broken AcoustID setup looked completely
normal — which cost a real debugging session to untangle.
- New AcoustIDClient.lookup_with_status() returns a structured result that
distinguishes ok / no_match / error / no_backend / fingerprint_error /
unavailable. fingerprint_and_lookup() stays dict-or-None (library scanner /
auto-import / their tests unchanged) as a thin wrapper over it.
- verify_audio_file() uses it: a real error -> new VerificationResult.ERROR
(-> _acoustid_result='error' -> the existing red 'Error' history badge),
a genuine no-match -> SKIP 'No match in AcoustID database'. ERROR never
quarantines (an outage/bad key must not punish good files).
- test_api_key() now validates via the authoritative direct API call (error
code 4 = invalid key) instead of the swallowed-exception path.
Tests: structured-status distinction, legacy-wrapper contract, verify ERROR vs
SKIP, and test_api_key invalid-vs-accepted. Existing verify tests updated to
stub lookup_with_status (a stub returning just recordings is inferred as ok).
The actions-column Approve button (approveQuarantineFromDownloadRow) POSTed
/approve without a task_id, so it took the inner-pipeline path and never marked
the task completed — the row stayed 'Quarantined' even though the file imported.
The chooser's Accept was already fixed; this brings the inline button in line:
it now carries data-task-id and sends task_id, so the re-import runs through the
verification wrapper and the row flips to Completed on success.
Accepting a quarantined item re-imported the file correctly, but the download
modal kept showing 'Quarantined'. The re-import ran through the inner pipeline,
which doesn't mark task completion (that's the verification wrapper's job), and
the sidecar context had no task_id anyway (popped before quarantine).
The chooser's Accept now sends the originating task_id, and the endpoint
re-runs the import through the verification wrapper with that task_id (+ batch_id
looked up from the task), so the task is marked completed only after the file is
verified moved — the row flips to Completed on the next poll. Manager-tab
approvals (no task_id, no JSON body — handled via get_json(silent=True)) keep
the original inner-pipeline path.
Also clear has-candidates + the quarantine dataset on every status render so a
row that goes quarantined -> completed doesn't keep a stale chooser attached.
Clicking a quarantined track's status used to open the generic search modal,
identical to a plain failure — no way to review or recover the file. It now
opens a chooser:
- Listen: streams the file in-app via a new /api/quarantine/<id>/stream
endpoint (range-supported; the real audio Content-Type is recovered from the
sidecar since the on-disk file ends in .quarantined).
- Accept & Import: existing /approve (restore + re-import, gates bypassed).
- Search for a different result: the existing candidates modal (old behavior).
Non-quarantine failures (not_found / failed / cancelled) are unchanged — a
single click listener routes by dataset set at render time, so a task that
fails then later quarantines can't end up double-bound.
Also fixes the Accept failure on Windows: the Listen stream holds an open file
handle, so the subsequent restore move hit WinError 32 ('file in use') and the
endpoint mislabeled it 'thin sidecar'. Accept now releases the audio handle
before approving, and approve/recover moves retry briefly on transient OS locks
(_move_with_retry). Accept also auto-falls-back to Recover-to-Staging for
genuinely thin/orphaned sidecars.
Tests: stream-info resolution (sidecar + filename-fallback + missing), and
_move_with_retry success/give-up.
post_process_matched_download_with_verification pops task_id/batch_id out of
the context before running the inner pipeline (so the inner doesn't fire its
own task notifications). But _mark_task_quarantined runs inside that inner call
and reads context['task_id'] — which is now None — so it silently no-op'd.
Result: every download through this wrapper (album-bundle / staging path)
quarantined WITHOUT recording quarantine_entry_id on the task, so the UI had no
handle to manage the file (the status click just fell back to the search modal).
_mark_task_quarantined now also stashes the entry id on the context (survives
the pop), and the wrapper applies it to the real task in both quarantine
branches (integrity + AcoustID). Direct (non-wrapper) callers are unchanged.
Tests: unit coverage for the stash-with/without-task_id behavior, plus a
wrapper-level test proving the entry id reaches the task on integrity quarantine.
HiFi assembles FLAC from HLS segments and demuxes with `ffmpeg -c copy`,
which preserves total_samples=0 in the STREAMINFO of Tidal's fragmented/
streamed FLAC. Every audio frame is present and the file plays fine, but
mutagen computes length = total_samples / sample_rate = 0, so the integrity
check rejected it as 'zero-length audio' and quarantined nearly every HiFi
download. Users confirmed the quarantined files play normally once restored.
Length 0 is not proof of corruption at that point: the file already passed
the size gate, was identified as a real audio format, and has a valid info
block — a genuinely empty/truncated/stub file fails one of those earlier
checks instead. Treat length 0 as 'length unknown': accept the file and skip
the duration cross-check we can't perform without a length. mutagen never
decoded/validated frame data anyway, so this doesn't weaken real corruption
detection — size, parse, format, info-block, and duration-drift guards all
remain.
Tests: a large valid-parse length-0 file (streamed-FLAC signature) is now
accepted; a tiny length-0 stub still fails (size gate fires first).
A Soulseek album bundle stages whichever single folder scored best. If that
folder doesn't contain every track the album needs, the missing tracks were
marked not_found with no fallback — even in hybrid mode where later sources
(Deezer, YouTube, etc.) could fill them. The staging-miss short-circuit fired
for Soulseek because 'soulseek' was lumped into the torrent/usenet source set
when album bundles were added, and album_bundle_partial only reflects whether
the files found IN the folder downloaded, not whether the folder had every
needed track.
Drop 'soulseek' from the short-circuit (keep torrent/usenet). A track not
claimed from the staged Soulseek folder now falls through to the normal
per-track Soulseek search and, in hybrid mode, onward down the configured
chain. Unlike torrent/usenet — where per-track search re-adds the same
release — Soulseek per-track search is a genuine per-file network search, so
this is correct and cheap. Realizes the original author's stated intent
('keep partial bundles from blocking per-track fallback') robustly, since the
partial flag couldn't detect a folder that was simply missing tracks.
Only affects tracks NOT claimed from staging — fully-staged albums claim every
track via try_staging_match and never reach this gate, so working albums are
unchanged. Likely also mitigates #755 (all-album-import failures now fall
through to per-track instead of dying).
Tests: rewrote the two Soulseek staged-miss tests to assert fall-through
(single + hybrid-first); kept the torrent guard; added a usenet guard test.
The user-facing Search-for-Match / Fix popup runs non-strict MB searches.
That path built a bare "track artist" query with no field scoping, so the
artist was just a free fuzzy term — covers and karaoke whose TITLES contained
the artist name outranked the canonical recording. Reproduced live: searching
"Say You Will" / Foreigner returned cover artists with Foreigner absent, and
"Sweet Child O Mine" / Guns N Roses returned only covers (Presnyakov, PMJ…),
never the Guns N' Roses original.
Keep the track/album side loose (no phrase quotes → diacritic + bracketed-
suffix recall, the reason non-strict exists) but field-scope the artist as
artist:(...) so it constrains. The artist value is Lucene-escaped via
_escape_lucene() — without it, names like "Sunn O)))" or "Anthony Green
(Saosin)" would close the artist:( group early (returning unrelated artists)
or break the query (zero results). Same fix applied to search_release.
Verified against the live MB API: both reporter queries now return the real
artist top-to-bottom; diacritic recall is preserved (artist:(Bjork) folds to
Björk); and paren/?/!-laden artist names produce valid, balanced queries.
Tests pin the constructed query string (no network): non-strict scopes and
escapes the artist while keeping the track loose/unquoted; strict path
unchanged; plus _escape_lucene unit coverage.
The save endpoint coerced library_track_id with int(), which rejected
every non-numeric id with "Invalid library track id". Library ids are
str(ratingKey) — numeric for Plex but GUIDs/hashes for Navidrome,
Jellyfin, and other Subsonic servers — and are stored in the TEXT
tracks.id column, so the coercion broke manual matching on every
non-Plex server.
Replace the int() coercion with a normalize_library_track_id() helper
that trims and rejects only empty input, passing the opaque string id
straight through. Plex numeric ids are unaffected (SQLite INTEGER
affinity still stores a clean numeric string as an int, so existing
matches are byte-identical) and no schema migration is needed (the
INTEGER column already stores non-numeric ids as text).
Tests: pure-helper cases (numeric/GUID/whitespace/empty) plus a real-DB
round-trip proving a GUID id saves, reads back unchanged, and enriches.
Self-review caught a test-fidelity hole: the temp cache overrode
_run_maintenance_write with a simplified version, so evict_over_capacity was
tested against the stub's plumbing, not production's (retry + connection
handling). Removed the override — _get_db is now the only injected seam, so the
test runs the genuine code path. Differential-verified the LRU assertions are
real: flipping ORDER BY ASC->DESC makes them fail. 8/8 pass; ruff clean.
Investigation (not assumption): the cache's TTL eviction + junk cleanup ARE
correct and DO run automatically every 6h (CacheEvictorJob, auto_fix=True).
The real gap is there's NO SIZE CEILING — TTL-only eviction means 'how big can
it get' = 'however much you fetch within the 30-day window', so heavy
discovery/enrichment legitimately grew metadata_cache_entities to ~1.8M rows /
7.6 GB, bloating the main DB (a factor in the corruption incident).
Fix — add a bounded LRU cap:
- entities_to_evict_for_capacity(total, max_rows): pure decision fn (cap<=0
disables), unit-testable like core.db_integrity.prune_backups.
- MetadataCache.evict_over_capacity(): deletes the least-recently-ACCESSED rows
(uses the already-stored last_accessed_at; NULL = never-touched = evicted
first) down to the ceiling. Default 250k rows, tunable.
- Wired as Phase 5 of CacheEvictorJob — runs LAST, after TTL/junk/orphan/null
cleanup, so it only trims a still-oversized HEALTHY cache.
Verified safe to bound/wipe: audited every cache reader (get_entity/
get_entities_batch/get_search_results/get_entity_detail/browse) — all degrade
to None/[]/empty on miss, treated as 'go fetch'. Nothing depends on a row
existing, so eviction can't break callers.
Tests: tests/metadata/test_cache_capacity_eviction.py (8) — pure-fn coverage +
real temp-DB proof that it drops the LRU rows specifically (not arbitrary) and
NULL-access rows go first. 18 adjacent cache tests still green; ruff clean.
Follow-ups (separate phases, scoped): (2) move the cache to its own bounded
metadata_cache.sqlite3 (no JOINs to library tables — confirmed clean to split;
invalidate-and-rebuild rather than migrate the 7.6GB), (3) kill the
raw_json + 22-extracted-column double storage.
Post-incident hardening. A WAL-mode DB corrupted (most likely an interrupted
write during a hard restart), and the backup routine made it unrecoverable:
it (a) never checked integrity, so src.backup() faithfully copied the corrupt
pages into every rolling backup, and (b) pruned oldest-by-mtime, so each new
corrupt backup evicted the last good one. Result: all snapshots poisoned.
New core/db_integrity.py (pure, unit-tested):
- quick_check()/is_healthy(): fast read-only PRAGMA quick_check probe.
- safe_backup(): verifies the SOURCE is healthy BEFORE the Online-Backup copy
and the RESULT after; refuses + discards rather than save a corrupt copy.
- prune_backups(): rotation that NEVER deletes the most-recent verified-healthy
backup, even to honor max_keep — so a run of bad backups can't drop your last
good snapshot.
Wired into BOTH backup paths (the /api/database/backup endpoint and the
auto_backup_database automation handler) — they now refuse on integrity failure
(409 / error status, existing backups untouched) and prune safely.
Tests: tests/test_db_integrity.py (8) using REAL temp DBs incl. a physically
corrupted one — proves refuse-corrupt-source, discard-corrupt-result, and the
exact incident scenario (newest backups corrupt -> the older healthy one is
protected from pruning). Existing maintenance-handler backup test still green
(29 passed). compile + ruff clean.
NOTE: this prevents silent backup poisoning; it does NOT stop the underlying
corruption. Follow-ups still worth doing: WAL-checkpoint on clean shutdown +
a periodic live-DB integrity alert (so corruption is caught on day 1).
Compared my #730 fix against contributor PR #731 (same independent design).
Grafted their good idea — a confidence bonus when the album's full core phrase
appears intact in the release title (rescues long multi-word names whose token
coverage gets diluted) — and kept my accent-folding, which #731 lacks (their
normalize drops accented chars: Bjork -> 'bj rk').
IMPORTANT: implemented the phrase bonus WORD-BOUNDARY anchored, not as a raw
substring. My first cut used 'phrase in norm_title' (matching #731) and it
immediately reintroduced the substring bug #730 exists to fix — 'heroes'
matched 'superheroes' and the wrong album scored 0.9/passed. PR #731 has this
latent flaw. The regex anchors the phrase to word boundaries so the bonus
fires for real matches only.
Verified: substring trap (Superheroes/Scary Monsters) rejected; edition
suffixes + intact-phrase albums kept. +1 phrase-bonus test (incl. the
word-boundary guard). 126 plugin tests pass; ruff clean.
Co-authored-by: Tyler Richardson-LaPlume <170156756+IamGroot60@users.noreply.github.com>
Review caught that artist_name was added to pick_best_album_release's signature
and threaded through both call sites but never actually used — dead, misleading
code. Removed it from the helper + both callers. Artist-aware gating would be a
deliberate future feature (titles carry the artist inconsistently, so a hard
artist gate would risk the same false-negative class I just fixed); the album
relevance gate already resolves the reported wrong-release bug. No behavior
change. 127 plugin tests pass; compile + ruff clean.
Self-review found a false-negative in the title-relevance gate I just added:
it scored 'fraction of the ALBUM-NAME's words present in the title', so a
stored album name with an edition/remaster suffix the torrent lacks
('Currents (Deluxe)', 'Heroes (2017 Remaster)') scored BELOW the 0.6 floor and
the correct release was wrongly refused -> fell back to per-track. The very
first issue example ('Heroes 2017 Remaster') would have regressed.
Fix: strip edition/format/year NOISE words (deluxe, remaster, edition, flac,
years, bitrates, ...) before scoring, via _significant_words(), with a fallback
to the raw words so an album literally named '1989' or 'Deluxe' isn't emptied
to match-everything. Verified both directions: edition suffixes now KEPT, while
the wrong-album rejection (Scary Monsters for a Heroes request, Superheroes)
still scores 0.
Tests: +2 regression tests (edition-suffix kept; noise/number-only album name).
125 album-bundle/dispatch/plugin tests pass; compile + ruff clean.
Reporter (IamGroot60): requesting an album via a Prowlarr-backed source
(Usenet/Torrent) could download a DIFFERENT album — e.g. asking for Bowie's
'Heroes' downloaded 'Scary Monsters' because the picker ranked purely by
seeders/grabs -> quality -> size with NO title check, and the wrong album had
~16x the grabs. (Confirmed the old picker chose the wrong release on exactly
this scenario.)
Fix (the reporter's proposal):
- album_title_relevance(candidate_title, album_name): word-coverage match,
accent-folded (Bjork != bj rk) and WORD-BOUNDARY (Heroes != Superheroes), so
a wrong album that shares no title words scores 0.
- pick_best_album_release gains album_name/artist_name params and a relevance
gate (floor 0.6) applied BEFORE the seeders/quality/size ranking. When
album_name is given and NOTHING clears the floor, returns None.
- torrent.py + usenet.py call sites pass album_name/artist_name and set
result['fallback'] = True on None, so the dispatcher (source-agnostic
fallback routing) hands off to the per-track flow instead of grabbing a
wrong album. Matches what Soulseek already did via its preflight scorer.
No album_name -> no gating (old behavior preserved for callers without a
title). Tests: 9 new in test_album_bundle.py (relevance math incl. the
substring trap + accent fold, the exact Bowie refuse-and-fallback scenario,
None-when-no-match, and no-gate-without-name). 125 album-bundle/dispatch/plugin
tests pass; compile + ruff clean.
Reporter: album covers render as a top strip then solid grey ('break' on
import) — and it happens regardless of the album-art toggles. That ruled out
the import embed/cover.jpg paths (all toggle-gated) and pointed at the DISPLAY
cache, which every cover view goes through.
Root cause: ImageCache._fetch_and_store streamed the body to a tmp file and
committed it as status='ok' with only a 'total <= 0' (empty) guard. A
dropped/short connection makes requests' iter_content END EARLY WITHOUT
raising, so a PARTIAL image was cached permanently and served forever as a
half-decoded cover. The high-res art change in 2.6.4 (bigger images) makes a
mid-stream cutoff more likely, especially on the reporter's LXC.
Fix: capture the declared Content-Length and, after streaming, reject when
fewer bytes arrived (unlink the tmp file, raise ImageCacheError) so nothing
broken is cached and the next request retries fresh. When the server omits
Content-Length (chunked), we can't detect truncation, so we don't reject —
behavior unchanged there.
Tests (tests/test_image_cache.py): truncated download raises + caches nothing +
a later good fetch still works (differential-verified it's silently cached
without the guard); positive control (declared==actual) caches normally;
no-Content-Length still caches. 6 image-cache tests pass.
Strong-candidate fix: it's a real defect that produces exactly this symptom,
but I can't reproduce the reporter's LXC network to prove it's THE cause.
Reporter (Vicky-2418) saw the artist search fire a separate external-API
search for nearly every letter typed. There WAS a 300ms debounce, but that's
short enough that a deliberately-typed name lands a keystroke per debounce
window, so each letter kicked off (and aborted) a fresh search — noisy in the
logs and wasteful.
Bumped both live-search surfaces that drive the shared SearchController
(external metadata APIs) to 600ms: the /search enhanced input (search.js) and
the global-search widget (downloads.js). 600ms coalesces a name being typed
into one search after the user pauses, while still feeling live. Enter still
triggers an immediate search on both (existing keypress/keydown handlers),
and the per-change abort already cancels stale in-flight fetches.
Frontend-only; both files syntax-clean.
The mockup had a seek tooltip (timestamp tracks the cursor over the progress
bar) but it was never ported to the real player. Added it: mousemove computes
the hovered fraction -> formatTime(duration*frac), positions the tip, shows on
hover / hides on leave. Guarded when no duration. Frontend-only; JS + CSS clean.
listening_history was populated ONLY from the media server; the web player
recorded nothing. Now a play heard ~10s logs to listening_history AND bumps
tracks.play_count/last_played — so the existing 'recently played' query reflects
actual SoulSync listening, and the Phase-2 smart-radio recency signal gets real
data.
- core/playback/play_log.build_play_event(): pure, DB-agnostic normalizer from
player payload -> listening_history event shape. Caller supplies the
timestamp (stays pure). Composite/streamed ids never become the int
db_track_id; bool ids rejected; missing title -> skip. 9 unit tests.
- MusicDatabase.record_web_player_play(): inserts the history row + increments
play_count/last_played for the library track in one call.
- /api/library/log-play: thin endpoint, server-side timestamp, best-effort
(logging failure never 500s / never affects playback).
- Frontend: npMaybeLogPlay on timeupdate fires once per track at the 10s
threshold (flag reset in setTrackInfo, set-before-fetch so it can't
double-fire), fully fire-and-forget.
Pure builder is unit-tested; the DB write can't run in-sandbox (real DB throws)
so it's a thin straightforward insert+update. JS + web_server parse clean.
Spotify-style context line above the track title. npSetPlayContext(text) shows/
hides it; set to 'Radio' when radio mode turns on, '<Artist> Radio' from
playArtistRadio (specific label wins over generic), cleared on stop/clearTrack
and when radio mode is turned off. Accent-colored name, uppercase label.
Frontend-only; JS + CSS clean.
The sidebar mini-player had prev/play/next/stop/expand but not the two
set-and-forget controls you reach for without opening the full view. Added
shuffle + repeat (3-mode, with a repeat-one badge) to the mini-controls.
State stays in sync both ways: handleNpShuffle/handleNpRepeat now call a shared
syncShuffleRepeatUI() that reflects state onto BOTH the modal and mini buttons,
so toggling in either place updates the other. Mini buttons reuse the same
handlers. Accent-active styling via --accent-light-rgb.
JS clean; CSS balance consistent with HEAD.
- Added playNext(track): inserts a track right after the current one (Spotify
'Play next'), vs addToQueue which appends to the end. Falls back to
addToQueue when nothing is playing.
- Artist-detail track rows now show BOTH a Play-next (⇥) and Add-to-queue (+)
button; the delegated handler builds one shared library-track payload and
routes to playNext / addToQueue. (Add-to-queue was already wired; play-next
+ the second button are new.)
- Fixed the queue button's hardcoded 29,185,84 to var(--accent-rgb) so it
follows the settings accent (kettui UI-consistency), and styled the new
play-next button to match.
Note: deliberately NOT adding queue buttons to SEARCH results — those are
stream/download (non-library) tracks the queue's auto-advance can't reliably
play. JS syntax clean on both files.
- Keyboard: added N (next) / P (previous) track shortcuts; 'm' mute now works
whether or not the modal is open (was modal-only). Space/seek/volume/escape
unchanged.
- Volume persistence: volume now saved to localStorage on every change (slider
+ arrow keys, via npPersistVolume) and restored on load instead of always
resetting to 70%. npLoadSavedVolume validates the stored 0..100 value.
initializeMediaPlayer applies it + syncs both slider UIs.
Frontend-only; init runs from init.js after full parse so the module consts
are defined. JS syntax clean.
playArtistRadio() flipped npRadioMode=true directly but never fetched similar
tracks, so the queue stayed empty until the current song ENDED (onAudioEnded is
what triggered the radio fetch). The modal's Radio button does it right via
npSetRadioMode(true, {fetchIfNeeded:true}).
Fix: await playLibraryTrack(...) (it's async and sets currentTrack only after
resolving the canonical DB row), THEN call npSetRadioMode(true, {fetchIfNeeded})
— which seeds the current track into the queue and immediately fetches the
radio queue. Replaces the old fixed-setTimeout guess that raced the async track
load (and could fire before currentTrack.id existed -> silent no-op).
Self-audit of the revamp surface found real bugs, now fixed:
- DOUBLE-ADVANCE race: crossfade starts ~6s before track end, but when the
track actually 'ended' fired, onAudioEnded ALSO advanced — two skips.
onAudioEnded now bails when npXfadeActive (crossfade owns the advance).
- STRAY CROSSFADE on manual skip/stop: skipping or stopping mid-fade left the
interval running, firing npFinishCrossfade on top of the manual change, and
left the second <audio> playing. Added npCancelCrossfade() (clears the timer,
tears down the 2nd audio, restores main volume) called at the top of
playQueueItem and in handleStop. The fade interval also self-checks
npXfadeActive each tick. npFinishCrossfade clears all flags cleanly so the
legitimate handoff isn't treated as an abort.
- stream_start: moved 'global stream_background_task' to function top (it was
declared inside an if-block — parsed, but brittle/bad form).
web_server parses; 76 streaming+radio tests pass; JS syntax clean; CSS balance
unchanged from HEAD.
Wires the StreamStateStore (Phase 3a) into the live routes so each browser/
device gets its OWN playback instead of every client sharing one global
stream_state. Fixes the long-standing limit where a second tab/device/listener
would hijack the one playback.
- _stream_session_id(): stable per-browser id stored in the Flask session
cookie (falls back to DEFAULT when no request context — e.g. the socket
broadcast thread — so single-user behavior is identical).
- _current_stream_state(): the StreamSession for the calling browser.
- Routes rewired to the caller's session + its own lock: /api/library/play,
/api/stream/start, /api/stream/status, /stream/audio, /api/stream/stop.
- Background tasks tracked per session (stream_tasks[sid]) instead of one
global Future, so stopping/replacing one listener's stream doesn't cancel
another's. Executor bumped 1 -> 4 workers so concurrent listeners don't
queue behind each other.
- Per-session staging: named sessions stage under Stream/<sid>/Stream so
listeners never clear each other's files; default session keeps flat Stream/.
- /stream/status now returns THIS listener's state, which is what the frontend
polls — so per-listener works without touching the socket broadcast (left
serving the default session, now vestigial for status).
Isolation invariant covered by tests/streaming/test_stream_state_store.py
(distinct sessions independent, default stable). The route-level cookie wiring
+ actual two-client no-collision behavior need live multi-client verification
(can't be tested without booting Flask + separate cookies) — EXPERIMENTAL,
needs Boulder to test with 2 browsers/devices. 33 streaming tests still pass;
web_server parses.
The Media Session API was partial — play/pause/stop/seek±10/prev/next handlers
+ metadata/artwork existed, but the OS lock-screen/Bluetooth/notification
control had a DEAD scrubber (no position, no drag-to-seek). Completed it:
- setPositionState (duration/position/rate) so the lock screen shows a live
progress bar, pushed throttled (~1/s) from timeupdate, reset on
loadedmetadata of a new track, and on manual seek.
- 'seekto' action handler so dragging the lock-screen/notification scrubber
actually seeks (with fastSeek when available).
Now hardware/Bluetooth keys + the lock-screen scrubber fully drive playback
with art, metadata, and live position. Feature-detected throughout.
Click any synced lyric line to jump playback to that line's timestamp (and
resume if paused). Reuses the existing _npLyricsState.lines {time,text} data.
Hover affordance: accent-tinted line + pointer cursor. Synced lyrics only
(plain lyrics have no timestamps).
- Stop button fix: my round .np-btn { width/height 46px; border-radius:50% }
override was also hitting .np-btn-stop (it carries both classes), squashing
the 'Stop' text pill into a tiny circle. Exempted .np-btn.np-btn-stop back to
an auto-width pill.
- Queue persistence: npPersistQueue() (called from renderNpQueue, the single
mutation hook) saves the queue to localStorage; npRestoreQueue() on init
repopulates the panel on reload WITHOUT auto-playing (index reset to -1).
Queue no longer vanishes on refresh.
- Crafted entrance: controls stagger-fade/rise in when the modal opens
(npRiseIn keyframe, delays cascading util->progress->controls->volume->
upnext). Art container excluded so its transform stays free for the
play-scale.
Frontend-only; Boulder verifying live.
Crossfade was a no-op toggle. Real crossfade needs two tracks audible at once,
but /stream/audio only serves the ONE current track (single global
stream_state). So:
- web_server: extracted the range-serving body of /stream/audio into
_serve_audio_file_with_range, and added /stream/library-audio?path= which
serves an arbitrary LIBRARY file through it. Security: the path is resolved
via _resolve_library_file_path (same validator /api/library/play uses) so it
only serves files inside the configured transfer/download/media-library
dirs — not arbitrary disk.
- frontend: a second hidden <audio> (#audio-player-xfade) preloads the NEXT
library track when the current one is within 6s of ending (crossfade on,
not repeat-one), ramps the two volumes in opposite directions, then hands
off to playQueueItem so all normal now-playing state is set.
Honest limits (documented in code): library→library only (streamed tracks
hard-cut as before); there's a brief silent reload at hand-off because
playQueueItem re-points the single stream_state — the perceived crossfade has
already happened by then. EXPERIMENTAL — needs Boulder's live audio
verification; I can't test audio in-sandbox.
33 streaming tests still pass (stream_audio refactor is behavior-preserving).
Two next-level player features (frontend-only):
1. Album-art ambient color — replaced the flat pixel AVERAGE (which muddied
every cover to grey-brown) with dominant-VIBRANT extraction: coarse
histogram binning weighted by saturation² × population, then a punch-up
pass (boost saturation ~1.3x, floor brightness) so the modal glow reads as
the cover's real standout color, Apple-Music style. Feeds the existing
--np-ambient-r/g/b hooks.
2. Drag-to-reorder queue — queue rows are now draggable; npReorderQueue moves
the item AND recomputes npQueueIndex so the currently-playing track stays
correctly tracked after a reorder. Accent drop-line indicator, grab cursor,
dragging opacity.
Verified live in-browser by Boulder.
Player-revamp frontend (Phase 1). Brings the Now Playing modal to the approved
mockup look + features:
- Full restyle (override block in style.css): 28px modal radius, stronger
art-driven ambient glow, 340px rounded art that scales while playing, bold
28px title, accent artist name, accent FLAC pill, dominant 70px gradient
play button, accent-gradient progress/volume/visualizer. All driven by the
existing --accent-rgb / --accent-light-rgb so it follows the settings accent.
- Click album art -> Plexamp-style visualizer takeover, fed by the REAL
music-synced Web Audio analyser (npStartVisualizerLoop), click again -> art.
- Rich queue rows: album thumbnail + title/artist + duration, equalizer
animation on the now-playing row, hover-reveal remove.
- Up-next peek below the controls (shows the next queued track).
- Sleep timer (cycles 15/30/60m, real setTimeout -> handleStop).
- Crossfade toggle present (visual state + persisted pref; the dual-audio
crossfade engine is the next step, not yet wired).
Frontend-only; verified live in-browser by Boulder. No backend/test surface.
Foundation for multi-listener playback. Today web_server.py keeps ONE global
stream_state dict + one lock (web_server.py:747), so the whole server shares a
single 'currently playing' — every tab/device is a remote for the same
playback and two listeners collide. That global is woven through ~22 sites and
isn't unit-testable where it lives.
Lifted into core/streaming/state.py WITHOUT changing behavior:
- StreamSession: one playback's state, dict-compatible (s['k'], s.get,
s.update, 'k' in s) so existing call sites work unchanged, each with its
OWN RLock so distinct sessions never block/clobber each other.
- StreamStateStore: registry of named sessions; lazy + race-safe create;
DEFAULT session reproduces today's exact single-global behavior. Also
drop()/active_ids()/session_ids() for the eventual per-listener wiring.
web_server.py now binds (DEFAULT) and
. Drop-in: every .update()/[k]/.get()/ site behaves identically. _set_stream_state routes a reassign
through session.replace() so the store's session stays the live object (it's
effectively dead — prepare.py only mutates in place — but safe now).
Honest scope: this is the PROVABLE half of Phase 3. The remaining half (3b:
derive a per-browser session id, per-session Stream/ staging, executor
concurrency, disconnect cleanup) is browser-coupled and can't be verified
without driving 2+ live clients — deferred to a live session. The store API is
already shaped for it.
Tests (tests/streaming/, 33 total):
- test_stream_state_store.py (19): session dict-compat, isolation, lazy
create, drop rules, active_ids, concurrent-create race safety.
- test_stream_state_callsite_compat.py (7): every real web_server access
pattern (library/play, stream/start, status, audio guard, stop, prepare
in-place mutation, set->replace) against the exact object web_server binds.
- test_prepare.py +1: real prepare worker drives an actual StreamSession.
76 streaming+radio tests green; ruff clean; web_server.py parses.
Replaces radio's pure ORDER BY RANDOM() with weighted ranking. Each tier now
fetches a generous random POOL (4x the needed count, floored) and
core/radio/selection ranks it before the collector keeps the best:
score_candidate = play_count(log-damped, w=1.0)
+ lastfm_playcount(log-damped, w=0.5)
- recently_played penalty(w=2.0)
+ stable per-id jitter(w=1.0, hash-derived so runs vary but
tests stay reproducible)
Modest weights so popularity guides without burying lesser-played tracks, and
jitter keeps radio from being identical every run. All intelligence is in pure
functions (rank_candidates / score_candidate) so it's tunable + unit-testable
without SQL.
Defensive: the DB method probes PRAGMA table_info(tracks) and omits
play_count/lastfm_playcount from the SELECT when absent (older DBs predating
the listening-history migration) — the scorer treats missing signals as 0, so
radio degrades to jitter-only instead of crashing on 'no such column'.
Tests (tests/radio/, 43 total):
- score_candidate / rank_candidates: deterministic unit coverage (popularity
ordering, lastfm contribution, recency penalty, garbage→0, stable jitter).
These CANNOT pass against pre-Phase-2 code.
- DB end-to-end: ranking surfaces the heavily-played track first out of a
decoy pool (wiring proof — probabilistic vs old random, documented honestly);
plus a no-rank-columns DB proving the defensive degrade path.
- All Phase-0a behavioral/refactor-equivalence tests still green.
60 radio + adjacent-DB tests pass; ruff clean.
First step of the stream/player/radio revamp (see revamp_plan.md). The radio
algorithm lived inline inside database.music_database.get_radio_tracks as raw
SQL tangled with selection logic — untestable without a live DB (which also
throws in the dev sandbox). Lifted the pure DECISIONS into core/radio/selection.py:
- parse_tags / merge_tags — JSON-or-CSV tag fields → ordered deduped list
- same_artist_cap — tier-1 30%-floored-at-5 cap
- build_like_conditions — OR-of-LIKEs SQL fragment + params per tier
- RadioCollector — dedup + cap + exclude-set + NOT-IN placeholder/value tracking
The DB method keeps the cursor work and now delegates every decision to these
helpers. Faithful extraction, not a rewrite — behavior unchanged.
This is the kettui foundation move: radio is now unit-testable, so Phase 2
(smart ranking — play-count / recency / feature seeding) becomes 'evolve a
tested function' instead of 'rewrite SQL and pray'.
Tests (tests/radio/):
- test_selection.py (22): unit coverage of every extracted helper
- test_get_radio_tracks_db.py (7): drive the REAL get_radio_tracks against
in-memory sqlite — tier fallback, dedup, exclude, file_path filter.
Behavior-pinned: these 7 pass against BOTH old inline and new extracted
code (refactor-equivalence proof). 52 adjacent DB+radio tests green.
# discover page — best in class plan (#913 + full generator audit)
morning notes. did the work overnight. tl;dr at top, details below, all of it `break nothing` + tested.
## what i shipped tonight (done, tested, safe)
### 1. listening recommendations (#913) — went from BROKEN to best-in-class
the feature was silently producing **zero** recs on real data. dug in and found three stacked bugs in the generation:
- **wrong id key (the killer).**`similar_artists.source_artist_id` is a *source* id (spotify/itunes/deezer), but the scanner built its id→name map from `artists.id` (the internal row id). so every edge resolved to nothing → 0 recs. proved it on your live db: internal-id join = 0 rows, spotify-id join = 71,636 rows.
- **consensus could never fire.** it fed the ranker `get_top_similar_artists`, which does `GROUP BY similar_artist_name` + `MAX(source_artist_id)` — collapsing every similar artist down to a *single* seed. the whole point of the ranker is "artist X is similar to 3 of your seeds = strong signal," and that signal was being flattened away before it ever reached the ranker.
- **similarity strength thrown away.** each edge stores a 1-10 closeness rank; it was ignored (everything weighted equally).
the fix (all in the pure, tested core + thin scan wiring):
- build id→name from the **source-id columns**, query the **raw per-seed edges** (consensus preserved), and thread **similarity_rank** into the score so a seed's closest matches count for more.
- **recency-weighted seeds**: `weight = lifetime_plays + 1.5 × recent_30d_plays`. picks now track what you're into *now*, not just all-time totals.
result on your actual library (simulated through the real code path): **40 recommendations, 13 with multi-seed consensus, all 40 with cached art.** top picks: Arcangel (Bad Bunny + Ozuna + J Balvin), Melanie Martinez (Ariana + Billie), Maluma, De La Ghetto — all coherent, all explainable.
### 2. its own row on the discover page
new row **"Based On Your Listening"** — play-weighted, consensus-ranked artist cards with a **"Because you listen to X, Y"** line. sits right above the library-driven "Recommended For You" row. purely additive: new endpoint `/api/discover/listening-recommendations`, new loader, hides itself when empty.
**you need to run one watchlist scan** for the row to populate (the data regenerates during the scan — i did NOT touch your live db). before that scan the row just stays hidden; after it, it fills in.
> note: this is deliberately different from the existing "Recommended For You" row. that one is driven by your *whole library / watchlist*. this one is driven by your *actual listening intensity* — the ~30 artists you really play, not the thousands you happen to own.
### 3. Fresh Tape "only 5-10 tracks" — fixed
root cause: `get_discovery_recent_albums` orders `release_date DESC`, so announced-but-unreleased albums sort to the *top* and ate the 50-album budget. the scanner skipped them *after* the budget was already spent → only a handful of released albums left → 5-10 tracks. fixed by fetching a generous budget (300) **and** excluding next-year albums at the query, so released albums fill the budget. the precise same-year `is_future_release` skip stays as a second guard. downstream caps (6/artist, top 75, take 50) unchanged.
**tests:** 25 pure-core cases (consensus/similarity/recency) + 2 Fresh Tape regression tests, all green. full discovery suite (255) green. nothing else touched.
---
## best-in-class roadmap for listening recs (next phases — your call)
these are the levers to take it further. ordered by value-to-risk. none are required; tonight's work stands on its own.
| phase | what | value | risk | notes |
|---|---|---|---|---|
| **3** | **playable track row** ✅ DONE | high | low-med | shipped: "🎧 Your Listening Mix" row — a track playlist (play/queue/download/sync) right under the artist row. stored as full render-ready dicts (not pool-hydrated, so it can't shrink on pool rotation like Fresh Tape does). |
| **4** | **direct top-tracks fetch** ✅ DONE | high | med | shipped: scan fetches each recommended artist's top tracks (Spotify/Deezer), resolving the artist id by name-search when the similar-artist row lacks one — guarded by a strict name-match so it never pulls the wrong artist. bounded (top 20 recs), per-call guarded, fail-soft to the pool. iTunes has no top-tracks API → pool-only there. needs a live scan to populate. |
| **5** | **genre-affinity boost** | med | low | we already compute your genre breakdown. boost recs whose genres match your top genres → tighter taste alignment. pure scoring add. |
| **6** | **adventurousness dial** | med | low | the ranker already supports `min_seed_count` (consensus floor). expose it as a "Safe ↔ Adventurous" slider on the row. |
| **7** | **diversity pass** | low-med | low | avoid 40 recs all orbiting your single heaviest seed — cap picks-per-seed so the row spans your taste. |
the core is built to absorb all of these without re-plumbing — `similarity_from_rank`, `build_recency_weighted_seeds`, and the scoring formula are all pure + tested.
how each one is generated today, and whether it can be elevated. "clear win" = safe + additive. "product call" = needs your decision (changes the row's character).
### curated (built during the scan, then hydrated)
- **Fresh Tape / Release Radar** — new releases from watchlist+similar artists. **FIXED tonight** (see above). one more *clear win* available: hydration silently drops any curated id no longer in the discovery pool — could fall back to the stored `track_data_json` blob so the row can't shrink at read time.
- **Seasonal Mix** — cleanest of the bunch. hydrates from a dedicated `seasonal_tracks` table (carries its own data), so it doesn't suffer the pool-drop problem. no bug.
### discovery-pool generators (live queries)
- **Popular Picks** — ranks by popularity DESC. solid. only nit: on iTunes (no popularity scale) it silently degrades to random — indistinguishable from Shuffle there. UI-label thing at most.
- **Hidden Gems** — *clear win*. currently `ORDER BY RANDOM()` over low-popularity tracks — so it's "random obscure," not "*best* obscure." a light ranking (popularity just under the threshold, or genre-affinity to you) would make it feel curated instead of arbitrary. (a deeper *product call*: add personalization like Archives has — bigger lift, changes its "pure underground" character.)
- **Genre Playlists** — good. pushes the genre match into SQL. `RANDOM()` ordering is fine for a browse; a popularity/affinity tiebreak (*clear win*) would make thin genres feel less arbitrary.
- **Discovery Shuffle** — random by design, correct. only possible add: exclude tracks already shown in other rows this refresh (needs a cross-section seen-set — medium plumbing).
- **Time Machine (by decade)** — *clear win, low risk*: decades are hardcoded, so a modern-only library shows 7 decade tabs, 5 empty. filter the tabs to decades that actually have pool data.
- **Daily Mix** — the weakest row. the "50% your library" half permanently returns nothing (library tracks have no source ids to play), so each Daily Mix is really just a relabeled Genre Playlist. real fix = backfill source ids into library rows (*schema-level, higher risk*) — worth a dedicated pass, not a quick tweak. also silently falls back to "top artists as pseudo-genres" when genre data is missing → "Daily Mix 1" becomes mislabeled artist-radio. gate/label that (*clear win*).
### cross-cutting
- **hydration fragility** (Fresh Tape + Archives): both depend on curated ids still living in the pool at read time; misses are dropped silently. Seasonal already solved this with a dedicated table. giving the two spotify-style rows the same data-blob fallback is the single most robust cross-cutting fix. low risk, clear win.
- **RANDOM-ordering pattern** (Hidden Gems, Shuffle, Genre, Decade): intentional for variety, but leaves quality signal on the table for the non-shuffle rows. adding a light ranking pass to Hidden Gems + Genre is the biggest "best-in-class" lever after tonight's work.
want me to take any of these? the Hidden Gems ranking + Time Machine empty-decade filter + the Fresh Tape/Archives hydration fallback are all safe, additive, same-shape-as-tonight wins i can knock out next.
If `webui/static/dist/.vite/manifest.json` is missing or stale, React-owned routes and route handoffs may not load correctly.
**YouTube streaming / music videos** need two extra things on bare-metal installs (Docker bundles both):
- **Deno** — yt-dlp now requires a JavaScript runtime to unlock YouTube formats. Without it, streams and music-video downloads fail with `Requested format is not available`. Install: `winget install DenoLand.Deno` (Windows) or see [deno.com](https://docs.deno.com/runtime/), then restart SoulSync.
- **yt-dlp nightly** — the stable release can lag months behind YouTube changes. If YouTube breaks, update with: `python -m pip install -U --pre "yt-dlp[default]"`
### Local Development
This is only for contributors working on the WebUI with hot reload. Normal Python/no-Docker installs should build once with `npm run build` as shown above, then run only Gunicorn.
@ -340,6 +360,7 @@ on any OS. `./dev.sh` remains available as a Unix shell wrapper.
- **slskd** running and accessible ([Download](https://github.com/slskd/slskd/releases)) — required for Soulseek downloads
- **Spotify API** credentials ([Dashboard](https://developer.spotify.com/dashboard)) — optional but recommended for discovery
- **Media Server** (optional): Plex, Jellyfin, or Navidrome
- **Deno** (Python/no-Docker installs only): JavaScript runtime required by yt-dlp for YouTube streaming/music videos — `winget install DenoLand.Deno` or [deno.com](https://docs.deno.com/runtime/). Docker images bundle it.
- **Deezer ARL token** (optional): For Deezer downloads — get from browser cookies after logging into deezer.com
- **Tidal account** (optional): For Tidal downloads — authenticate via device flow in Settings
- **Qobuz account** (optional): For Qobuz downloads — email/password login in Settings
🎚️ **Best-quality downloads** — downloads now follow a ranked quality profile you drag to order (FLAC 24/192 → mp3). best-quality mode grabs the highest-quality copy across *every* source; priority mode gets an opt-in rank-based order toggle. quarantine is folded into the Downloads page + safer imports (AcoustID fail-closed, silence/truncation guards).
🎧 **Discover got smart** — "Based On Your Listening" ranks artists from who you *actually play*, and "Your Listening Mix" is a playable track playlist of their top tracks (works on any source). Fresh Tape fills properly now.
⚡ **Wing It Pool** — a new spot next to Discovery Pool to review + re-match the tracks Wing It guessed at (they used to be invisible).
🔁 **Auto-Sync redesign** — the scheduling board is now clean horizontal lanes instead of a side-scrolling column wall.
🐛 **Fixes** — multi-disc albums no longer show disc-2 as "missing" (#927), playlists no longer stuck on "Never Synced" (#925), and tracks can't import while quarantined (#928). thanks @ramonskie + @nick2000713 🙏
⚠️ **re-scan your library once** so the multi-disc fix can backfill disc numbers on existing tracks. enjoy! 🎶
**SoulSync 2.8.0** is out 🎉 a quality + reliability release.
🧹 **The Unverified queue, finally under control** — if you saw thousands of "unverified" rows piling up, this is for you. the AcoustID scan stops duplicating history rows, a one-time reconcile on startup clears the existing backlog from your library (no re-scan), and a new 🧹 *Clean orphaned* button sweeps dead rows whose file is gone. (#934 — thanks @nick2000713 for #938)
✂️ **Preview Clip Cleanup** — a new Tools job that finds the ~30s preview clips the HiFi source sometimes hands back instead of the full song, then deletes them and re-wishlists the real version. each finding has a ▶ Play button so you can confirm before approving.
💿 **Album Completeness handles split albums** — an album split across multiple library rows no longer shows every fragment as falsely "incomplete"; it groups the validated fragments into one correct finding. (#936 — thanks @ragnarlotus)
🐛 **Fixes** — pasted YouTube cookies no longer throw `unsupported browser: "custom"` on Docker (thanks HellRa1SeR); longer remasters aren't quarantined as "truncated" anymore (#937, thanks @diegocade1); "Add to Wishlist" from a discography went from ~15–30s *per track* to instant; wishlist art renders for re-downloads; and **Clear Completed** is back on the Downloads page.
⚡ **Performance** — trimmed the dashboard GPU usage that was hammering Firefox/Zen (and Background Particles are OFF by default now), plus bounded the runaway memory growth that could lock the app up on big libraries. (#935 / #802)
**SoulSync 2.8.1** is out 🎉 a feature + reliability release.
🎧 **Export playlists to Spotify & Deezer** — the mirrored-playlist export now has **Sync to Spotify** and **Sync to Deezer** next to the ListenBrainz / JSPF options. it builds a playlist in your account from the IDs soulsync already has (the discovery cache first, then your library), so an already-discovered playlist exports **instantly with zero API calls**. re-exporting updates the same playlist instead of duplicating it, and an optional *"match missing tracks"* toggle confidently searches for the stragglers — a wrong-artist or karaoke version is left out, never guessed. the first Spotify export asks permission once. (#945)
🏷️ **Library Reorganize — Rename only** — a lighter action that just **renames your files** to your naming scheme: no re-tag, no quality/AcoustID re-check, no copy-to-staging. much faster on a NAS, and only touches files whose path actually changes. pick it from the new Action dropdown. (#875 — thanks @tsoulard / @Tacobell444)
💿 **Broader lossless handling** — lossy-copy now covers **all lossless formats**, not just FLAC (#941); and **DSD** (`.dsf`/`.dff`) is recognized as lossless instead of false-flagged "truncated" (#939).
🐛 **Download + search fixes** — an unbalanced bracket no longer false-fails as "file not found"; a file we couldn't quarantine is left for retry instead of deleted; "file not found" errors are actionable now; pasted Qobuz/Tidal links inject the exact track into manual search (#932); the Wing It pool "Fix Match" works again.
⚡ **Reduce visual effects, refined** — it no longer freezes functional motion (spinners, progress), only the expensive GPU stuff (blur, shadows, glow). worker orbs default OFF on Firefox and run at ~30fps under reduce-effects. plus a jellyfin scan watchdog fix for big libraries.
**SoulSync 2.8.2** is out 🎉 a stability + performance release.
🎧 **Spotify, reliably** — the Docker boot hang is fixed: with Spotify as your primary source, an unreachable Spotify API could block startup so the container bound `:8008` but never served the UI. auth probes are now deferred during boot + capped with a timeout. the "re-auth didn't stick" bug is fixed too (the OAuth callback and the app were reading different token caches), and **Sync to Spotify** now works — it asks for playlist-write permission once, on-demand, leaving your normal login untouched. (#949 — thanks HellRa1SeR)
⚡ **The "slow after update" fix** — the post-update lag wasn't SoulSync, it was browser password managers (Bitwarden/1Password/etc.) rebuilding their autofill overlay on *every* DOM change. non-credential fields are now marked so they skip them — **~110× less main-thread blocking** in the reporter's benchmark. plus a new **Max Performance** mode (Settings → Appearance) that kills every effect for no-GPU / Docker setups. (#948 — thanks @nick2000713)
📥 **Large-library imports no longer time out** — dropping a whole library into staging used to make the import page scan every file synchronously and never load. the scan runs in the background now with a live "Scanning N of M…" progress, and fills in when done. (#947 — thanks @ramonskie)
logger.error(f"[Post-Processing] Task {task_id} was completed by stream processor - not marking as failed")
return
download_tasks[task_id]['status']='failed'
download_tasks[task_id]['error_message']=f'File not found on disk after {_file_search_max_retries} search attempts. Expected: {os.path.basename(task_filename)}'
# slskd reported the transfer complete, but the finder never located
# the file under the configured download folder. Name the folder we
# searched and the two real causes — "still being written" (timing)
# or "SoulSync's download path doesn't match slskd's" (the classic
# standalone config mismatch) — so the user can self-diagnose instead
# of getting an opaque "not found". (Discord: Shdjfgatdif.)