Commit graph

4388 commits

Author SHA1 Message Date
BoulderBadgeDad
3ab9301a18
Merge pull request #951 from Nezreka/dev
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
Dev
2026-06-29 11:24:23 -07:00
BoulderBadgeDad
4b8ddad9ff fix: stop the Tidal token-refresh loop (regression from #949)
#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.
2026-06-29 11:18:00 -07:00
BoulderBadgeDad
68d52a1b3f Release 2.8.2: version bump + release notes
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).
2026-06-29 11:10:50 -07:00
BoulderBadgeDad
81bd85d639
Merge pull request #949 from HellRa1SeR/fix/docker-spotify-boot-hang
Fix docker spotify boot hang; add boot guard for provider clients
2026-06-29 10:55:39 -07:00
BoulderBadgeDad
f746fb4fc4 fix(#947): poll ALL staging queries while scanning, not just files
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.
2026-06-29 10:03:14 -07:00
BoulderBadgeDad
8f1ffa7632 import: page shows scan progress + auto-loads when the background scan finishes (#947, phase 2 UI)
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.
2026-06-29 09:45:24 -07:00
Siddharth Pradhan
a8520bc9b2 revert dockerfile changes 2026-06-29 12:25:50 -04:00
BoulderBadgeDad
79648a4f5f import: async staging scan so a large library doesn't time out the page (#947, phase 1 backend)
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.
2026-06-29 09:24:41 -07:00
Siddharth Pradhan
9fc3628062 Defer all provider API probes during boot to prevent startup hangs.
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.
2026-06-29 12:17:44 -04:00
Siddharth Pradhan
9b18e99419 Fix Docker boot hang when Spotify is the configured primary source.
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>
2026-06-29 12:08:03 -04:00
BoulderBadgeDad
b05b641521
Merge pull request #948 from nick2000713/codex/fix-startup-freeze
Fix post-update UI freeze: stop password managers re-scanning the DOM + add Max Performance mode
2026-06-29 08:47:23 -07:00
BoulderBadgeDad
efefdd64ff spotify export: clickable authorize link (popup-block safe) + endpoint pre-check tests
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.
2026-06-29 08:35:25 -07:00
BoulderBadgeDad
f3672c7ab4 spotify export: on-demand write-auth (restores Sync to Spotify safely, #945)
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.
2026-06-29 08:32:19 -07:00
BoulderBadgeDad
6f451a34e1 playlist export: hide Spotify until on-demand write-auth; release notes → Deezer-now
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.
2026-06-29 08:18:40 -07:00
BoulderBadgeDad
633aa82b22 fix: un-break Spotify auth — revert the write scope + fix the OAuth token-cache mismatch
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.
2026-06-29 08:09:18 -07:00
nick2000713
bd6db37624 fix(ui): info icons show the button hand, not the text caret (Windows)
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>
2026-06-29 11:55:18 +02:00
nick2000713
8d549eb4fa fix(max-performance): hide the same decorative elements reduce-effects hides
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>
2026-06-29 11:33:03 +02:00
nick2000713
1e68e339ca perf(bench): runtime toggle for password-manager autofill suppression
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>
2026-06-29 11:26:45 +02:00
nick2000713
84d6208bc9 perf: add Max Performance mode + stop password-manager autofill storm
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>
2026-06-29 11:06:13 +02:00
BoulderBadgeDad
5d5aac9be3
Merge pull request #946 from Nezreka/dev
Dev
2026-06-28 23:35:05 -07:00
BoulderBadgeDad
60e7193539 Release 2.8.1: version bump + release notes
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.
2026-06-28 23:31:32 -07:00
BoulderBadgeDad
9bb73c4cb4 fix(#945): Spotify backfill must disable cross-service search fallback
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.
2026-06-28 23:10:11 -07:00
BoulderBadgeDad
050ce79d51 playlist export: gate Spotify/Deezer buttons on connection (#945, increment 8)
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.
2026-06-28 23:04:20 -07:00
BoulderBadgeDad
4743dfd644 playlist export: opt-in confident-search backfill for the unmatched tail (#945, increment 7)
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.
2026-06-28 23:02:08 -07:00
BoulderBadgeDad
91eaaeabb2
Merge pull request #944 from HellRa1SeR/fix-vulnerabilities
fix(webui): resolve npm audit vulnerabilities via audit fix
2026-06-28 22:30:15 -07:00
BoulderBadgeDad
37c8b06a27 playlist export: resolve IDs from the discovery cache first (#945, increment 6)
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.
2026-06-28 21:39:39 -07:00
BoulderBadgeDad
6e1a377f37 test(#945): run db_service_track_id against a real temp schema
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.
2026-06-28 21:26:39 -07:00
BoulderBadgeDad
614deba0af playlist export: Sync to Spotify / Deezer modal options (#945, increment 5)
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.
2026-06-28 21:22:30 -07:00
BoulderBadgeDad
465a77f01d playlist export: Spotify/Deezer export job + endpoint (#945, increment 4)
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).
2026-06-28 21:20:06 -07:00
BoulderBadgeDad
0db72bf48d deezer: playlist-write via the ARL gw-light gateway (#945, increment 3)
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.
2026-06-28 21:15:04 -07:00
BoulderBadgeDad
37c2b9b569 spotify: playlist-write client + single-source OAuth scope (#945, increment 2)
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.
2026-06-28 21:12:53 -07:00
BoulderBadgeDad
ede62824ad playlist export: resolver foundation for Spotify/Deezer targets (#945, increment 1)
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.
2026-06-28 21:04:47 -07:00
BoulderBadgeDad
c593a17ac2 spotify search endpoint: plain query, not field-scoped — fixes pool-fix "no results"
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.
2026-06-28 19:48:04 -07:00
BoulderBadgeDad
62aa2bef2d library reorganize: Full vs Rename-only action in the modal (#875)
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.
2026-06-28 19:11:03 -07:00
BoulderBadgeDad
5df873655f library reorganize: wire rename-only mode through the queue (#875)
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.
2026-06-28 19:08:42 -07:00
BoulderBadgeDad
92c101e300 library reorganize: add a rename-only executor (#875) — core + tests
#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.
2026-06-28 18:52:42 -07:00
Siddharth Pradhan
ad715dddda fix(webui): resolve npm audit vulnerabilities via audit fix - vite HIGH: server.fs.deny bypass and NTLM hash disclosure on Windows - undici HIGH: TLS bypass, WebSocket DoS, cross-origin SOCKS5 routing - @babel/core LOW: arbitrary file read via sourceMappingURL comment All 90 frontend tests (16 suites) continue to pass.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 21:22:42 -04:00
BoulderBadgeDad
34adb6fb32 worker orbs: run at ~30fps when reduce-effects is on (perf)
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.
2026-06-28 17:35:46 -07:00
BoulderBadgeDad
79383df6d8 worker orbs: let the toggle win over reduce-effects (experiment)
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().
2026-06-28 17:29:22 -07:00
BoulderBadgeDad
a62d2d4310 ui appearance: default worker orbs OFF on Firefox for first-time users
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.
2026-06-28 16:50:22 -07:00
BoulderBadgeDad
3207310448 reduce-effects: kill expensive GPU properties, stop freezing functional motion
"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.
2026-06-28 16:22:10 -07:00
BoulderBadgeDad
6a388c43fc
Merge pull request #943 from nick2000713/ui/settings-page-cleanup
UI/settings page cleanup
2026-06-28 16:05:38 -07:00
dev
33fe92a525 Enlarge settings page header 2026-06-29 00:37:01 +02:00
dev
512ec227be Polish security settings controls 2026-06-29 00:33:54 +02:00
BoulderBadgeDad
b07359cdb5 downloads: make the "file not found" failure actionable instead of opaque
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.
2026-06-28 15:32:47 -07:00
dev
caee0fc3e2 Clarify import quality and AcoustID wording 2026-06-29 00:19:53 +02:00
BoulderBadgeDad
969700674c import singles: default the Identify search to "artist - title" (dash)
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.
2026-06-28 15:16:13 -07:00
BoulderBadgeDad
8abf470018 imports: never delete a file we couldn't quarantine — leave it for retry
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.
2026-06-28 15:10:43 -07:00
BoulderBadgeDad
551df0c3ca downloads: fix file-finder collapsing on an unbalanced bracket (false "not found")
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.
2026-06-28 15:10:30 -07:00
dev
1382cb6117 Bootstrap saved appearance effects early 2026-06-29 00:01:56 +02:00