The "Test all sources" Deezer probe called _ssJson("/api/deezer-download/test") with no opts, i.e. a
GET — but that route is POST-only, so it 405s, the probe throws, and the dot goes red even though Deezer
downloads fine. POST it with an empty body (the endpoint then tests the SAVED ARL). The other probe
endpoints (qobuz/hifi/amazon/soundcloud status) are all GET, so this was the only offender.
Deezer download is_configured()/is_available() returned the raw _authenticated flag, but the ARL auth
is LAZY — only is_authenticated() triggers it (post-boot). The primary-source download path calls
is_authenticated() so Deezer auths and works; the hybrid-mode source gate (web_server _resolve sources)
and the green-light status (orchestrator.get_source_status) both read is_configured(), which never fired
the auth — so Deezer looked unauthenticated, got pruned from the hybrid chain, and showed no green dot.
Route is_configured()/is_available() through is_authenticated() so all three agree (and the auth still
defers cleanly during boot). 4 seam tests: lazy-auth fires post-boot, defers during boot, stays True
when already authed, False with no ARL.
Feedback pass: red read as "bad", so the spectrum is now green -> blue through cyan (deep/exploratory,
not alarming). The orb glow was way too hot — cut it right down and softened the pulse ring + the colour
aura. Dialed the wiggle back (lower amplitude, fewer cycles, slower) so the adventurous end is lively,
not chaotic. Header is now a small uppercase "ADVENTUROUSNESS" overline above the big colour-shifting
state word, instead of the plain bold title.
Scrapped the basic slider for a custom animated control matching the vision: a draggable orb rides an
SVG line that is redrawn every frame (rAF). At the left (value 0) the line is green and waves gently —
"artists you already like", alive but calm; drag the orb right and the colour shifts through the warm
spectrum (green -> yellow -> orange -> red) while the wave grows taller and more erratic (a detuned
second harmonic). The orb glows the current colour and rides the wave; the state word recolours too.
Releasing saves (POST /api/discover/adventurousness) and re-fetches both rec rows. The loop skips work
while the page is hidden. Still one source of truth with the Settings slider.
64 script-integrity tests green.
The first cut had two problems (screenshot): the gradient track was invisible (Chromium does not render
the range inputs own background as the track when appearance:none — must style ::-webkit-slider-runnable-
track / ::-moz-range-track) and the grid layout stretched it full-width into a sparse void.
Now: a contained (max-width 880) single-row flex control — icon | title+state | Safe | gradient track |
Adventurous | live %. The gradient (green->accent->orange) renders on the track pseudo-elements, the
thumb is centred on it, and a % readout anchors the right edge. Mobile wraps the slider to its own row.
A prominent control above the rec rows: gradient slider (Safe green -> accent -> hot orange), glowing
grab-thumb, a compass icon that swaps with the level (lifebuoy/compass/dice/crystal-ball) and a live
state word ("Playing it safe" -> "Deep cuts only"). Dragging updates the label live; releasing saves
and immediately re-fetches both rec rows so the effect is instant.
Shares the config key discover.adventurousness with the Settings -> Discovery slider via a new
GET/POST /api/discover/adventurousness endpoint, so the two controls stay in sync (change one, the
other reflects it on next load) — one source of truth, no divergence.
64 script-integrity tests green; route ruff-clean + compiles.
The similar-artists route now re-ranks by the same popularity penalty: it derives a score from the
SQL signals (occurrence primary + a small similarity tiebreak) and runs apply_adventurousness with the
discover.adventurousness level. At level 0 the function returns the list unchanged, so the featured-
rotation order is fully preserved — the dial only reshuffles once raised. Popularity is already on the
rows; the temp _adv_score key is stripped before returning. Fail-soft.
Both Discover rec rows now respond to the one global dial.
A 0..1 range slider (Safe <-> Adventurous) in Settings -> Discovery with a live value readout. It
auto-saves via the settings page existing range-input listener, persists to config under
discover.adventurousness (matching the /api/settings "discover" section), and re-populates on load.
The listening-recs route reads that key (fixed from discovery. -> discover. to match the settings
section). Drag it up and "Based On Your Listening" leans harder toward obscure picks on the next load.
64 script-integrity tests green.
The listening-recs route now reads discovery.adventurousness (default 0.3) and re-ranks the stored recs
through apply_adventurousness before reshaping — so globally-popular picks sink and obscure ones rise.
Popularity is enriched at REQUEST time via a new MusicDatabase.get_similar_artist_popularities(names)
lookup (the stored recs do not carry popularity inline), so it works on existing data with no re-scan.
Both the lookup and the re-rank are fail-soft — any error leaves the original order. Default 0.3 is a
gentle nudge; the slider to tune it is the next increment.
Core pure function already has 5 seam tests; DB lookup smoke-verified against a temp DB.
Both Discover rec rows already exclude what you own/watch, so novelty is baked in; the missing lever
is a popularity penalty. apply_adventurousness(items, level) re-ranks dicts (score + optional 0-100
popularity) so globally-popular candidates sink as the dial rises. Pure + reusable across both rec
rows. level<=0 returns the input order unchanged (a copy) — fully additive, no regression; 1.0 applies
the full penalty (a popularity-100 pick loses 70% of its score). Missing popularity is never penalised.
5 seam tests (no-op+copy, demotion, proportional penalty, missing-pop, clamping). 37 pass. Wiring
(scan stores popularity -> routes re-rank live -> Settings slider) is the next increment.
_reorderDiscoverSections() runs after all loaders and moves each section (incl. ones nested in the
mid-page #discover-bylt-sections) into the intended top-to-bottom order: Genre Explorer -> Your/Year
Mixes -> for-you (listening/recommended/BYLT) -> new (recent/genres/seasonal) -> library (artists/
albums) -> deeper digs (undiscovered/labels/deep cuts) -> stations & tools (Last.fm/ListenBrainz/
Build). Hero + Artist Map stay near the top; hidden collapsed-mix sections are left invisible.
Also: ListenBrainz/Last.fm recording playlists carry no album or duration, so _normalizeTrack now
leaves album blank (not "Unknown Album") and renderCompactPlaylist blanks the duration when 0 (not
"0:00"), matching the original displayListenBrainzTracks behaviour.
64 script-integrity tests green.
_normalizeTrack fell back to {} when there was no track_data_json, so it never read the top-level
name/artists[]/album that decade (and Spotify-shaped) rows carry — every field defaulted to Unknown.
Fall back to the track object itself (|| track), matching _renderTabbedTrackLists trackData = track
behaviour. Flat (track_name) + nested (track_data_json) shapes still resolve.
Two bugs in the new playlist-card sections:
- "undefined" track rows: those sources nest fields under track.track_data_json (name/artists[]/
album/duration_ms), but renderCompactPlaylist read flat track.track_name/etc. Added _normalizeTrack
(handles both shapes) and routed renderCompactPlaylist + the mix-card mosaic through it, so decade /
Last.fm / ListenBrainz tracks render real titles instead of undefined.
- amateur covers: cards showed a single flat emoji. Now _hydrateMixCovers background-fetches each
cards tracks, upgrades the placeholder to a real 2x2 album-art mosaic (matching Your Mixes), and
caches the tracks on the mix so the modal opens instantly. Wired into _renderMixGrid (decades) and
the ListenBrainz/Last.fm render. Dropped the emoji coverHtml.
64 script-integrity tests green.
buildListenBrainzPlaylistsHtml (shared by both sections) now emits a grid of mix cards instead of
full-width track-table subsections — each playlist is a card that opens its tracks (lazy-loaded from
/api/discover/listenbrainz/playlist/{id}) + Download/Sync in the shared modal. ListenBrainz sync uses
its own -sync-total/-sync-matched status spans, so the mix carries a matching statusHtml; the sync
button id (discover-lb-playlist-{id}-sync-btn) lines up so startListenBrainzPlaylistSync drives it.
The old eager loadTracksForPlaylists calls are now harmless no-ops (their -playlist containers are
gone, so loadListenBrainzPlaylistTracks early-returns). Wing-It is omitted from the card modal for now
(its dropdown needs modal-aware positioning).
Browse by Genre removed (section markup + the loadGenreBrowserTabs call): it was empty
(/api/discover/genres/available returns nothing) and redundant with the Genre Explorer pills.
64 script-integrity tests green.
Foundation for folding the remaining playlist sections into the mix-card system:
- openMixModal now takes a custom `actions` list (not just the built-in Download/Sync), a `statusBase`
so any sections live sync lands in the modal, a `pollerKey` for in-flight re-show, and `fetchTracks`
for lazy sections that load tracks on open.
- _buildMixCard supports a non-mosaic `coverHtml` + `trackCount` (for art-less cards like decades).
- new _renderMixGrid(container, mixes) renders cards into any sections own grid (not the shared shelf).
Time Machine -> "Year Mixes": a shelf of decade cards (gradient cover + "1980s" label); opening one
lazy-loads /api/discover/decade/{year} into the modal (populating decadeTracksCache so Sync works)
with Download/Sync. Tabs removed.
Fix: _upsertMixCard rendered the WHOLE shared registry into Your Mixes, so decades (added by
_renderMixGrid for the modal lookup) leaked in. Your Mixes now tracks its own _yourMixKeys and renders
only those. 64 script-integrity tests green.
Because You Listen To rows now render .ya-card grids (with the Show-all clamp) instead of the old
discover-card carousels. The genre-browser tiles (_renderGenreCard) are converted too for consistency
— that loader is currently dead code (loadGenreBrowser is never called; only the tabbed Browse-by-Genre
modal runs), so this just future-proofs it. With this, no discover-card markup remains in discover.js
— the whole Discover page is one card language (.ya-card + the mix cards), leaving only the Genre
Explorer pills + Artist Map as the intentional splash elements.
64 script-integrity tests green.
Based On Your Listening + Recommended For You now use the same .ya-card as the rest (square cover,
name overlaid, recommendation reason as the subtitle) in wrapping grids with the Show-all clamp,
replacing the bespoke recommended-artist-card layout. The watchlist toggle and live image enrichment
are preserved by keeping their hooks on the new markup — .recommended-artist-card[data-artist-id],
.recommended-card-image, and .recommended-card-watchlist-btn (restyled as a hover-reveal corner pill
that stays lit when watching). _clampGrid wired into both onRendered callbacks.
64 script-integrity tests green.
Step 2 of the makeover — the album/release sections now use the same .ya-card as Your Artists
(square cover so art is not cropped, name overlaid, artist beneath) in responsive wrapping grids,
replacing the old spotify-library-card / discover-card / carousels:
- Recent Releases, Your Albums (owned/missing badge kept), Undiscovered Albums, New In Your Genres,
From Your Labels (one shared _cacheDiscoverCard), Seasonal Albums.
- _insertCacheSection gained a wrapGrid flag; Genre Explorer opts out (wrapGrid=false) so its pill
layout is not squashed into the card grid (fixes the regression where it broke under the hero).
- new _clampGrid caps a grid at ~12 cards with a Show all (N) / Show less toggle, so a 30-item
section is not a wall; wired into the cache sections + Recent Releases + Seasonal Albums.
64 script-integrity tests green. Next: the two artist sections (Based On Your Listening / Recommended
For You) need their watchlist + live-enrichment hooks moved before they can switch to .ya-card.
Fresh Tape (release_radar), The Archives (discovery_weekly), and the Seasonal/Summer Mix
(seasonal_playlist) move out of their old full-width compact-track-table sections into the unified
shelf as mix cards (set the global in the controller renderItems, then _upsertMixCard + collapse the
old section after load). Daily Mixes loader is converted the same way (each mix → a card) for if/when
that HIDDEN feature is re-enabled. So every active mix now lives in one consistent shelf.
64 script-integrity tests green.
Reworks the Discover page mixes from full-width compact-track tables into a consistent shelf of
playlist cards, the way Spotify Home does it. Each mix is ONE card (a 2x2 mosaic cover from its top
tracks + name/count, hover play button); clicking it opens a modal with the track list + the mix
actions — the track table now lives where a track list belongs (inside the opened mix).
- new "Your Mixes" section + .discover-mix-card / .discover-mixes-grid + .mix-modal styles.
- _buildMixCard / _upsertMixCard / openMixModal + a mix registry; the four personalized track-mix
loaders (Listening Mix, Popular Picks, Hidden Gems, Discovery Shuffle) collapse their old table
section into a card via _collapseOldMixSection (which also strips the old section's duplicate
sync ids so the modal owns the live status).
- modal actions use the standard btn--secondary/btn--primary; Download closes the mix modal first
so its own modal is interactable; Sync shows live progress IN the modal and survives close/re-open
(detects an in-flight sync via discoverSyncPollers and re-reveals the status).
64 script-integrity tests green. Next: fold the remaining mixes (Fresh Tape / The Archives /
Seasonal / Daily Mixes) into the same shelf, then unify the album/genre sections onto the card.
#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.