Compare commits

...

19 commits
2.8.1 ... main

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
33 changed files with 1228 additions and 133 deletions

View file

@ -9,9 +9,9 @@ on:
workflow_dispatch:
inputs:
version_tag:
description: 'Version tag (e.g. 2.8.1)'
description: 'Version tag (e.g. 2.8.2)'
required: true
default: '2.8.1'
default: '2.8.2'
jobs:
build-and-push:

View file

@ -1,15 +1,15 @@
**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** right next to the ListenBrainz / JSPF options. it builds a playlist in your account from the track 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 in). spotify needs a one-time reconnect for write access. (#945)
🎧 **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 reorganize action that just **renames your files** to your current naming scheme: no re-tagging, no quality/AcoustID re-check, no copy-to-staging. much faster on a NAS, and only touches files whose path actually changes (which also fixes the "2 of 14 previewed but everything got modified" album-splitting). pick it from the new Action dropdown. (#875 — thanks @tsoulard / @Tacobell444)
🏷️ **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 works for **all lossless formats**, not just FLAC (#941); and **DSD** (`.dsf`/`.dff`) is recognized as lossless instead of being false-flagged as "truncated" (#939).
💿 **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 in a filename 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); and the Wing It pool "Fix Match" search works again.
🐛 **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) and only kills the expensive GPU stuff (blur, shadows, glow). worker orbs default OFF on Firefox for new users and run at ~30fps under reduce-effects. plus jellyfin scans page the bulk fetch so the watchdog can't false-stall a big library.
**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.
🔧 **Under the hood** — settings page cleanup (#943, thanks @nick2000713), spotify oauth hardening (#942, thanks HellRa1SeR), and npm audit security fixes for vite / undici / @babel (#944, thanks HellRa1SeR).
🔧 **Under the hood** — settings cleanup (#943, @nick2000713), spotify oauth hardening (#942) + npm security fixes (#944, HellRa1SeR).
enjoy! 🎶

9
RELEASE_2.8.2_discord.md Normal file
View file

@ -0,0 +1,9 @@
**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)
enjoy! 🎶

27
core/boot_phase.py Normal file
View file

@ -0,0 +1,27 @@
"""Boot-phase guard for non-blocking container startup.
While the gunicorn worker is importing ``web_server`` (module-level client and
worker initialization), external provider API probes must not block startup.
Network validation is deferred until ``mark_boot_complete()`` runs at the end
of that import pass.
"""
from __future__ import annotations
import threading
_boot_lock = threading.Lock()
_boot_active = True
def is_boot_phase() -> bool:
"""Return True while module import must avoid blocking provider API calls."""
with _boot_lock:
return _boot_active
def mark_boot_complete() -> None:
"""End the boot phase — provider clients may perform network probes again."""
global _boot_active
with _boot_lock:
_boot_active = False

View file

@ -121,6 +121,7 @@ class DeezerDownloadClient(DownloadSourcePlugin):
self._license_token = None
self._user_data = None
self._authenticated = False
self._pending_arl: Optional[str] = None
# Quality preference
self._quality = quality_tier_for_source('deezer', default='flac')
@ -128,7 +129,12 @@ class DeezerDownloadClient(DownloadSourcePlugin):
# Try to authenticate on init if ARL is configured
arl = config_manager.get('deezer_download.arl', '')
if arl:
self._authenticate(arl)
from core.boot_phase import is_boot_phase
if is_boot_phase():
self._pending_arl = arl
logger.debug("Deezer ARL present — authentication deferred until after boot")
else:
self._authenticate(arl)
logger.info(f"Deezer download client initialized (download path: {self.download_path})")
@ -227,6 +233,11 @@ class DeezerDownloadClient(DownloadSourcePlugin):
return self._authenticated
def is_authenticated(self) -> bool:
if self._pending_arl and not self._authenticated:
from core.boot_phase import is_boot_phase
if not is_boot_phase():
self._authenticate(self._pending_arl)
self._pending_arl = None
return self._authenticated
async def check_connection(self) -> bool:

View file

@ -8,7 +8,7 @@ import time
import uuid
from concurrent.futures import as_completed
from dataclasses import dataclass
from typing import Any, Callable, Dict
from typing import Any, Callable, Dict, Optional
from core.imports.album import build_album_import_context, build_album_import_match_payload, resolve_album_artist_context
from core.imports.context import get_import_context_artist, get_import_track_info, normalize_import_context
@ -84,12 +84,124 @@ class ImportRouteRuntime:
_STAGING_SCAN_LOCK = threading.Lock()
_STAGING_SCAN_TTL = 6.0 # seconds — covers the page-open burst; re-scans after
_staging_scan_cache: Dict[str, Any] = {"path": None, "ts": 0.0, "records": None}
# Bumped by invalidate_staging_scan_cache() so a background scan that finishes after an
# import doesn't re-commit stale (pre-import) records (see the generation guard above).
_staging_scan_generation: Dict[str, int] = {"value": 0}
# Background-scan plumbing: a large staging folder (whole-library migration, #947) makes
# the synchronous scan exceed gunicorn's 120s request timeout. The runner moves the SAME
# scan off the request thread; the endpoints report progress instead of blocking.
_staging_scan_status: Dict[str, Any] = {
"status": "idle", "scanned": 0, "total": 0, "path": None, "error": None,
}
_staging_scan_status_lock = threading.Lock()
def _scan_staging_records(runtime: ImportRouteRuntime, staging_path: str) -> list[Dict[str, Any]]:
def _staging_cache_hit(staging_path: str) -> Optional[list]:
"""The cached records for ``staging_path`` if still fresh, else None (no scan triggered)."""
c = _staging_scan_cache
if (c["records"] is not None and c["path"] == staging_path
and (time.time() - c["ts"]) < _STAGING_SCAN_TTL):
return c["records"]
return None
def ensure_background_staging_scan(runtime: ImportRouteRuntime, staging_path: str) -> None:
"""Start a background scan for ``staging_path`` unless the cache is warm or a scan for
this path is already running. Idempotent safe to call on every request."""
if _staging_cache_hit(staging_path) is not None:
return
with _staging_scan_status_lock:
if (_staging_scan_status["status"] == "scanning"
and _staging_scan_status["path"] == staging_path):
return
_staging_scan_status.update({"status": "scanning", "scanned": 0, "total": 0,
"path": staging_path, "error": None})
def _run() -> None:
try:
_scan_staging_records(runtime, staging_path, progress=_staging_scan_status)
with _staging_scan_status_lock:
if _staging_scan_status["path"] == staging_path:
_staging_scan_status["status"] = "done"
except Exception as exc: # noqa: BLE001 — surface any scan error to the poller
with _staging_scan_status_lock:
_staging_scan_status.update({"status": "error", "error": str(exc)})
threading.Thread(target=_run, name="staging-scan", daemon=True).start()
def get_staging_records_or_status(runtime: ImportRouteRuntime, staging_path: str,
*, grace_seconds: float = 3.0) -> tuple[str, Any]:
"""Non-blocking staging access for the page endpoints. Returns ``("ready", records)``
when the cache is warm or the scan completes within ``grace_seconds`` (so small/normal
folders still answer in a single request), otherwise ``("scanning", status_dict)`` after
making sure a background scan is running."""
records = _staging_cache_hit(staging_path)
if records is not None:
return ("ready", records)
ensure_background_staging_scan(runtime, staging_path)
deadline = time.time() + max(0.0, grace_seconds)
while True:
records = _staging_cache_hit(staging_path)
if records is not None:
return ("ready", records)
with _staging_scan_status_lock:
status = dict(_staging_scan_status)
if status.get("status") == "error":
return ("error", status)
if time.time() >= deadline:
return ("scanning", status)
time.sleep(0.05)
def _records_or_scanning_payload(runtime: ImportRouteRuntime, staging_path: str):
"""Shared helper for the page endpoints: returns ``(records, None)`` when the scan is
ready, or ``(None, payload)`` when a background scan is still running the caller
returns that payload so the page polls + shows progress instead of blocking/timing out.
A scan error is re-raised so the endpoint's own try/except logs + returns it exactly as
when the scan ran inline (preserves the existing error contract)."""
state, val = get_staging_records_or_status(runtime, staging_path)
if state == "error":
raise RuntimeError(val.get("error") or "staging scan failed")
if state == "scanning":
return None, {"success": True, "scanning": True,
"progress": {"scanned": val.get("scanned", 0),
"total": val.get("total", 0)}}
return val, None
def staging_scan_status(runtime: ImportRouteRuntime) -> tuple[Dict[str, Any], int]:
"""Lightweight, instant scan-progress poll for the page (no grace-wait, no file I/O) —
``ready`` true once the cache is warm and the files/groups/hints calls will answer fast."""
try:
staging_path = runtime.get_staging_path()
except Exception as exc:
return {"success": False, "error": str(exc)}, 500
with _staging_scan_status_lock:
st = dict(_staging_scan_status)
return {
"success": True,
"ready": _staging_cache_hit(staging_path) is not None,
"status": st.get("status", "idle"),
"scanned": st.get("scanned", 0),
"total": st.get("total", 0),
"error": st.get("error"),
}, 200
def _scan_staging_records(runtime: ImportRouteRuntime, staging_path: str,
*, progress: Optional[Dict[str, Any]] = None) -> list[Dict[str, Any]]:
"""Walk staging + read each audio file's tags ONCE, returning per-file records
that staging files/groups/hints all derive from. Briefly cached + locked so the
page-open trio shares a single scan rather than each re-walking and re-reading."""
page-open trio shares a single scan rather than each re-walking and re-reading.
``progress`` (optional, default None = unchanged behaviour) is a dict the scan
updates live with ``total`` (audio-file count, from a fast first pass) and ``scanned``
(tag-reads done so far) so a background runner can report progress. A generation guard
keeps a scan that finishes AFTER an import (which bumped ``_staging_scan_generation``)
from committing stale records to the cache."""
now = time.time()
cached = _staging_scan_cache
if (cached["records"] is not None and cached["path"] == staging_path
@ -103,33 +215,50 @@ def _scan_staging_records(runtime: ImportRouteRuntime, staging_path: str) -> lis
and (now - cached["ts"]) < _STAGING_SCAN_TTL):
return cached["records"]
records: list[Dict[str, Any]] = []
start_generation = _staging_scan_generation["value"]
# Pass 1 (fast): collect the audio-file list — no tag I/O — so we know the total.
audio_files: list[tuple[str, str, Optional[str]]] = []
if os.path.isdir(staging_path):
for root, _dirs, filenames in os.walk(staging_path):
rel_dir = os.path.relpath(root, staging_path)
top_folder = rel_dir.split(os.sep)[0] if rel_dir != "." else None
for fname in filenames:
ext = os.path.splitext(fname)[1].lower()
if ext not in AUDIO_EXTENSIONS:
continue
full_path = os.path.join(root, fname)
rel_path = os.path.relpath(full_path, staging_path)
meta = runtime.read_staging_file_metadata(full_path, rel_path)
records.append({
"filename": fname, "rel_path": rel_path, "full_path": full_path,
"extension": ext, "title": meta["title"], "album": meta["album"],
"artist": meta["artist"], "albumartist": meta["albumartist"],
"track_number": meta["track_number"], "disc_number": meta["disc_number"],
"top_folder": top_folder,
})
if os.path.splitext(fname)[1].lower() in AUDIO_EXTENSIONS:
audio_files.append((root, fname, top_folder))
if progress is not None:
progress["total"] = len(audio_files)
progress["scanned"] = 0
_staging_scan_cache.update({"path": staging_path, "ts": time.time(), "records": records})
# Pass 2 (slow): read each file's tags, updating progress as we go.
records: list[Dict[str, Any]] = []
for root, fname, top_folder in audio_files:
full_path = os.path.join(root, fname)
rel_path = os.path.relpath(full_path, staging_path)
meta = runtime.read_staging_file_metadata(full_path, rel_path)
records.append({
"filename": fname, "rel_path": rel_path, "full_path": full_path,
"extension": os.path.splitext(fname)[1].lower(),
"title": meta["title"], "album": meta["album"],
"artist": meta["artist"], "albumartist": meta["albumartist"],
"track_number": meta["track_number"], "disc_number": meta["disc_number"],
"top_folder": top_folder,
})
if progress is not None:
progress["scanned"] += 1
# Generation guard: if an import invalidated the cache mid-scan, these records are
# stale — return them to this caller but do NOT commit them as the shared cache.
if _staging_scan_generation["value"] == start_generation:
_staging_scan_cache.update({"path": staging_path, "ts": time.time(), "records": records})
return records
def invalidate_staging_scan_cache() -> None:
"""Drop the cached staging scan (call after an import moves/removes files so the
next files/groups/hints request reflects the new state immediately)."""
next files/groups/hints request reflects the new state immediately). Also bumps the
scan generation so an in-flight background scan won't re-commit pre-import records."""
_staging_scan_generation["value"] += 1
_staging_scan_cache.update({"path": None, "ts": 0.0, "records": None})
@ -139,6 +268,10 @@ def staging_files(runtime: ImportRouteRuntime) -> tuple[Dict[str, Any], int]:
staging_path = runtime.get_staging_path()
os.makedirs(staging_path, exist_ok=True)
records, scanning = _records_or_scanning_payload(runtime, staging_path)
if scanning is not None:
return scanning, 200
files = [
{
"filename": r["filename"],
@ -151,7 +284,7 @@ def staging_files(runtime: ImportRouteRuntime) -> tuple[Dict[str, Any], int]:
"disc_number": r["disc_number"],
"extension": r["extension"],
}
for r in _scan_staging_records(runtime, staging_path)
for r in records
]
files.sort(key=lambda f: f["filename"].lower())
@ -168,8 +301,12 @@ def staging_groups(runtime: ImportRouteRuntime) -> tuple[Dict[str, Any], int]:
if not os.path.isdir(staging_path):
return {"success": True, "groups": []}, 200
records, scanning = _records_or_scanning_payload(runtime, staging_path)
if scanning is not None:
return scanning, 200
album_groups = {}
for r in _scan_staging_records(runtime, staging_path):
for r in records:
album = r["album"]
artist = r["albumartist"] or r["artist"]
if not album or not artist:
@ -215,9 +352,13 @@ def staging_hints(runtime: ImportRouteRuntime) -> tuple[Dict[str, Any], int]:
if not os.path.isdir(staging_path):
return {"success": True, "hints": []}, 200
records, scanning = _records_or_scanning_payload(runtime, staging_path)
if scanning is not None:
return scanning, 200
tag_albums = {}
folder_hints = {}
for r in _scan_staging_records(runtime, staging_path):
for r in records:
if r["top_folder"]:
folder_hints[r["top_folder"]] = folder_hints.get(r["top_folder"], 0) + 1

View file

@ -357,10 +357,26 @@ def get_hydrabase_client(allow_fallback: bool = True, require_enabled: bool = Tr
return None
def get_configured_primary_source() -> str:
"""Return metadata.fallback_source as stored in config, without runtime downgrade.
Unlike get_primary_source(), this never probes Spotify auth. Use at boot and
anywhere blocking network I/O must be avoided (gunicorn worker import, Docker
cold start when Spotify is unreachable).
"""
_default = METADATA_SOURCE_PRIORITY[0]
return _get_config_value("metadata.fallback_source", _default) or _default
def get_primary_source(spotify_client_factory: Optional[MetadataClientFactory] = None) -> str:
"""Return configured primary metadata source."""
from core.boot_phase import is_boot_phase
if is_boot_phase():
return get_configured_primary_source()
_default = METADATA_SOURCE_PRIORITY[0]
source = _get_config_value("metadata.fallback_source", _default) or _default
source = get_configured_primary_source()
if source == "spotify":
try:
@ -447,7 +463,19 @@ def get_primary_source_status(
musicbrainz_client_factory: Optional[MetadataClientFactory] = None,
) -> Dict[str, Any]:
"""Return a generic status snapshot for the active primary metadata source."""
from core.boot_phase import is_boot_phase
source = _get_config_value("metadata.fallback_source", "deezer") or "deezer"
if is_boot_phase():
display_source = source
if source == "spotify" and _get_config_value("metadata.spotify_free", False):
display_source = "spotify_free"
return {
"source": display_source,
"connected": False,
"response_time": 0,
}
started = time.time()
connected = False
@ -515,9 +543,13 @@ def get_client_for_source(
musicbrainz_client_factory: Optional[MetadataClientFactory] = None,
):
"""Return exact client for a source, or None if unavailable."""
from core.boot_phase import is_boot_phase
if source == "spotify":
try:
client = get_spotify_client(client_factory=spotify_client_factory)
if is_boot_phase():
return client if client and getattr(client, "sp", None) else None
if client and client.is_spotify_authenticated():
return client
except Exception as e:

View file

@ -49,6 +49,7 @@ from core.metadata.registry import (
get_discogs_client,
get_hydrabase_client,
get_itunes_client,
get_configured_primary_source,
get_primary_client,
get_primary_source,
get_primary_source_label,
@ -117,6 +118,7 @@ __all__ = [
"get_metadata_service",
"get_musicmap_similar_artists",
"get_primary_client",
"get_configured_primary_source",
"get_primary_source",
"get_primary_source_label",
"get_spotify_client_for_profile",

View file

@ -164,6 +164,8 @@ class QobuzClient(DownloadSourcePlugin):
def _restore_session(self):
"""Try to restore saved session from config."""
from core.boot_phase import is_boot_phase
saved = config_manager.get('qobuz.session', {})
app_id = saved.get('app_id', '')
app_secret = saved.get('app_secret', '')
@ -178,6 +180,10 @@ class QobuzClient(DownloadSourcePlugin):
'X-User-Auth-Token': self.user_auth_token,
})
if is_boot_phase():
logger.info("Loaded Qobuz session from config (verification deferred until after boot)")
return
# Verify the token is still valid
try:
resp = self.session.get(

View file

@ -17,12 +17,27 @@ logger = get_logger("spotify_client")
# Single source of truth for the Spotify OAuth scope. Used by EVERY SpotifyOAuth
# construction (the client, the per-profile registry, and all web_server callbacks) so
# the authorize URL and token exchange can never request different scopes — a mismatch
# silently re-prompts or denies. The `playlist-modify-*` pair powers exporting a mirrored
# playlist back to Spotify (#945); existing users re-auth once to grant it.
# silently re-prompts or denies.
#
# IMPORTANT — do NOT add scopes here lightly. Spotipy's validate_token treats a cached
# token as invalid the moment the requested scope is no longer a subset of the token's
# granted scope, so GROWING this string invalidates EVERY existing user's token and forces
# a re-auth on upgrade. `playlist-modify-*` (for exporting a playlist back to Spotify, #945)
# was pulled back out for exactly that reason — it broke all Spotify users on upgrade. The
# Spotify export must request write access on-demand (incremental auth) instead.
SPOTIFY_OAUTH_SCOPE = (
"user-library-read user-read-private playlist-read-private "
"playlist-read-collaborative user-read-email user-follow-read "
"playlist-modify-public playlist-modify-private"
"playlist-read-collaborative user-read-email user-follow-read"
)
# The export scope = the normal login scope PLUS playlist write. Requested ONLY by the
# on-demand export-auth route (/auth/spotify/export) when a user chooses to export a playlist
# to Spotify — NEVER by the normal login. That's the whole safety property: the global scope
# above is unchanged, so no existing token is invalidated. The token Spotify returns from the
# export flow is a SUPERSET of the read scope, so it still passes the normal auth check
# (read ⊆ read+write) — one account, one token, just with write added for the opt-in user.
SPOTIFY_EXPORT_SCOPE = (
SPOTIFY_OAUTH_SCOPE + " playlist-modify-public playlist-modify-private"
)
@ -791,6 +806,16 @@ class SpotifyClient:
self._auth_cached_result = None
self._auth_cache_time = 0
def _has_cached_oauth_token(self) -> bool:
"""Return True when a persisted OAuth token exists (no network I/O)."""
if self.sp is None:
return False
try:
cache_handler = getattr(self.sp.auth_manager, 'cache_handler', None)
return bool(cache_handler and cache_handler.get_cached_token() is not None)
except Exception:
return False
def is_spotify_authenticated(self) -> bool:
"""Check if Spotify client is specifically authenticated (not just iTunes fallback).
Results are cached for 60 seconds to avoid excessive API calls.
@ -866,6 +891,26 @@ class SpotifyClient:
logger.debug("publish_spotify_status cache hit: %s", e)
return self._auth_cached_result
from core.boot_phase import is_boot_phase
if is_boot_phase():
result = self._has_cached_oauth_token()
with self._auth_cache_lock:
self._auth_cached_result = result
self._auth_cache_time = time.time()
try:
from core.metadata.status import publish_spotify_status
publish_spotify_status(
connected=result,
authenticated=result,
rate_limited=False,
rate_limit=None,
post_ban_cooldown=None,
)
except Exception as e:
logger.debug("publish_spotify_status boot-phase: %s", e)
return result
# Cache miss — make API call outside the lock.
# Safety: if there's no cached token, return False immediately.
# Without this guard, spotipy's auth_manager will try to start an interactive
@ -897,7 +942,8 @@ class SpotifyClient:
# Use a dedicated probe client (retries=0) so a 429 here propagates
# immediately and we can detect long Retry-After bans.
try:
probe = spotipy.Spotify(auth_manager=self.sp.auth_manager, retries=0)
probe = spotipy.Spotify(
auth_manager=self.sp.auth_manager, retries=0, requests_timeout=15)
probe.current_user()
result = True
except Exception as e:
@ -1148,6 +1194,19 @@ class SpotifyClient:
return playlists
return []
def has_write_scope(self) -> bool:
"""True when the cached Spotify token carries playlist-modify (the export write scope).
The export endpoint uses this to decide whether to run, or to first send the user
through the on-demand export-auth flow. Fail-safe: any error False (not authorized)."""
if self.sp is None:
return False
try:
cache_handler = getattr(self.sp.auth_manager, "cache_handler", None)
token = cache_handler.get_cached_token() if cache_handler else None
return "playlist-modify" in ((token or {}).get("scope") or "")
except Exception:
return False
def create_or_update_playlist(self, name, track_ids, *, existing_id=None,
public=False, description=""):
"""Create a Spotify playlist owned by the authed user (or replace an existing

View file

@ -520,8 +520,18 @@ class TidalClient:
return True
def is_authenticated(self):
def is_authenticated(self) -> bool:
"""Check if client is authenticated, refreshing expired tokens if possible"""
from core.boot_phase import is_boot_phase
if is_boot_phase():
if self.access_token and time.time() < self.token_expires_at:
return True
return bool(self.access_token and self.refresh_token)
# Token still valid — no refresh needed. (Restored: #949 moved this short-circuit
# into the boot-phase branch only, so every post-boot call fell through to the
# refresh below — a constant silent-refresh loop on a perfectly valid token.)
if self.access_token and time.time() < self.token_expires_at:
return True

View file

@ -134,6 +134,7 @@ class TidalDownloadClient(DownloadSourcePlugin):
self._device_auth_future = None
self._device_auth_link = None
self._boot_session_tokens: Optional[dict] = None
# Engine reference is populated by set_engine() at registration
# time. Until then dispatch returns None — orchestrator wires
@ -163,6 +164,14 @@ class TidalDownloadClient(DownloadSourcePlugin):
expiry_time = saved.get('expiry_time', 0)
if token_type and access_token:
from core.boot_phase import is_boot_phase
if is_boot_phase():
self._boot_session_tokens = saved
logger.info(
"Loaded Tidal download session from config (verification deferred until after boot)"
)
return
try:
expiry_dt = datetime.fromtimestamp(expiry_time, tz=timezone.utc) if expiry_time else None
@ -181,6 +190,39 @@ class TidalDownloadClient(DownloadSourcePlugin):
except Exception as e:
logger.warning(f"Could not restore Tidal session: {e}")
def _complete_deferred_session(self) -> bool:
"""Finish restoring a session that was deferred during boot."""
pending = getattr(self, '_boot_session_tokens', None)
if not pending or tidalapi is None:
self._boot_session_tokens = None
return False
if not self.session:
self.session = tidalapi.Session()
token_type = pending.get('token_type', '')
access_token = pending.get('access_token', '')
refresh_token = pending.get('refresh_token', '')
expiry_time = pending.get('expiry_time', 0)
self._boot_session_tokens = None
try:
expiry_dt = datetime.fromtimestamp(expiry_time, tz=timezone.utc) if expiry_time else None
restored = self.session.load_oauth_session(
token_type=token_type,
access_token=access_token,
refresh_token=refresh_token,
expiry_time=expiry_dt,
)
if restored and self.session.check_login():
logger.info("Restored Tidal download session from saved tokens")
self._save_session()
return True
logger.warning("Saved Tidal session tokens are invalid/expired")
except Exception as e:
logger.warning(f"Could not restore Tidal session: {e}")
return False
def _save_session(self):
if not self.session:
return
@ -192,6 +234,14 @@ class TidalDownloadClient(DownloadSourcePlugin):
})
def is_authenticated(self) -> bool:
from core.boot_phase import is_boot_phase
if is_boot_phase():
pending = getattr(self, '_boot_session_tokens', None)
return bool(pending and pending.get('access_token'))
if getattr(self, '_boot_session_tokens', None):
return self._complete_deferred_session()
if not self.session:
return False
try:

View file

@ -1,43 +1,21 @@
# soulsync 2.8.1`dev``main`
# soulsync 2.8.2`dev``main`
a feature + reliability release. the headline is **export a mirrored playlist back to Spotify or Deezer** — same one-click flow as the listenbrainz export, now pointed at the streaming services. plus a **rename-only mode** for Library Reorganize, broader lossless handling, a pile of download fixes, and the reduce-visual-effects pass refined so it stops freezing functional motion.
a stability + performance release. the headline is **Spotify reliability** (the Docker boot hang and the "logged out / re-auth won't stick" issues are fixed), a big **performance** win that explains the "slow after update" reports, and **large-library imports** that no longer time out the import page.
---
## what's new
### 🎧 Export playlists to Spotify & Deezer (#945)
the mirrored-playlist export modal now has **Sync to Spotify** and **Sync to Deezer** next to the listenbrainz / jspf options. it builds a playlist in your account from the tracks soulsync already has the service IDs for:
### 🎧 Spotify reliability
- **Docker boot hang fixed (#949 — thanks HellRa1SeR)** — with Spotify set as your primary metadata source, an unreachable Spotify API could block the gunicorn worker during startup, so the container bound port 8008 but never actually served the Web UI. provider auth probes are now deferred during boot (and capped with a timeout), so startup can't hang on a slow Spotify. same guard added for Qobuz / Deezer / Tidal.
- **"re-auth didn't stick" fixed** — the OAuth callback wrote your token to one cache while the app read another, so re-authenticating could silently fail validation (and trip an `Address already in use` on the callback port). unified on one token store — re-auth takes effect now.
- **Sync to Spotify works** — exporting a mirrored playlist to Spotify now asks for playlist-write permission **once**, on-demand, the first time you use it. your normal Spotify login is untouched, so upgrading never forces a re-auth.
- resolves each track from what's already on hand first — the **discovery cache**, then your library's stored IDs — so for an already-discovered playlist it's instant and uses **zero API calls**
- re-exporting **updates the same playlist in place** instead of spawning duplicates
- an optional **"match missing tracks"** toggle does a confident live search for the stragglers — and only adds a match it's sure about (a wrong-artist or karaoke version is left out, never guessed)
- service buttons grey out + point you to Settings when that service isn't connected
- spotify needs a one-time reconnect to grant playlist-write access
### ⚡ Performance — the "slow after update" fix
- **Password-manager autofill storm fixed (#948 — thanks @nick2000713)** — the real cause of the post-update lag wasn't SoulSync rendering, it was browser password managers (Bitwarden / 1Password / etc.) rebuilding their autofill overlay on **every** DOM change — and SoulSync mutates the DOM constantly (live status, progress bars, countdowns). non-credential fields are now marked so managers skip them (your login fields are left alone). the reporter measured **~110× less main-thread blocking** and ~20 → ~96 FPS.
- **Max Performance mode (new)** — Settings → Appearance. one switch kills the worker orbs, particles, all blur/shadows, and every animation/transition, and greys out the individual effect toggles so it's clearly in charge. for software-rendered / no-GPU setups (Docker, remote desktop) where even simple animations cost real CPU.
### 🏷️ Library Reorganize — Rename only (#875)
a lighter reorganize action: it just **renames your files** to your current naming scheme — no re-tagging, no quality/AcoustID re-check, no copy-to-staging. much faster on a NAS, won't fail on post-processing reasons, and only touches files whose path actually changes (which also fixes the "2 of 14 previewed but everything got modified" album-splitting). pick it from the new **Action** dropdown in the reorganize modal.
### 💿 Lossless handling
- lossy-copy now works for **all lossless formats**, not just FLAC (#941)
- **DSD** (`.dsf` / `.dff`) is recognized as lossless and no longer false-flagged as "truncated" (#939)
### 🐛 Download + search fixes
- a download with an unbalanced bracket in its name no longer false-fails as "file not found"
- a file we couldn't quarantine is left in place for retry instead of deleted
- the Identify search for single imports defaults to "artist - title" (with the dash)
- "file not found" failures now say what actually happened instead of an opaque error
- pasted Qobuz/Tidal links **inject the exact track** into manual search instead of hoping text-search surfaces it (#932)
- the Wing It pool "Fix Match" search works again (it was returning "no results" for everything)
### ⚡ Visual effects + scan reliability
- **Reduce visual effects** no longer freezes functional motion (spinners, progress) — it only kills the expensive GPU stuff (blur, shadows, glow)
- worker orbs default **OFF on Firefox** for new users, and run at ~30fps under reduce-effects
- jellyfin library scans page the bulk fetch so the no-progress watchdog can't false-stall a big library
### 🔧 Under the hood
- settings page cleanup (#943 — thanks @nick2000713)
- spotify oauth credential normalization + redirect-uri handling (#942 — thanks HellRa1SeR)
- security: npm audit fixes for vite / undici / @babel (#944 — thanks HellRa1SeR)
### 📥 Large-library imports no longer time out (#947 — thanks @ramonskie)
- dropping a whole library into your staging folder used to make the import page scan every file synchronously and blow past the request timeout — so the page never loaded, and every reload re-timed-out. the scan now runs in the **background** with a live **"Scanning N of M…"** progress, and the page fills in automatically when it's done. (auto-import remains the hands-off path for the actual matching.)
enjoy 🎶

View file

@ -0,0 +1,163 @@
"""Async/background staging scan (#947): a whole-library migration makes the synchronous
scan exceed gunicorn's 120s timeout. The runner moves the SAME scan off the request thread
with progress + a generation guard. Metadata reads are injected so no real audio is needed."""
import os
import time
import types
import pytest
import core.imports.routes as routes
def _meta(_full, _rel):
return {"title": "t", "album": "Alb", "artist": "Art", "albumartist": "Art",
"track_number": None, "disc_number": None}
def _runtime(read=_meta):
return types.SimpleNamespace(read_staging_file_metadata=read)
def _staging(tmp_path, n=3, subdir="Artist/Album"):
d = tmp_path / "staging"
for part in subdir.split("/"):
d = d / part
d.mkdir(parents=True, exist_ok=True)
for i in range(n):
(d / f"{i:02d}.flac").write_text("x")
return str(tmp_path / "staging")
@pytest.fixture(autouse=True)
def _reset():
routes.invalidate_staging_scan_cache()
routes._staging_scan_status.update({"status": "idle", "scanned": 0, "total": 0,
"path": None, "error": None})
yield
routes.invalidate_staging_scan_cache()
def _await_done(timeout=5.0):
end = time.time() + timeout
while time.time() < end and routes._staging_scan_status["status"] == "scanning":
time.sleep(0.03)
def test_scan_reports_progress(tmp_path):
sp = _staging(tmp_path, 3)
prog = {}
recs = routes._scan_staging_records(_runtime(), sp, progress=prog)
assert len(recs) == 3
assert prog["total"] == 3 and prog["scanned"] == 3
def test_default_scan_behaviour_unchanged(tmp_path):
sp = _staging(tmp_path, 2)
assert len(routes._scan_staging_records(_runtime(), sp)) == 2 # no progress arg = as before
def test_accessor_ready_for_small_folder(tmp_path):
sp = _staging(tmp_path, 2)
state, val = routes.get_staging_records_or_status(_runtime(), sp, grace_seconds=3.0)
assert state == "ready" and len(val) == 2
def test_accessor_scanning_when_scan_exceeds_grace(tmp_path):
sp = _staging(tmp_path, 2)
def slow(_f, _r):
time.sleep(0.4)
return _meta(_f, _r)
state, val = routes.get_staging_records_or_status(_runtime(slow), sp, grace_seconds=0.1)
assert state == "scanning"
assert val["status"] == "scanning" and val["total"] in (0, 2)
_await_done()
def test_ensure_scan_is_idempotent(tmp_path):
sp = _staging(tmp_path, 2)
calls = {"n": 0}
def counting(_f, _r):
calls["n"] += 1
time.sleep(0.15)
return _meta(_f, _r)
rt = _runtime(counting)
routes.ensure_background_staging_scan(rt, sp)
routes.ensure_background_staging_scan(rt, sp) # must NOT start a second scan
_await_done()
assert calls["n"] == 2 # 2 files read once, not 4
def test_generation_guard_discards_stale_records(tmp_path):
sp = _staging(tmp_path, 2)
def read_then_import(_f, _r):
routes.invalidate_staging_scan_cache() # simulate an import landing mid-scan
return _meta(_f, _r)
recs = routes._scan_staging_records(_runtime(read_then_import), sp)
assert len(recs) == 2 # caller still gets its records
assert routes._staging_scan_cache["records"] is None # but stale set NOT committed to cache
def _full_runtime(staging_path, read=_meta):
return types.SimpleNamespace(
get_staging_path=lambda: staging_path,
read_staging_file_metadata=read,
logger=types.SimpleNamespace(error=lambda *a, **k: None),
)
def test_helper_passes_records_through_when_ready(monkeypatch):
monkeypatch.setattr(routes, 'get_staging_records_or_status',
lambda rt, sp: ("ready", [{"x": 1}]))
records, scanning = routes._records_or_scanning_payload(_runtime(), "/x")
assert scanning is None and records == [{"x": 1}]
def test_helper_builds_scanning_payload(monkeypatch):
monkeypatch.setattr(routes, 'get_staging_records_or_status',
lambda rt, sp: ("scanning", {"scanned": 5, "total": 20, "status": "scanning"}))
records, scanning = routes._records_or_scanning_payload(_runtime(), "/x")
assert records is None
assert scanning == {"success": True, "scanning": True,
"progress": {"scanned": 5, "total": 20}}
def test_staging_files_endpoint_ready(tmp_path):
payload, status = routes.staging_files(_full_runtime(_staging(tmp_path, 2)))
assert status == 200 and payload["success"] and len(payload["files"]) == 2
def test_staging_files_endpoint_returns_scanning(tmp_path, monkeypatch):
monkeypatch.setattr(routes, 'get_staging_records_or_status',
lambda r, p: ("scanning", {"scanned": 3, "total": 10}))
payload, status = routes.staging_files(_full_runtime(_staging(tmp_path, 2)))
assert status == 200 and payload.get("scanning") is True
assert payload["progress"] == {"scanned": 3, "total": 10}
def test_staging_groups_endpoint_returns_scanning(tmp_path, monkeypatch):
monkeypatch.setattr(routes, 'get_staging_records_or_status',
lambda r, p: ("scanning", {"scanned": 1, "total": 9}))
payload, status = routes.staging_groups(_full_runtime(_staging(tmp_path, 2)))
assert payload.get("scanning") is True
def test_scan_status_ready_after_warm(tmp_path):
sp = _staging(tmp_path, 2)
routes._scan_staging_records(_runtime(), sp) # warm the cache
payload, status = routes.staging_scan_status(_full_runtime(sp))
assert status == 200 and payload["success"] and payload["ready"] is True
def test_scan_status_not_ready_when_cold(tmp_path):
sp = _staging(tmp_path, 2) # cold (autouse reset)
payload, _ = routes.staging_scan_status(_full_runtime(sp))
assert payload["ready"] is False
assert "scanned" in payload and "total" in payload

View file

@ -0,0 +1,77 @@
"""Boot-phase guards must defer blocking provider network probes."""
from unittest.mock import MagicMock, patch
from core.boot_phase import is_boot_phase, mark_boot_complete
from core.metadata import registry
def setup_function():
mark_boot_complete()
def teardown_function():
mark_boot_complete()
def test_get_primary_source_skips_spotify_probe_during_boot(monkeypatch):
import core.boot_phase as boot_phase
boot_phase._boot_active = True
monkeypatch.setattr(registry, "get_configured_primary_source", lambda: "spotify")
with patch.object(registry, "get_spotify_client") as get_client:
assert registry.get_primary_source() == "spotify"
get_client.assert_not_called()
def test_get_primary_source_status_skips_client_probe_during_boot(monkeypatch):
import core.boot_phase as boot_phase
boot_phase._boot_active = True
monkeypatch.setattr(
registry, "_get_config_value",
lambda key, default=None: "spotify" if key == "metadata.fallback_source" else default,
)
with patch.object(registry, "get_client_for_source") as get_client:
status = registry.get_primary_source_status()
get_client.assert_not_called()
assert status["source"] == "spotify"
assert status["connected"] is False
def test_spotify_auth_uses_token_presence_only_during_boot(monkeypatch):
import core.boot_phase as boot_phase
from core.spotify_client import SpotifyClient
boot_phase._boot_active = True
client = SpotifyClient.__new__(SpotifyClient)
client.sp = MagicMock()
client._auth_cache_lock = __import__('threading').Lock()
client._auth_cached_result = None
client._auth_cache_time = 0
client._AUTH_CACHE_TTL = 900
monkeypatch.setattr(client, "_has_cached_oauth_token", lambda: True)
with patch("spotipy.Spotify") as spotify_cls:
assert client.is_spotify_authenticated() is True
spotify_cls.assert_not_called()
def test_deezer_download_defers_arl_auth_during_boot(monkeypatch):
import core.boot_phase as boot_phase
from core.deezer_download_client import DeezerDownloadClient
boot_phase._boot_active = True
monkeypatch.setattr(
"config.settings.config_manager.get",
lambda key, default=None: "fake-arl" if key == "deezer_download.arl" else default,
)
with patch.object(DeezerDownloadClient, "_authenticate") as authenticate:
client = DeezerDownloadClient(download_path="/tmp/deezer-test")
authenticate.assert_not_called()
assert client._pending_arl == "fake-arl"
assert client.is_authenticated() is False

View file

@ -0,0 +1,32 @@
"""Tests for boot-safe configured primary source lookup."""
from unittest.mock import MagicMock, patch
from core.boot_phase import mark_boot_complete
from core.metadata import registry
def setup_function():
mark_boot_complete()
def test_get_configured_primary_source_reads_config_without_auth_probe(monkeypatch):
monkeypatch.setattr(
registry,
"_get_config_value",
lambda key, default=None: "spotify" if key == "metadata.fallback_source" else default,
)
with patch.object(registry, "get_spotify_client") as get_client:
assert registry.get_configured_primary_source() == "spotify"
get_client.assert_not_called()
def test_get_primary_source_still_downgrades_unauthenticated_spotify(monkeypatch):
monkeypatch.setattr(registry, "get_configured_primary_source", lambda: "spotify")
spotify = MagicMock()
spotify.is_spotify_authenticated.return_value = False
monkeypatch.setattr(registry, "get_spotify_client", lambda **_: spotify)
assert registry.get_primary_source() == registry.METADATA_SOURCE_PRIORITY[0]

View file

@ -128,3 +128,27 @@ def test_spotify_backfill_search_disables_cross_service_fallback(monkeypatch):
assert fn is not None
fn('Kendrick Lamar', 'Not Like Us') # drives the search
assert seen['allow_fallback'] is False
def test_spotify_export_endpoint_demands_auth_when_no_write_scope(monkeypatch):
"""The export endpoint must return needs_auth (not start a doomed job) when the Spotify
token lacks write scope and it must short-circuit BEFORE touching the DB."""
import types
monkeypatch.setattr(ws, 'spotify_client',
types.SimpleNamespace(has_write_scope=lambda: False))
resp = ws.app.test_client().post('/api/playlists/5/export/service/spotify')
data = resp.get_json()
assert data['needs_auth'] is True
assert data['auth_url'] == '/auth/spotify/export'
assert data['success'] is False
def test_spotify_export_endpoint_proceeds_when_write_scope_present(monkeypatch):
"""With write scope, the spotify path must NOT short-circuit on needs_auth (it goes on to
start a job here it just must not be a needs_auth response)."""
import types
monkeypatch.setattr(ws, 'spotify_client',
types.SimpleNamespace(has_write_scope=lambda: True))
resp = ws.app.test_client().post('/api/playlists/999999/export/service/spotify')
data = resp.get_json()
assert not data.get('needs_auth')

View file

@ -164,3 +164,77 @@ def test_insufficient_scope_says_reconnect():
raise Exception('403 Forbidden: insufficient client scope')
res = _spotify_with(_ScopeErr()).create_or_update_playlist('X', ['a'])
assert not res['success'] and 'Reconnect Spotify' in res['error']
# ── Spotify auth regression hotfix: scope must not force re-auth; callbacks must write
# the DB store the client reads (else a re-auth never takes effect) ──
import os as _os
def test_oauth_scope_has_no_write_scope_that_forces_reauth():
"""Spotipy invalidates a cached token the moment the requested scope stops being a subset
of the token's granted scope — so GROWING the global scope forces every user to re-auth on
upgrade (it broke all Spotify users). The write scope (playlist-modify) must NOT live in the
global scope; request it on-demand instead."""
from core.spotify_client import SPOTIFY_OAUTH_SCOPE
assert 'playlist-modify' not in SPOTIFY_OAUTH_SCOPE
# the read scopes existing tokens already carry must stay
for s in ('user-library-read', 'user-read-private', 'playlist-read-private',
'playlist-read-collaborative', 'user-read-email', 'user-follow-read'):
assert s in SPOTIFY_OAUTH_SCOPE
def test_global_oauth_callbacks_use_db_token_cache_not_file():
"""The OAuth callbacks wrote the new token to the legacy file cache while the client reads
DatabaseTokenCache, so a re-auth never reached the client ("validation failed" despite a good
exchange). The global callbacks must write the same DB-backed store the client uses."""
root = _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))
src = open(_os.path.join(root, 'web_server.py'), encoding='utf-8').read()
assert "cache_path='config/.spotify_cache'" not in src # no global file-cache writes
assert src.count('cache_handler=DatabaseTokenCache(config_manager)') >= 2
# ── on-demand Spotify export write-auth (#945 follow-up) ──
def test_export_scope_is_global_plus_write_and_global_stays_readonly():
"""The export scope adds playlist-modify ON TOP of the unchanged global scope. Critically
the GLOBAL scope must NOT gain write (that's what force-invalidated everyone's token)."""
from core.spotify_client import SPOTIFY_OAUTH_SCOPE, SPOTIFY_EXPORT_SCOPE
assert 'playlist-modify' not in SPOTIFY_OAUTH_SCOPE # global stays read-only
assert 'playlist-modify-public' in SPOTIFY_EXPORT_SCOPE
assert 'playlist-modify-private' in SPOTIFY_EXPORT_SCOPE
# export scope is a strict superset of the global read scope
assert set(SPOTIFY_OAUTH_SCOPE.split()).issubset(set(SPOTIFY_EXPORT_SCOPE.split()))
class _CacheHandler:
def __init__(self, token):
self._token = token
def get_cached_token(self):
return self._token
def _client_with_token(token):
import types
c = _SpotifyClient.__new__(_SpotifyClient)
c.sp = types.SimpleNamespace(auth_manager=types.SimpleNamespace(cache_handler=_CacheHandler(token)))
return c
def test_has_write_scope_true_when_token_carries_playlist_modify():
tok = {'scope': 'user-library-read playlist-modify-public playlist-read-private'}
assert _client_with_token(tok).has_write_scope() is True
def test_has_write_scope_false_for_readonly_token_or_missing():
assert _client_with_token({'scope': 'user-library-read playlist-read-private'}).has_write_scope() is False
assert _client_with_token(None).has_write_scope() is False # no cached token
assert _client_with_token({}).has_write_scope() is False # token w/o scope field
def test_has_write_scope_false_when_no_client():
c = _SpotifyClient.__new__(_SpotifyClient)
c.sp = None
assert c.has_write_scope() is False

View file

@ -0,0 +1,46 @@
"""Regression: post-boot, is_authenticated() must NOT refresh a still-valid Tidal token.
#949 moved the "token still valid -> return True" short-circuit into the boot-phase branch
only, so every post-boot call fell through to the silent refresh a constant-refresh loop
(wolf's logs: "access token expired -> refresh -> success" every few seconds)."""
import time
import core.boot_phase as boot_phase
from core.tidal_client import TidalClient
def _client(expires_at):
c = TidalClient.__new__(TidalClient)
c.access_token = "tok"
c.refresh_token = "refresh"
c.token_expires_at = expires_at
c._refresh_calls = 0
def _fake_refresh():
c._refresh_calls += 1
return True
c._refresh_access_token = _fake_refresh
return c
def test_valid_token_does_not_refresh_post_boot(monkeypatch):
monkeypatch.setattr(boot_phase, "_boot_active", False) # post-boot
c = _client(time.time() + 3600) # valid for an hour
assert c.is_authenticated() is True
assert c._refresh_calls == 0 # MUST NOT refresh a valid token
def test_expired_token_still_refreshes_post_boot(monkeypatch):
monkeypatch.setattr(boot_phase, "_boot_active", False)
c = _client(time.time() - 10) # expired
assert c.is_authenticated() is True
assert c._refresh_calls == 1 # expired -> one refresh
def test_valid_token_returns_true_during_boot(monkeypatch):
monkeypatch.setattr(boot_phase, "_boot_active", True) # boot phase
c = _client(time.time() + 3600)
assert c.is_authenticated() is True
assert c._refresh_calls == 0 # boot never probes/refreshes

View file

@ -40,7 +40,7 @@ logger = setup_logging(_log_level, _log_path)
# App version — single source of truth for backup metadata, system-info, update check, etc.
# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release.
_SOULSYNC_BASE_VERSION = "2.8.1"
_SOULSYNC_BASE_VERSION = "2.8.2"
def _build_version_string():
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
@ -177,6 +177,7 @@ from core.imports.routes import singles_process as _import_singles_process
from core.imports.routes import staging_files as _import_staging_files
from core.imports.routes import staging_groups as _import_staging_groups
from core.imports.routes import staging_hints as _import_staging_hints
from core.imports.routes import staging_scan_status as _import_staging_scan_status
from core.imports.routes import staging_suggestions as _import_staging_suggestions
from core.imports.paths import build_final_path_for_track as _build_final_path_for_track
from core.imports.pipeline import build_import_pipeline_runtime as _build_import_pipeline_runtime
@ -387,6 +388,7 @@ def _initial_appearance_context():
_request_is_firefox(),
)
reduce_effects = config_manager.get('ui_appearance.reduce_effects', False) is True
max_performance = config_manager.get('ui_appearance.max_performance', False) is True
r, g, b = _hex_to_rgb(accent)
hue, saturation, lightness = _rgb_to_hsl(r, g, b)
light = _hsl_to_rgb(hue, saturation, min(lightness + 0.16, 0.95))
@ -399,6 +401,7 @@ def _initial_appearance_context():
'initial_particles_enabled': particles_enabled,
'initial_worker_orbs_enabled': worker_orbs_enabled,
'initial_reduce_effects': reduce_effects,
'initial_max_performance': max_performance,
}
@ -4940,6 +4943,38 @@ def auth_spotify():
logger.error(f"Error starting Spotify auth: {e}")
return f"<h1>Spotify Authentication Error</h1><p>{str(e)}</p>", 500
@app.route('/auth/spotify/export')
def auth_spotify_export():
"""On-demand authorization for Spotify playlist EXPORT (#945).
Requests the export scope (the normal read scope + playlist-modify) so the user can write
a playlist to their Spotify WITHOUT changing the global login scope, so no existing
token is invalidated. Spotify returns a superset token; the normal /callback exchanges and
stores it unchanged (read read+write keeps the standard auth check happy). show_dialog
forces the consent screen so the new write permission is actually granted."""
try:
from spotipy.oauth2 import SpotifyOAuth
from core.spotify_client import normalize_spotify_oauth_config, SPOTIFY_EXPORT_SCOPE
from core.spotify_token_cache import DatabaseTokenCache
cfg = normalize_spotify_oauth_config(config_manager.get_spotify_config())
if not cfg.get('client_id') or not cfg.get('client_secret'):
return "<h1>Spotify not configured</h1><p>Add your Spotify app credentials in Settings first.</p>", 400
auth_manager = SpotifyOAuth(
client_id=cfg['client_id'],
client_secret=cfg['client_secret'],
redirect_uri=cfg.get('redirect_uri', 'http://127.0.0.1:8888/callback'),
scope=SPOTIFY_EXPORT_SCOPE,
cache_handler=DatabaseTokenCache(config_manager),
show_dialog=True,
)
add_activity_item("", "Spotify Export Auth", "Requesting permission to create playlists", "Now")
return redirect(auth_manager.get_authorize_url())
except Exception as e:
logger.error(f"Error starting Spotify export auth: {e}")
return f"<h1>Spotify Export Authorization Error</h1><p>{str(e)}</p>", 500
@app.route('/auth/tidal')
def auth_tidal():
"""
@ -5229,12 +5264,16 @@ def spotify_callback():
configured_uri = config.get('redirect_uri', "http://127.0.0.1:8888/callback")
logger.info(f"Using redirect_uri for token exchange: {configured_uri}")
# Write the freshly-exchanged token to the SAME store the client reads
# (DatabaseTokenCache), not the legacy file — otherwise a re-auth never reaches the
# client and "validation failed" even though the exchange succeeded.
from core.spotify_token_cache import DatabaseTokenCache
auth_manager = SpotifyOAuth(
client_id=config['client_id'],
client_secret=config['client_secret'],
redirect_uri=configured_uri,
scope=SPOTIFY_OAUTH_SCOPE,
cache_path='config/.spotify_cache'
cache_handler=DatabaseTokenCache(config_manager)
)
token_info = auth_manager.get_access_token(auth_code)
@ -27916,6 +27955,15 @@ def start_playlist_export_service(playlist_id, service):
service = (service or '').lower()
if service not in ('spotify', 'deezer'):
return jsonify({"success": False, "error": f"Unsupported export target: {service}"}), 400
# Spotify export needs the write scope, which the normal login doesn't request. If the
# current token doesn't carry it, tell the UI to send the user through the one-time
# on-demand export-auth (instead of starting a job that would 403). #945.
if service == 'spotify' and not (spotify_client and spotify_client.has_write_scope()):
return jsonify({
"success": False, "needs_auth": True,
"auth_url": "/auth/spotify/export",
"error": "Spotify needs permission to create playlists",
}), 200
body = request.get_json(silent=True) or {}
backfill = bool(body.get('backfill'))
db = get_database()
@ -36111,13 +36159,17 @@ def start_oauth_callback_servers():
configured_uri = config.get('redirect_uri', "http://127.0.0.1:8888/callback")
_oauth_logger.info(f"Using redirect_uri for token exchange: {configured_uri}")
# Create auth manager and exchange code for token
# Create auth manager and exchange code for token. Use the SAME store
# the client reads (DatabaseTokenCache), not the legacy file — a re-auth
# written to the file never reaches the DB-backed client, so it would
# report "validation failed" despite a successful exchange.
from core.spotify_token_cache import DatabaseTokenCache
auth_manager = SpotifyOAuth(
client_id=config['client_id'],
client_secret=config['client_secret'],
redirect_uri=configured_uri,
scope=SPOTIFY_OAUTH_SCOPE,
cache_path='config/.spotify_cache'
cache_handler=DatabaseTokenCache(config_manager)
)
# Extract the authorization code and exchange it for tokens
@ -36483,11 +36535,13 @@ except Exception as e:
# they explicitly want background Spotify enrichment.
spotify_enrichment_worker = None
try:
from core.metadata_service import get_primary_source as _get_primary_source
from core.metadata_service import get_configured_primary_source
from database.music_database import MusicDatabase
spotify_enrichment_db = MusicDatabase()
spotify_enrichment_worker = SpotifyWorker(database=spotify_enrichment_db)
_primary = _get_primary_source()
# Use configured source only — get_primary_source() probes Spotify auth and can
# block gunicorn worker boot indefinitely when the API is unreachable.
_primary = get_configured_primary_source()
_user_paused = config_manager.get('spotify_enrichment_paused', False)
if _user_paused or _primary != 'spotify':
spotify_enrichment_worker.paused = True # Set BEFORE start() to prevent race condition
@ -37649,6 +37703,12 @@ def import_staging_groups():
return jsonify(payload), status
@app.route('/api/import/staging/scan-status', methods=['GET'])
def import_staging_scan_status():
payload, status = _import_staging_scan_status(_build_import_route_runtime())
return jsonify(payload), status
@app.route('/api/import/staging/hints', methods=['GET'])
def import_staging_hints():
payload, status = _import_staging_hints(_build_import_route_runtime())
@ -38967,6 +39027,11 @@ def start_runtime_services():
_runtime_started = True
# Module import is complete — provider clients may now perform network probes.
from core.boot_phase import mark_boot_complete
mark_boot_complete()
# Direct execution: python web_server.py (dev/Windows fallback)
# Production should use: gunicorn -c gunicorn.conf.py wsgi:application
if _DIRECT_RUN:

View file

@ -24,11 +24,12 @@
window._particlesEnabled = {{ initial_particles_enabled|tojson }};
window._workerOrbsEnabled = {{ initial_worker_orbs_enabled|tojson }};
window._reduceEffectsActive = {{ initial_reduce_effects|tojson }};
window._maxPerfActive = {{ initial_max_performance|tojson }};
</script>
{{ vite_assets('head')|safe }}
</head>
<body{% if initial_reduce_effects %} class="reduce-effects"{% endif %}>
<body class="{% if initial_reduce_effects %}reduce-effects {% endif %}{% if initial_max_performance %}max-performance{% endif %}">
<!-- Setup Wizard Overlay -->
<div id="setup-wizard-overlay" class="setup-wizard-overlay" style="display: none;">
<div class="setup-wizard-container">
@ -6438,7 +6439,14 @@
<input type="checkbox" id="reduce-effects-enabled">
Reduce Visual Effects
</label>
<small class="settings-hint">Disables backdrop blur, animations, transitions, and shadows. Significantly reduces GPU/CPU usage on low-end devices.</small>
<small class="settings-hint">Disables backdrop blur and shadows (the GPU-heavy bits). Keeps functional motion like loading spinners. Good for low-end devices.</small>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="max-performance-enabled">
Max Performance
</label>
<small class="settings-hint">Nuclear low-power mode for software-rendered / no-GPU setups (e.g. Docker, remote desktop). Turns off Worker Orbs, Particles, all blur/shadows AND every animation &amp; transition (loading spinners go static). Overrides the three options above.</small>
</div>
</div>

View file

@ -23,11 +23,21 @@ export interface ImportStagingFile {
manual_match?: ImportTrackResult;
}
/** While a large staging folder is still being scanned in the background (#947), the
* staging endpoints return `scanning: true` + progress instead of files/groups; the query
* polls until the scan completes and real data arrives. */
export interface ImportScanProgress {
scanned: number;
total: number;
}
export interface ImportStagingFilesPayload {
success: boolean;
files?: ImportStagingFile[];
staging_path?: string;
error?: string;
scanning?: boolean;
progress?: ImportScanProgress;
}
export interface ImportStagingGroup {
@ -47,6 +57,8 @@ export interface ImportStagingGroupsPayload {
success: boolean;
groups?: ImportStagingGroup[];
error?: string;
scanning?: boolean;
progress?: ImportScanProgress;
}
export interface ImportAlbumResult {

View file

@ -250,6 +250,19 @@ describe('import route', () => {
expect(await screen.findByText('Import folder: error')).toBeInTheDocument();
});
it('shows scan progress while a large staging folder is still scanning (#947)', async () => {
server.use(
http.get('/api/import/staging/files', () =>
HttpResponse.json({ success: true, scanning: true, progress: { scanned: 5, total: 20 } }),
),
);
renderImportRoute();
expect(await screen.findByTestId('import-page')).toBeInTheDocument();
expect(await screen.findByText(/Scanning 5 of 20 files/)).toBeInTheDocument();
});
it('stores the active tab in nested route paths', async () => {
const { history } = renderImportRoute();

View file

@ -21,17 +21,24 @@ import { fallbackImage, RefreshIcon, useImportStaging } from './import-shared';
export function ImportPage() {
useReactPageShell('import');
const { refreshStaging, stagingFiles, stagingPath, stagingQuery } = useImportStaging();
const { refreshStaging, scanning, scanProgress, stagingFiles, stagingPath, stagingQuery } =
useImportStaging();
const isRefreshing = stagingQuery.isRefetching;
const lastRefreshedAt =
stagingQuery.dataUpdatedAt > 0 ? formatShortTime(stagingQuery.dataUpdatedAt) : null;
// While a large staging folder is still scanning (#947), show progress instead of a count.
const fileCountText = scanning
? scanProgress && scanProgress.total > 0
? `Scanning ${scanProgress.scanned} of ${scanProgress.total} files…`
: 'Scanning staging folder…'
: getStagingStatsText(stagingFiles);
return (
<div id="import-page" data-testid="import-page">
<div className={styles.importPageContainer}>
<ImportHeader
error={stagingQuery.error}
fileCountText={getStagingStatsText(stagingFiles)}
fileCountText={fileCountText}
loading={stagingQuery.isLoading}
stagingPath={stagingPath}
refreshing={isRefreshing}

View file

@ -1,4 +1,5 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import type { ImportQueueJob, ImportStagingFile } from '../-import.types';
@ -20,6 +21,22 @@ export function useImportStaging() {
...importStagingFilesQueryOptions(),
});
// A large staging folder (whole-library migration, #947) is scanned in the background; the
// endpoints return `scanning: true` until it's done. While scanning, poll so the page fills
// in automatically once the scan completes. Invalidate ALL staging queries (files, groups,
// suggestions) — not just files — so the album tab's separate groups query refetches too,
// otherwise it would stay stuck on its initial {scanning} response. A plain setInterval (NOT
// react-query's refetchInterval) that only runs while scanning leaves normal/error states
// untouched; only currently-mounted queries actually refetch.
const scanning = stagingQuery.data?.scanning === true;
useEffect(() => {
if (!scanning) return undefined;
const id = window.setInterval(() => {
void invalidateImportStagingQueries(queryClient);
}, 1500);
return () => window.clearInterval(id);
}, [scanning, queryClient]);
return {
refreshStaging: async () => {
clearFinishedJobs();
@ -28,6 +45,8 @@ export function useImportStaging() {
// Keep the empty fallback stable so staging-driven effects do not loop while loading.
stagingFiles: stagingQuery.data?.files ?? EMPTY_STAGING_FILES,
stagingPath: stagingQuery.data?.staging_path || 'Not configured',
scanning,
scanProgress: stagingQuery.data?.progress ?? null,
stagingQuery,
};
}

View file

@ -386,8 +386,8 @@ function _renderEqualizerBars(grid, data) {
// Call embers: tiny accent sparks rise off the fill tip, spawned per
// socket update in proportion to REAL traffic — motion strictly means
// API calls are happening right now. Suppressed during cooldown and
// under reduced-effects.
if (!window._reduceEffectsActive && !cooling && realPct > 0.03) {
// under reduced-effects / max-performance.
if (!window._reduceEffectsActive && !window._maxPerfActive && !cooling && realPct > 0.03) {
_spawnEmbers(bar, pct, realPct > 0.6 ? 3 : realPct > 0.25 ? 2 : 1);
}

View file

@ -3404,15 +3404,15 @@ function closeHelperSearch() {
const WHATS_NEW = {
// Convention: keep only the CURRENT release here, plus a single brief
// "Earlier versions" summary entry. Don't accumulate old per-version blocks.
'2.8.1': [
{ date: 'June 2026 — 2.8.1 release' },
{ title: 'Export playlists to Spotify & Deezer (#945)', desc: 'the mirrored-playlist export modal 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). spotify needs a one-time reconnect for write access.', page: 'playlists' },
{ title: 'Library Reorganize — Rename only (#875)', desc: 'a lighter reorganize action that just renames your files to your current naming scheme — no re-tagging, no quality/AcoustID re-check, no copy-to-staging. much faster on a NAS, won\'t fail on post-processing, and only touches files whose path actually changes (which also fixes the "2 of 14 previewed but everything got modified" album-splitting). pick it from the new Action dropdown. (thanks @tsoulard / @Tacobell444.)', page: 'library' },
{ title: 'Broader lossless handling (#941, #939)', desc: 'lossy-copy now works for all lossless formats, not just FLAC; and DSD (.dsf/.dff) is recognized as lossless instead of being false-flagged as "truncated".', page: 'downloads' },
{ title: 'Download + search fixes', desc: 'an unbalanced bracket in a filename no longer false-fails as "file not found"; a file we couldn\'t quarantine is left for retry instead of deleted; the Identify search defaults to "artist - title"; "file not found" errors are actionable now; pasted Qobuz/Tidal links inject the exact track into manual search (#932); and the Wing It pool "Fix Match" search works again.', page: 'downloads' },
{ title: 'Reduce visual effects, refined', desc: 'it no longer freezes functional motion (spinners, progress) — only the expensive GPU stuff (blur, shadows, glow) is killed. worker orbs default OFF on Firefox for new users and run at ~30fps under reduce-effects.', page: 'settings' },
{ title: 'More fixes', desc: 'jellyfin scans page the bulk fetch so the no-progress watchdog can\'t false-stall a big library; settings page cleanup (#943, thanks @nick2000713); spotify oauth credential normalization (#942, thanks HellRa1SeR); npm audit security fixes for vite/undici/babel (#944, thanks HellRa1SeR).', page: 'settings' },
{ title: 'Earlier versions', desc: '2.8.0 brought the Unverified-queue cleanup (#934), Preview Clip Cleanup, split-album Completeness (#936), and a dashboard performance + memory pass (#935/#802). 2.7.9 added best-quality downloads + ranked quality profiles, Discover "Based On Your Listening", and the Wing It Pool; 2.7.0 made multi-user real.' },
'2.8.2': [
{ date: 'June 2026 — 2.8.2 release' },
{ title: 'Spotify Docker boot hang fixed (#949)', desc: 'with Spotify as your primary metadata source, an unreachable Spotify API could block the gunicorn worker at startup — the container bound port 8008 but never served the Web UI. provider auth probes are deferred during boot (and capped with a timeout) so startup can\'t hang on a slow Spotify; same guard for Qobuz/Deezer/Tidal. (thanks HellRa1SeR.)', page: 'settings' },
{ title: '"Re-auth didn\'t stick" fixed', desc: 'the OAuth callback wrote your Spotify token to one cache while the app read another, so re-authenticating could silently fail validation. unified on one token store — re-auth takes effect now.', page: 'settings' },
{ title: 'Sync to Spotify works', desc: 'exporting a mirrored playlist to Spotify now asks for playlist-write permission once, on-demand, the first time you use it. your normal Spotify login is untouched, so upgrading never forces a re-auth.', page: 'playlists' },
{ title: 'The "slow after update" fix (#948)', desc: 'the real cause of post-update lag was browser password managers (Bitwarden/1Password/etc.) rebuilding their autofill overlay on every DOM change — and soulsync mutates the DOM constantly. non-credential fields are now marked so managers skip them (login fields left alone). ~110x less main-thread blocking in the reporter\'s benchmark. (thanks @nick2000713.)', page: 'dashboard' },
{ title: 'Max Performance mode', desc: 'Settings → Appearance. one switch kills the worker orbs, particles, all blur/shadows and every animation, and greys out the individual effect toggles. for software-rendered / no-GPU setups (Docker, remote desktop) where even simple animations cost real CPU.', page: 'settings' },
{ title: 'Large-library imports no longer time out (#947)', desc: 'dropping a whole library into staging used to make the import page scan every file synchronously and hit the request timeout — so it never loaded. the scan now runs in the background with a live "Scanning N of M…" progress, and the page fills in automatically when done. (thanks @ramonskie.)', page: 'downloads' },
{ title: 'Earlier versions', desc: '2.8.1 added playlist export to Spotify & Deezer (#945), a Rename-only Library Reorganize (#875), broader lossless + DSD handling, and a refined reduce-visual-effects pass. 2.8.0 brought the Unverified-queue cleanup (#934) + dashboard performance work; 2.7.9 added best-quality downloads + the Wing It Pool; 2.7.0 made multi-user real.' },
],
};
@ -3443,40 +3443,36 @@ const WHATS_NEW = {
// usage_note?: 'optional hint shown at the bottom' }
const VERSION_MODAL_SECTIONS = [
{
title: "Export playlists to Spotify & Deezer",
description: "send a mirrored playlist back to your streaming account — the same one-click export, now pointed at Spotify and Deezer.",
title: "Spotify, reliably",
description: "the Docker boot hang and the \"logged out / re-auth won't stick\" issues are fixed.",
features: [
"#945 — Sync to Spotify / Sync to Deezer sit next to the ListenBrainz/JSPF options in the export modal; each builds a playlist in your account",
"resolves IDs from what's already on hand first — the discovery cache, then your library — so an already-discovered playlist exports instantly with zero API calls",
"re-exporting updates the same playlist in place instead of spawning duplicates",
"an optional \"match missing tracks\" toggle confidently searches for the stragglers — a wrong-artist or karaoke version is left out, never guessed in",
"service buttons grey out + point to Settings when disconnected; Spotify needs a one-time reconnect for write access",
"#949 — with Spotify as your primary source, an unreachable Spotify API could block the gunicorn worker at startup (the container bound :8008 but never served the UI). auth probes are deferred during boot + capped with a timeout so startup can't hang; same guard for Qobuz/Deezer/Tidal. thanks HellRa1SeR",
"\"re-auth didn't stick\" — the OAuth callback wrote your token to one cache while the app read another; unified on a single token store, so re-authenticating actually takes effect",
"Sync to Spotify works — exporting a mirrored playlist to Spotify asks for write permission once, on-demand, the first time you use it; your normal login is untouched, so upgrading never forces a re-auth",
],
},
{
title: "Library Reorganize — Rename only",
description: "a lighter reorganize that just renames, no reprocessing.",
title: "The \"slow after update\" fix",
description: "the post-update lag wasn't us — it was your password manager.",
features: [
"#875 — renames your files to your current naming scheme with no re-tag, no quality/AcoustID re-check, no copy-to-staging — much faster on a NAS and won't fail on post-processing reasons",
"only touches files whose path actually changes, which also fixes the \"2 of 14 previewed but everything got modified\" album-splitting; pick it from the new Action dropdown. thanks @tsoulard / @Tacobell444",
"#948 — browser password managers (Bitwarden/1Password/etc.) rebuild their autofill overlay on every DOM change, and soulsync mutates the DOM constantly. non-credential fields are now marked so managers skip them (login fields untouched) — ~110x less main-thread blocking, ~20 → ~96 FPS in the reporter's benchmark. thanks @nick2000713",
"new Max Performance mode (Settings → Appearance) — one switch kills orbs, particles, all blur/shadows and every animation, for software-rendered / no-GPU setups (Docker, remote desktop)",
],
},
{
title: "Recent fixes",
description: "reliability + reported bugs squashed this cycle.",
title: "Large-library imports no longer time out",
description: "migrate a whole library into staging without the page choking.",
features: [
"lossy-copy now covers all lossless formats, not just FLAC (#941); DSD (.dsf/.dff) is recognized as lossless instead of false-flagged \"truncated\" (#939)",
"downloads: 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\" search works again; the Identify search defaults to \"artist - title\"",
"jellyfin scans page the bulk fetch so the no-progress watchdog can't false-stall a big library; settings page cleanup (#943); spotify oauth normalization (#942); npm security fixes (#944)",
"#947 — dropping thousands of files into staging used to scan every file synchronously and hit the request timeout, so the import page never loaded (and every reload re-timed-out)",
"the scan now runs in the background with a live \"Scanning N of M…\" progress, and the page fills in automatically when it's done. auto-import remains the hands-off path for matching. thanks @ramonskie",
],
},
{
title: "Reduce visual effects, refined",
description: "the lag toggle that no longer breaks the UI.",
title: "Earlier in 2.8.1",
description: "2.8.1 was a features + reliability release.",
features: [
"it no longer freezes functional motion (spinners, progress indicators) — only the expensive GPU properties (blur, shadows, glow) are killed",
"worker orbs default OFF on Firefox for first-time users, and run at ~30fps when reduce-effects is on",
"playlist export to Spotify & Deezer (#945) — send a mirrored playlist back to your streaming account, resolving IDs from the discovery cache + your library",
"Rename-only Library Reorganize (#875), broader lossless + DSD handling (#941/#939), a pile of download/search fixes, and a refined reduce-visual-effects pass",
],
},
{

View file

@ -179,6 +179,14 @@ function initAccentColorListeners() {
applyReduceEffects(reduceEffectsCheckbox.checked);
});
}
// Max Performance toggle — apply immediately on change
const maxPerfCheckbox = document.getElementById('max-performance-enabled');
if (maxPerfCheckbox) {
maxPerfCheckbox.addEventListener('change', () => {
applyMaxPerformance(maxPerfCheckbox.checked);
});
}
}
function applyReduceEffects(enabled) {
@ -211,6 +219,67 @@ function applyReduceEffects(enabled) {
}
}
// Max Performance overrides Worker Orbs / Particles / Reduce Effects, so while it's
// on we lock those checkboxes (greyed + visually off) and restore them when it's
// off. We never fire their change handlers, so the user's real saved prefs
// (window._workerOrbsEnabled / _particlesEnabled / the reduce-effects localStorage)
// stay intact — saving reads those, not these forced-off boxes.
function _syncMaxPerfDependentToggles(maxPerfOn) {
const ids = ['worker-orbs-enabled', 'particles-enabled', 'reduce-effects-enabled'];
ids.forEach(id => {
const cb = document.getElementById(id);
if (!cb) return;
const group = cb.closest('.form-group');
if (maxPerfOn) {
cb.disabled = true;
cb.checked = false;
if (group) group.classList.add('setting-overridden');
} else {
cb.disabled = false;
if (group) group.classList.remove('setting-overridden');
// Restore each box to the user's real per-device preference.
if (id === 'worker-orbs-enabled') cb.checked = window._workerOrbsEnabled !== false;
else if (id === 'particles-enabled') cb.checked = window._particlesEnabled === true;
else if (id === 'reduce-effects-enabled') cb.checked = localStorage.getItem('soulsync-reduce-effects') === '1';
}
});
}
// Max Performance — the nuclear low-power switch for software-rendered / no-GPU
// setups (e.g. Docker). Superset of Reduce Visual Effects: body.max-performance CSS
// kills the expensive GPU properties AND all animation/transitions, while here we
// halt every JS canvas loop (particles + worker orbs; cursor-glow + API sparks gate
// on window._maxPerfActive themselves).
function applyMaxPerformance(enabled) {
if (enabled) {
document.body.classList.add('max-performance');
} else {
document.body.classList.remove('max-performance');
}
window._maxPerfActive = enabled;
localStorage.setItem('soulsync-max-performance', enabled ? '1' : '0');
const pcanvas = document.getElementById('page-particles-canvas');
if (enabled) {
if (window.pageParticles) window.pageParticles.stop();
if (pcanvas) pcanvas.style.display = 'none';
if (window.workerOrbs) window.workerOrbs.setPage('_disabled');
} else {
// Restore whatever the user's own toggles (and reduce-effects) still allow.
const reduce = window._reduceEffectsActive === true;
const activePage = document.querySelector('.page.active');
const activeId = activePage ? activePage.id.replace('-page', '') : null;
if (!reduce && window._particlesEnabled !== false) {
if (pcanvas) pcanvas.style.display = '';
if (window.pageParticles && activeId) window.pageParticles.setPage(activeId);
}
if (window._workerOrbsEnabled !== false && window.workerOrbs && activeId) {
window.workerOrbs.setPage(activeId);
}
}
_syncMaxPerfDependentToggles(enabled);
}
// Bootstrap accent and reduce-effects from localStorage instantly (prevents flash)
(function () {
// Auto performance mode on likely-weak hardware. Only acts when this device has
@ -264,6 +333,19 @@ function applyReduceEffects(enabled) {
} else if (window._reduceEffectsActive) {
document.body.classList.add('reduce-effects');
}
// Max Performance — device-scoped (localStorage wins over the server default,
// same as reduce-effects). The window flag is seeded server-side in index.html
// for a flash-free first paint; localStorage reconciles it here.
const maxPerfSaved = localStorage.getItem('soulsync-max-performance');
if (maxPerfSaved === '1') {
document.body.classList.add('max-performance');
window._maxPerfActive = true;
} else if (maxPerfSaved === '0') {
document.body.classList.remove('max-performance');
window._maxPerfActive = false;
} else if (window._maxPerfActive) {
document.body.classList.add('max-performance');
}
const saved = localStorage.getItem('soulsync-accent');
if (saved) applyAccentColor(saved);
// Bootstrap particles setting from localStorage — OFF by default (continuous
@ -319,6 +401,90 @@ async function bootstrapServerAppearanceSettings() {
bootstrapServerAppearanceSettings();
// ── Password-manager autofill suppression ──────────────────────────────
// Bitwarden / 1Password / LastPass etc. attach an inline autofill overlay to
// every <input>/<select>/<textarea> and REBUILD it on every DOM mutation. This
// app mutates the DOM continuously (live service status, download/automation
// progress bars, the per-second "next run" countdown, innerHTML hub rebuilds),
// so the managers' whole-document MutationObserver storms the main thread. A
// captured DevTools trace (2026-06-29) showed Bitwarden's
// bootstrap-autofill-overlay.js (setupOverlayOnField / setupOverlayListeners)
// using ~6× the CPU of the entire SoulSync app — almost the whole freeze.
//
// None of these fields are credentials (they're search boxes, filters, config),
// so we mark them ignored and the managers skip them: once a field carries the
// ignore hint, the overlay is never (re)attached, so the mutation→re-setup storm
// stops. Real sign-in fields (password type + the auth overlays) are left alone
// so the user can still autofill the login / PIN screen. Purely additive data-*
// attributes — no functional effect on the app, and a no-op for any manager that
// doesn't honour them.
(function suppressPasswordManagerAutofill() {
const SKIP_CONTAINERS = ['#login-overlay', '#launch-pin-overlay', '#profile-pin-dialog'];
const isCredentialField = (el) => {
if (el.type === 'password') return true;
return SKIP_CONTAINERS.some(sel => typeof el.closest === 'function' && el.closest(sel));
};
const IGNORE_ATTRS = ['data-bwignore', 'data-1p-ignore', 'data-lpignore', 'data-form-type'];
const tag = (el) => {
if (el.dataset.pmTagged) return; // tagged once — never touch again
if (isCredentialField(el)) return; // leave real login fields for the manager
el.dataset.pmTagged = '1';
el.setAttribute('data-bwignore', 'true'); // Bitwarden
el.setAttribute('data-1p-ignore', ''); // 1Password
el.setAttribute('data-lpignore', 'true'); // LastPass
el.setAttribute('data-form-type', 'other'); // Dashlane
if (!el.hasAttribute('autocomplete')) el.setAttribute('autocomplete', 'off');
};
const sweep = () => {
document.querySelectorAll(
'input:not([data-pm-tagged]),textarea:not([data-pm-tagged]),select:not([data-pm-tagged])'
).forEach(tag);
};
// Debounce: a burst of DOM mutations triggers at most one sweep per idle slot.
// The `:not([data-pm-tagged])` selector makes the steady-state sweep a no-op
// (it only ever processes freshly-added inputs), and our own attribute writes
// don't re-arm the observer (it watches childList, not attributes).
let pending = false, observer = null, disabled = false;
const scheduleSweep = () => {
if (disabled || pending) return;
pending = true;
const run = () => { pending = false; if (!disabled) sweep(); };
if (typeof requestIdleCallback === 'function') requestIdleCallback(run, { timeout: 400 });
else setTimeout(run, 300);
};
const startObserving = () => {
if (observer) return;
observer = new MutationObserver(scheduleSweep);
observer.observe(document.body, { childList: true, subtree: true });
};
const start = () => { sweep(); startObserving(); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
else start();
// Benchmark hook (not used by the app): toggle the suppression at runtime so a
// before/after can be measured without rebuilding. disable() strips the ignore
// hints + stops the observer, so password managers re-attach their autofill
// overlay — i.e. the pre-fix "before" behaviour. enable() re-tags + resumes.
window.__pmSuppress = {
disable() {
disabled = true;
if (observer) { observer.disconnect(); observer = null; }
document.querySelectorAll('[data-pm-tagged]').forEach((el) => {
IGNORE_ATTRS.forEach((a) => el.removeAttribute(a));
delete el.dataset.pmTagged;
});
},
enable() {
disabled = false;
sweep();
startObserving();
},
get isActive() { return !disabled; },
};
})();
// ── Profile System ─────────────────────────────────────────────
let currentProfile = null;
const PROFILE_CONTEXT_CHANGED_EVENT = 'ss:webui-profile-context-changed';
@ -2995,7 +3161,8 @@ async function loadPageData(pageId) {
const RECENTER_DELAY_MS = 1500;
let recenterTimer = 0;
const isReduced = () => document.body.classList.contains('reduce-effects');
const isReduced = () => document.body.classList.contains('reduce-effects')
|| document.body.classList.contains('max-performance');
const gridCenter = () => {
const r = grid.getBoundingClientRect();

View file

@ -2352,8 +2352,8 @@
// Listen for page changes from script.js
window.pageParticles = {
setPage(pageId) {
// Reduce Visual Effects performance mode halts the loop entirely.
if (window._reduceEffectsActive) { stop(); return; }
// Reduce Visual Effects / Max Performance modes halt the loop entirely.
if (window._reduceEffectsActive || window._maxPerfActive) { stop(); return; }
const presetName = PAGE_PRESETS[pageId] || 'none';
setPreset(presetName);
},
@ -2362,7 +2362,7 @@
// Auto-start for initial page (respect particles toggle)
requestAnimationFrame(() => {
if (window._particlesEnabled === false || window._reduceEffectsActive) return;
if (window._particlesEnabled === false || window._reduceEffectsActive || window._maxPerfActive) return;
const activePage = document.querySelector('.page.active');
if (activePage) {
const pageId = activePage.id.replace('-page', '');

View file

@ -1572,6 +1572,16 @@ async function loadSettingsData() {
if (reduceCheckbox) reduceCheckbox.checked = reduceEffects;
applyReduceEffects(reduceEffects);
// Max Performance — same device-scoped resolution as reduce-effects:
// localStorage is the per-device truth, server value the cross-device default.
// Applied last so it can lock/override the dependent toggles above when on.
const serverMaxPerf = settings.ui_appearance?.max_performance === true; // default false
const localMaxPerf = localStorage.getItem('soulsync-max-performance'); // '1' | '0' | null
const maxPerf = localMaxPerf !== null ? (localMaxPerf === '1') : serverMaxPerf;
const maxPerfCheckbox = document.getElementById('max-performance-enabled');
if (maxPerfCheckbox) maxPerfCheckbox.checked = maxPerf;
applyMaxPerformance(maxPerf);
// Populate Logging information
const logLevelSelect = document.getElementById('log-level-select');
if (logLevelSelect) logLevelSelect.value = settings.logging?.level || 'INFO';
@ -3493,9 +3503,13 @@ async function saveSettings(quiet = false) {
accent_preset: document.getElementById('accent-preset')?.value || '#1db954',
accent_color: document.getElementById('accent-custom-color')?.value || '#1db954',
sidebar_visualizer: document.getElementById('sidebar-visualizer-type')?.value || 'bars',
particles_enabled: document.getElementById('particles-enabled')?.checked !== false,
worker_orbs_enabled: document.getElementById('worker-orbs-enabled')?.checked !== false,
reduce_effects: document.getElementById('reduce-effects-enabled')?.checked === true
// Read the runtime flags / localStorage, not the checkboxes: while Max
// Performance is on it locks those boxes visually-off, but the user's real
// saved prefs live in the flags — so saving must not clobber them.
particles_enabled: window._particlesEnabled !== false,
worker_orbs_enabled: window._workerOrbsEnabled !== false,
reduce_effects: window._reduceEffectsActive === true,
max_performance: window._maxPerfActive === true
},
youtube: {
cookies_browser: document.getElementById('youtube-cookies-browser').value,

View file

@ -669,7 +669,7 @@ function exportMirroredPlaylist(playlistId, name) {
</button>
<button class="pl-export-choice" data-mode="spotify" style="width:100%;text-align:left;margin-bottom:10px;padding:13px 15px;border-radius:12px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.04);color:#fff;cursor:pointer;">
<div style="font-weight:600;">Sync to Spotify</div>
<div style="font-size:12px;color:rgba(255,255,255,0.55);">Create a Spotify playlist in your account from this list (reconnect Spotify if it asks for write access).</div>
<div style="font-size:12px;color:rgba(255,255,255,0.55);">Create a Spotify playlist in your account (the first time, you'll grant permission to create playlists).</div>
</button>
<button class="pl-export-choice" data-mode="deezer" style="width:100%;text-align:left;margin-bottom:16px;padding:13px 15px;border-radius:12px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.04);color:#fff;cursor:pointer;">
<div style="font-weight:600;">Sync to Deezer</div>
@ -678,7 +678,7 @@ function exportMirroredPlaylist(playlistId, name) {
<div style="font-size:11.5px;color:rgba(255,255,255,0.4);line-height:1.5;">Tracks are matched by ID (MusicBrainz for ListenBrainz/JSPF; the stored Spotify/Deezer ID for those). Tracks without a match can't be included — you'll see how many made it. Renaming/re-syncing can reset play counts on the destination.</div>
<label style="display:flex;align-items:flex-start;gap:8px;margin-top:12px;font-size:12px;color:rgba(255,255,255,0.6);cursor:pointer;">
<input type="checkbox" id="pl-export-backfill" style="margin-top:2px;flex-shrink:0;accent-color:rgb(var(--accent-rgb));">
<span><b style="color:rgba(255,255,255,0.75);">Match missing tracks</b> (Spotify/Deezer only) search the service for tracks with no known ID. Slower, and only confident matches are added.</span>
<span><b style="color:rgba(255,255,255,0.75);">Match missing tracks</b> (Spotify/Deezer) search the service for tracks with no known ID. Slower, and only confident matches are added.</span>
</label>
<div style="text-align:right;margin-top:14px;"><button onclick="document.getElementById('pl-export-modal').remove()" style="background:none;border:none;color:rgba(255,255,255,0.5);cursor:pointer;font-size:13px;">Cancel</button></div>
</div>`;
@ -730,8 +730,15 @@ async function _startPlaylistExport(playlistId, mode, name, backfill) {
body: isService ? JSON.stringify({ backfill: !!backfill }) : JSON.stringify({ mode }),
});
const data = await resp.json();
// Spotify export needs a one-time write-permission grant. Surface a clickable link (a
// direct user click avoids popup-blocking; window.open after this await would be blocked)
// and tell the user to retry once they've authorized.
if (data.needs_auth && data.auth_url) {
_setExportStatus(playlistId, `<span style="color:#f59e0b;">Spotify needs permission to create playlists — <a href="${data.auth_url}" target="_blank" rel="noopener" style="color:#38bdf8;text-decoration:underline;">authorize</a>, then click Export again.</span>`, 20000);
return;
}
if (!data.success || !data.job_id) {
_setExportStatus(playlistId, `<span style="color:#ef4444;">Export failed to start</span>`);
_setExportStatus(playlistId, `<span style="color:#ef4444;">${_esc(data.error || 'Export failed to start')}</span>`);
return;
}
_pollPlaylistExport(data.job_id, playlistId, mode, name);

View file

@ -138,7 +138,9 @@ body {
/* In performance mode the aura loses its blur + animation, leaving two hard
static circles hide them entirely instead. */
body.reduce-effects .sidebar::before,
body.reduce-effects .sidebar::after {
body.reduce-effects .sidebar::after,
body.max-performance .sidebar::before,
body.max-performance .sidebar::after {
display: none !important;
}
@ -500,12 +502,14 @@ body:not(.reduce-effects) .nav-button.active:hover {
transparent border, so no layout shift). The expensive full-effects
bits (gradient, transform/translateX, multi-layer box-shadow) stay
off so hovering doesn't trigger compositing/repaint churn. */
body.reduce-effects .nav-button:hover {
body.reduce-effects .nav-button:hover,
body.max-performance .nav-button:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.08);
}
body.reduce-effects .nav-button.active:hover {
body.reduce-effects .nav-button.active:hover,
body.max-performance .nav-button.active:hover {
background: rgba(var(--accent-rgb), 0.18);
border-color: rgba(var(--accent-rgb), 0.3);
}
@ -56088,6 +56092,13 @@ tr.tag-diff-same {
#settings-page .info-icon {
/* Single clean circle with a centred "i" (was the glyph, which carried
its own inner circle a double-circle that read as off-centre). */
/* It's a role="button" with a text "i" inside without these, hovering the
glyph gives the I-beam text caret on Windows (Linux happened to resolve a
pointer). Force the button hand + make the glyph non-selectable so the
cursor is the same on every platform. */
cursor: pointer;
-webkit-user-select: none;
user-select: none;
display: inline-flex;
align-items: center;
justify-content: center;
@ -60406,6 +60417,38 @@ body.reduce-effects #page-particles-canvas {
display: none !important;
}
/* Max Performance Nuclear low-power mode for software-rendered / no-GPU
setups (e.g. Docker, remote desktop), where there is no compositor and even
transform/opacity motion repaints on the CPU. Superset of Reduce Visual Effects:
kills the expensive GPU properties AND halts ALL animation + transitions so
functional loading spinners go static. That's the deliberate trade-off for
minimum CPU; the user opts in explicitly. Worker orbs, particles, the cursor
glow and the API-monitor sparks are halted in JS (gated on window._maxPerfActive). */
body.max-performance *,
body.max-performance *::before,
body.max-performance *::after {
animation: none !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
box-shadow: none !important;
filter: none !important;
}
/* Drop the always-on page-particles canvas from the compositor entirely. */
body.max-performance #page-particles-canvas {
display: none !important;
}
/* Appearance toggles that Max Performance overrides: greyed + locked while it's on. */
.form-group.setting-overridden {
opacity: 0.45;
}
.form-group.setting-overridden .checkbox-label {
cursor: not-allowed;
}
/* ============================================
ACTIVE DOWNLOADS PAGE Premium Glassmorphic
============================================ */
@ -63476,7 +63519,9 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt
}
body.reduce-effects .dash-card::before,
body.reduce-effects .dash-card::after {
body.reduce-effects .dash-card::after,
body.max-performance .dash-card::before,
body.max-performance .dash-card::after {
display: none;
}
.dash-card:hover {

View file

@ -1065,12 +1065,13 @@
// ── Page awareness ──
function isEnabled() {
// The worker-orbs toggle controls the orbs on its own — reduce-effects no
// longer force-kills them. The orb glow is canvas radial gradients, NOT a CSS
// blur(28px) (that's the sidebar aura orbs + frosted glass, which reduce-effects
// still kills), so the per-frame cost is moderate, not the blur-rasterize lag.
// If real telemetry says otherwise, revert by re-adding `&& !window._reduceEffectsActive`.
return window._workerOrbsEnabled !== false;
// Reduce-effects does NOT gate the orbs — they have their own toggle, so that
// setting controls them on its own. The orbs ARE killed by Max Performance: in a
// Docker / headless / remote-desktop container there is no GPU, so the per-frame
// radial-gradient canvas fill rasterizes on the CPU and can saturate a core,
// freezing the whole UI. Max Performance is the nuclear low-power switch for
// exactly those software-rendered setups, so the canvas loop must stay off there.
return window._workerOrbsEnabled !== false && !window._maxPerfActive;
}
// ── Real telemetry → pulses ──