diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 34e38beb..a2546ecc 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -9,9 +9,9 @@ on: workflow_dispatch: inputs: version_tag: - description: 'Version tag (e.g. 2.6.9)' + description: 'Version tag (e.g. 2.7.0)' required: true - default: '2.6.9' + default: '2.7.0' jobs: build-and-push: diff --git a/Support/REVERSE-PROXY.md b/Support/REVERSE-PROXY.md new file mode 100644 index 00000000..1e6152ff --- /dev/null +++ b/Support/REVERSE-PROXY.md @@ -0,0 +1,134 @@ +# Running SoulSync behind a reverse proxy (nginx / Caddy / Traefik) + +Putting SoulSync behind a reverse proxy lets you serve it over **HTTPS** and — the +important part — put **authentication** in front of it before exposing it to the +internet. This guide covers the safe setup. + +> **The golden rule:** the safest way to expose *any* self-hosted app publicly is +> to require authentication at the proxy (an auth layer), **not** to rely on the +> app's own protection. SoulSync's launch PIN is a useful fallback, but it is not +> a substitute for a real auth layer on a public instance. + +--- + +## 1. Turn on reverse-proxy mode + +By default SoulSync does **not** trust proxy headers (so a direct client can't spoof +its IP or pretend the connection is HTTPS). If you're behind a proxy that +terminates TLS, turn on **Settings → Security → "Behind a reverse proxy"** and +**restart SoulSync** (this option applies at startup). + +When enabled, SoulSync: +- trusts `X-Forwarded-For/Proto/Host/Port` from **one** proxy hop (correct client + IP, HTTPS detection, redirects), +- marks its session cookie `Secure` (HTTPS-only) + `SameSite=Lax`, and +- sends conservative security headers (`X-Content-Type-Options: nosniff`, + `X-Frame-Options: SAMEORIGIN`, `Strict-Transport-Security`). No CSP is set — tune + one at your proxy if you want it. + +**Leave it off if you access SoulSync directly over http:// on your LAN** — turning +it on would make the session cookie HTTPS-only and break plain-HTTP access. With it +off, none of the above applies and SoulSync behaves exactly as before. + +> The launch PIN is also brute-force limited (10 wrong attempts from an IP → a +> short cooldown), regardless of this setting — a correct PIN is never affected. + +Restart SoulSync after changing it. + +--- + +## 2. nginx + +SoulSync uses WebSockets (Socket.IO), so the `Upgrade`/`Connection` headers are +**required** — without them live updates silently stop working. + +```nginx +server { + listen 443 ssl; + server_name soulsync.example.com; + + ssl_certificate /etc/letsencrypt/live/soulsync.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/soulsync.example.com/privkey.pem; + + # Large library scans / uploads + client_max_body_size 0; + + location / { + proxy_pass http://127.0.0.1:8008; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + + # Required for Socket.IO / live updates + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_read_timeout 3600s; # long-running scans + proxy_send_timeout 3600s; + } +} +``` + +--- + +## 3. Caddy + +Caddy handles TLS automatically and proxies WebSockets out of the box: + +```caddy +soulsync.example.com { + reverse_proxy 127.0.0.1:8008 +} +``` + +Caddy sets `X-Forwarded-*` for you. (Add an auth provider directive if you want +auth at the proxy — see below.) + +--- + +## 4. Traefik + +Traefik proxies WebSockets automatically and forwards the headers. Point a router +at the SoulSync service on port `8008` with your TLS resolver; no extra WebSocket +config is needed. + +--- + +## 5. Add authentication in front (recommended for public instances) + +Pick one: + +- **Auth proxy** — [Authelia](https://www.authelia.com/), + [Authentik](https://goauthentik.io/), or + [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/). These sit in front + of SoulSync and force a login (with 2FA) before any request reaches it. Best + option for internet exposure. + + SoulSync can **trust the proxy's authenticated-user header** so the launch PIN is + skipped once the proxy has logged you in. Set the header name in **Settings → + Security → "Auth proxy user header"** (e.g. `Remote-User`). + + > ⚠️ **Only enable this behind a proxy you control that STRIPS any client-supplied + > copy of that header.** Otherwise a direct visitor could send `Remote-User: admin` + > and walk straight in. It's **off by default** — an unset header name means + > SoulSync ignores the header entirely (a spoofed one does nothing). +- **HTTP Basic Auth** — quick and simple (nginx `auth_basic` / Caddy `basicauth`). + Better than nothing; weaker than an auth proxy. +- **SoulSync launch PIN** — set an admin PIN in Settings. Enforced server-side, so + it can't be bypassed by hitting the API directly — but it's a shared PIN, so + treat it as a fallback, not your only gate. + +--- + +## Troubleshooting + +- **Live updates / progress bars don't move** → the WebSocket `Upgrade`/`Connection` + headers are missing (nginx) or your proxy is buffering. Check section 2. +- **Login won't stick / "session expired"** → you enabled `trust_reverse_proxy` but + are reaching SoulSync over plain `http://`. The session cookie is now HTTPS-only; + use `https://`, or turn the setting off for direct HTTP access. +- **Scans time out** → raise `proxy_read_timeout` / `proxy_send_timeout`. diff --git a/core/automation_engine.py b/core/automation_engine.py index 5264c9c5..1b85e221 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -635,6 +635,11 @@ class AutomationEngine: action_config['_automation_name'] = auto.get('name', '') if profile_id is not None: action_config['_profile_id'] = profile_id + # The profile this run acts AS: an explicit trigger profile, else the + # automation's owner, else admin. System + admin automations are + # profile 1, so this is a no-op for them — only non-admin-owned + # automations gain their correct identity in the background. + _effective_profile_id = profile_id if profile_id is not None else (auto.get('profile_id') or 1) # Action delay (skipped for manual run_now) delay_minutes = action_config.get('delay', 0) @@ -681,9 +686,14 @@ class AutomationEngine: except Exception as e: logger.debug("scheduled progress init: %s", e) - # Execute the action + # Execute the action under the owner's profile so get_current_profile_id() + # (and the per-profile clients it resolves) act as the automation's owner + # in the background, not admin. Reset in finally so a pooled thread can't + # leak the override to the next job. error = None result = {} + from core.profile_context import set_background_profile, reset_background_profile + _bg_token = set_background_profile(_effective_profile_id) try: result = handler_info['handler'](action_config) or {} logger.info(f"Automation '{auto['name']}' (id={automation_id}) executed: {result.get('status', 'ok')}") @@ -702,6 +712,8 @@ class AutomationEngine: error = str(e) result = {'status': 'error', 'error': error} logger.error(f"Automation '{auto['name']}' (id={automation_id}) failed: {e}") + finally: + reset_background_profile(_bg_token) # Finalize progress tracking if self._progress_finish_fn: diff --git a/core/credentials/__init__.py b/core/credentials/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/core/credentials/store.py b/core/credentials/store.py new file mode 100644 index 00000000..8cceb253 --- /dev/null +++ b/core/credentials/store.py @@ -0,0 +1,85 @@ +"""Named, switchable service-credential sets — pure logic (Phase 0 foundation). + +Today every auth service (Spotify, Tidal, Deezer, Qobuz, Plex, Jellyfin, +Navidrome) holds ONE credential set in config, and clients are global singletons +built from that single slot. This module is the groundwork for letting an admin +save MULTIPLE named credential sets per service ("pills") that each profile can +switch between, without anyone but the admin creating them. + +Kept PURE — service registry, payload validation, and active-set selection, +free of DB/Flask so it's unit-testable. Encrypted storage lives in MusicDatabase +(service_credentials / profile_service_credentials tables); runtime client +resolution + UI come in later phases. Nothing here changes existing behaviour; +it's dormant capability until wired. +""" + +from __future__ import annotations + +# Services that support multiple named credential sets, mapped to the payload +# keys that MUST be present for a set to be usable. Extra keys (OAuth tokens, +# redirect URIs, quality prefs) are allowed and preserved — these are only the +# minimum required to validate a set the admin is saving. +SERVICE_CREDENTIAL_SCHEMA = { + 'spotify': ('client_id', 'client_secret'), + 'tidal': ('access_token', 'refresh_token'), + 'deezer': ('arl',), + 'qobuz': ('user_auth_token',), + 'plex': ('base_url', 'token'), + 'jellyfin': ('base_url', 'api_key'), + 'navidrome': ('base_url', 'username', 'password'), +} + +SUPPORTED_SERVICES = frozenset(SERVICE_CREDENTIAL_SCHEMA) + + +def is_supported_service(service: str) -> bool: + """True when the service supports named credential sets.""" + return service in SERVICE_CREDENTIAL_SCHEMA + + +def validate_credential_payload(service: str, payload): + """Return ``(ok, missing_keys)`` for a credential set. + + Valid when every required key for the service is present and truthy. An + unknown service is invalid with no missing list (caller should reject it + as unsupported, not as "incomplete"). + """ + required = SERVICE_CREDENTIAL_SCHEMA.get(service) + if required is None: + return False, [] + if not isinstance(payload, dict): + return False, list(required) + + def _present(v): + # Whitespace-only strings count as missing — they'd otherwise save a + # blank secret that fails confusingly at the real service later. + return bool(v.strip()) if isinstance(v, str) else bool(v) + + missing = [k for k in required if not _present(payload.get(k))] + return (not missing), missing + + +def pick_active_credential(credentials, selected_id): + """From ``credentials`` (a list of dicts each carrying ``id``), return the + one whose id == ``selected_id``. + + Returns None when there's no selection OR the selected id isn't present — + i.e. a stale pointer whose credential set was deleted. The caller then + falls back to the global/admin default, so a deleted set never breaks a + profile. Pure + stale-safe. + """ + if not selected_id: + return None + for cred in credentials or []: + if cred.get('id') == selected_id: + return cred + return None + + +__all__ = [ + 'SERVICE_CREDENTIAL_SCHEMA', + 'SUPPORTED_SERVICES', + 'is_supported_service', + 'validate_credential_payload', + 'pick_active_credential', +] diff --git a/core/discovery/endpoints.py b/core/discovery/endpoints.py index 69dbb0d8..2d0d2042 100644 --- a/core/discovery/endpoints.py +++ b/core/discovery/endpoints.py @@ -579,45 +579,68 @@ def update_discovery_match( return {'error': 'Missing required fields'}, 400 state = states.get(identifier) - if not state: - return {'error': 'Discovery state not found'}, 404 + result = None + if state: + if track_index >= len(state['discovery_results']): + return {'error': 'Invalid track index'}, 400 - if track_index >= len(state['discovery_results']): - return {'error': 'Invalid track index'}, 400 + result = state['discovery_results'][track_index] + old_status = result.get('status') - result = state['discovery_results'][track_index] - old_status = result.get('status') + result['status'] = 'Found' + result['status_class'] = 'found' + result['spotify_track'] = spotify_track['name'] + result['spotify_artist'] = join_artist_names(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else extract_artist_name(spotify_track['artists']) + result['spotify_album'] = spotify_track['album'] + result['spotify_id'] = spotify_track['id'] - result['status'] = 'Found' - result['status_class'] = 'found' - result['spotify_track'] = spotify_track['name'] - result['spotify_artist'] = join_artist_names(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else extract_artist_name(spotify_track['artists']) - result['spotify_album'] = spotify_track['album'] - result['spotify_id'] = spotify_track['id'] + duration_ms = spotify_track.get('duration_ms', 0) + if duration_ms: + minutes = duration_ms // 60000 + seconds = (duration_ms % 60000) // 1000 + result['duration'] = f"{minutes}:{seconds:02d}" + else: + result['duration'] = '0:00' - duration_ms = spotify_track.get('duration_ms', 0) - if duration_ms: - minutes = duration_ms // 60000 - seconds = (duration_ms % 60000) // 1000 - result['duration'] = f"{minutes}:{seconds:02d}" - else: - result['duration'] = '0:00' + result['spotify_data'] = build_fix_modal_spotify_data(spotify_track) + result['wing_it_fallback'] = False + result['manual_match'] = True - result['spotify_data'] = build_fix_modal_spotify_data(spotify_track) - result['wing_it_fallback'] = False - result['manual_match'] = True + if old_status != 'found' and old_status != 'Found': + state['spotify_matches'] = state.get('spotify_matches', 0) + 1 - if old_status != 'found' and old_status != 'Found': - state['spotify_matches'] = state.get('spotify_matches', 0) + 1 + logger.info(f"Manual match updated: {source_log_label} - {identifier} - track {track_index}") + logger.info(f" → {result['spotify_artist']} - {result['spotify_track']}") - logger.info(f"Manual match updated: {source_log_label} - {identifier} - track {track_index}") - logger.info(f" → {result['spotify_artist']} - {result['spotify_track']}") - - try: original_track = result.get(original_track_key, {}) original_name = original_track.get('name', spotify_track['name']) original_artist = original_artist_getter(original_track) + else: + # #843: the in-memory discovery state can be gone — a server restart, + # or an imported playlist that wasn't discovered in THIS process — + # while the card is still shown from persisted data. The DURABLE part + # of a manual fix (writing the match to the discovery cache so future + # syncs resolve it) doesn't need the in-memory state, only the original + # track's name + artist, which the client now sends. Fall back to those + # instead of 404ing the fix into uselessness. + original_name = (data.get('original_name') or '').strip() + original_artist = (data.get('original_artist') or '').strip() + if not original_name and not original_artist: + return {'error': 'Discovery state not found'}, 404 + if not original_name: + original_name = spotify_track['name'] + # Key the cache by the FIRST artist — every in-memory + sync path uses + # artists[0], but the client may send a joined "A, B, C" string. Without + # this, a multi-artist track would save under a key the sync never looks + # up (full string ≠ first artist), so the fix would silently never apply. + if original_artist: + original_artist = original_artist.split(',')[0].strip() + logger.info( + f"Manual match (no in-memory state) → discovery cache: " + f"{source_log_label} - {identifier} - '{original_name}' by '{original_artist}'" + ) + try: cache_key = get_discovery_cache_key(original_name, original_artist) artists_list = spotify_track['artists'] if isinstance(artists_list, list): diff --git a/core/downloads/file_finder.py b/core/downloads/file_finder.py index 98aa4a66..0e6e03a7 100644 --- a/core/downloads/file_finder.py +++ b/core/downloads/file_finder.py @@ -83,15 +83,17 @@ def _normalize_for_finding(text: str) -> str: def _extract_basename(api_filename: str) -> str: - """Cross-platform rightmost-separator split, with YouTube / - Tidal ``id||title`` encoded filenames pre-normalised — the id - half is stripped so the title becomes the basename. Mirrors - the strip-then-split order ``web_server`` used.""" + """Cross-platform rightmost-separator split for a real remote PATH. + + A YouTube/Tidal/Qobuz ``id||title`` encoded filename is handled by + returning the title VERBATIM: the title is not a filesystem path, so a '/' + in it (e.g. the Sawano track ``YouSeeBIGGIRL/T:T``) is part of the name and + must NOT be split on (issue #835).""" if not api_filename: return "" if '||' in api_filename: _id, title = api_filename.split('||', 1) - api_filename = title + return title last_slash = max(api_filename.rfind('/'), api_filename.rfind('\\')) return api_filename[last_slash + 1:] if last_slash != -1 else api_filename @@ -236,14 +238,23 @@ def find_completed_audio_file( ``None`` when the file isn't found anywhere — callers should treat that as "not yet" (still mid-write) or "lost". """ - # YouTube / Tidal encoded filenames carry the id ahead of ``||``. - # Strip it up front so basename + dir-component extraction both - # operate on the title half. + # YouTube / Tidal / Qobuz encoded filenames carry the id ahead of ``||``. + # The title half is NOT a filesystem path: a '/' in it (e.g. the Sawano + # track ``YouSeeBIGGIRL/T:T``) is part of the title, so it must NOT be + # basename-split or read as a remote directory component — doing so + # truncated the search target to ``T:T`` and the real file was never found, + # quarantining valid downloads (issue #835). Real remote paths (Soulseek) + # still get basename + dir-component extraction. + encoded_title = None if api_filename and '||' in api_filename: - _id, api_filename = api_filename.split('||', 1) - target_basename = _extract_basename(api_filename) + _id, encoded_title = api_filename.split('||', 1) + if encoded_title is not None: + target_basename = encoded_title + api_dirs = [] + else: + target_basename = _extract_basename(api_filename) + api_dirs = _api_dir_parts(api_filename) normalized_target = _normalize_for_finding(target_basename) - api_dirs = _api_dir_parts(api_filename) best_dl_path, dl_sim = _search_in_directory( download_dir, 'downloads', target_basename, normalized_target, api_dirs, diff --git a/core/downloads/status.py b/core/downloads/status.py index 010581b9..f49a202b 100644 --- a/core/downloads/status.py +++ b/core/downloads/status.py @@ -36,6 +36,15 @@ from utils.logging_config import get_logger # Project logger factory so these lines reach app.log (soulsync.* namespace). logger = get_logger("downloads.status") +# #836 backstop: how long an slskd error state (Rejected/Failed/Errored/TimedOut) +# may persist on a non-manual task before the status formatter gives up on the +# retry monitor and marks it failed. The monitor's own retry window is ~15s +# (3 × 5s); this is well beyond it so a healthy retry always wins, and it only +# fires when the monitor genuinely can't make progress (e.g. a rejected transfer +# with no other source) — which otherwise hangs the task at 'downloading 0%' +# forever and blocks the whole batch from completing. +ERROR_STATE_TERMINAL_GRACE_SECONDS = 60 + def _schedule_completion_callback(deps, batch_id: str, task_id: str, success: bool) -> None: """Fire ``deps.on_download_completed`` on a one-shot daemon thread so @@ -394,17 +403,59 @@ def build_batch_status_data(batch_id: str, batch: dict, live_transfers_lookup: d # release the lock. _schedule_completion_callback(deps, batch_id, task_id, False) else: - # UNIFIED ERROR HANDLING: Let monitor handle errors for consistency - # Monitor will detect errored state and trigger retry within 5 seconds - logger.error(f"Task {task_id} API shows error state: {state_str} - letting monitor handle retry") + # Normally the retry monitor picks up an errored state and + # retries within ~15s. But if it can't make progress — e.g. an + # slskd transfer rejected with no other source — the task would + # otherwise sit at 'downloading 0%' forever, spam an ERROR every + # poll, AND block its batch from ever completing (#836: a rejected + # wishlist track, or rejected tracks in an album download). + # + # Backstop: measure how long the ERROR state has persisted (not + # how long the task has downloaded, so a slow-but-healthy transfer + # isn't failed). Once it exceeds the monitor's retry window with no + # resolution, mark the task failed so the worker frees and the + # batch can finish. A working retry transitions the task out of the + # error state first, clearing the timer below — so the healthy path + # never hits this. + # A monitor retry transitions the task (newer + # status_change_time), which restarts the window so each + # error EPISODE gets a fresh grace. If the monitor never + # transitions it (the stuck case), the window keeps growing. + err_since = task.get('_error_state_since') + if err_since is None or task.get('status_change_time', 0) > err_since: + task['_error_state_since'] = err_since = current_time + task.pop('_error_state_logged', None) + error_age = current_time - err_since - # Keep task in current status (downloading/queued) so monitor can detect error - # Don't mark as failed here - let the unified retry system handle it - if task['status'] in ['searching', 'downloading', 'queued']: - task_status['status'] = task['status'] # Keep current status for monitor + if error_age > ERROR_STATE_TERMINAL_GRACE_SECONDS: + err_msg = live_info.get('errorMessage') or live_info.get('error') or '' + task['status'] = 'failed' + task['error_message'] = ( + str(err_msg) if err_msg + else f'Download failed (state: {state_str})' + ) + task_status['status'] = 'failed' + task_status['error_message'] = task['error_message'] + logger.warning( + f"Task {task_id} stuck in error state '{state_str}' for " + f"{error_age:.0f}s with no retry progress — marking failed (#836)" + ) + _schedule_completion_callback(deps, batch_id, task_id, False) else: - task_status['status'] = 'downloading' # Default to downloading for error detection - task['status'] = 'downloading' + # Within the retry window — keep current status so the monitor + # can act. Log once per episode, not every poll, to stop the + # 2-second ERROR spam the reporter saw. + if not task.get('_error_state_logged'): + logger.warning( + f"Task {task_id} API shows error state: {state_str} " + f"- letting monitor handle retry" + ) + task['_error_state_logged'] = True + if task['status'] in ['searching', 'downloading', 'queued']: + task_status['status'] = task['status'] # Keep current status for monitor + else: + task_status['status'] = 'downloading' # Default to downloading for error detection + task['status'] = 'downloading' elif 'Completed' in state_str or 'Succeeded' in state_str: # Verify bytes actually transferred before trusting state string expected_size = live_info.get('size', 0) diff --git a/core/library/stale_guard.py b/core/library/stale_guard.py new file mode 100644 index 00000000..c614934c --- /dev/null +++ b/core/library/stale_guard.py @@ -0,0 +1,45 @@ +"""Guard against mass-deleting library rows when storage is unreachable. + +The library "sync" / cleanup paths mark a track stale when its file isn't on +disk and then delete the row. But ``os.path.exists`` returns False for EVERY +file when the music storage is momentarily unavailable — a sleeping NAS, a +dropped network mount, an unmounted Docker volume, a WSL mount hiccup. Without a +guard, one click then wipes the whole artist/library from the DB even though the +files are fine. + +This mirrors the safety the deep-scan path already had (``database_update_worker`` +skips removal when stale > 50% of a >100-track library — issue #828). Centralised +here so every stale-removal site can share one tested rule. +""" + +from __future__ import annotations + +# Don't second-guess tiny sets — a 2-track artist legitimately losing both files +# shouldn't be blocked. Above this, an implausibly large missing fraction almost +# always means "storage down", not "files actually deleted". +DEFAULT_MIN_TOTAL = 5 +DEFAULT_MAX_MISSING_FRACTION = 0.5 + + +def is_implausible_stale_removal( + missing_count: int, + total_count: int, + *, + min_total: int = DEFAULT_MIN_TOTAL, + max_fraction: float = DEFAULT_MAX_MISSING_FRACTION, +) -> bool: + """True when ``missing_count`` is too large a share of ``total_count`` to be a + real deletion — i.e. the storage is probably unreachable and the caller should + SKIP removal (and warn) rather than delete. + + Returns False for small sets (< ``min_total``) so normal cleanup of a few + genuinely-gone files still works. + """ + if total_count <= 0 or missing_count <= 0: + return False + if total_count < min_total: + return False + return missing_count > total_count * max_fraction + + +__all__ = ["is_implausible_stale_removal", "DEFAULT_MIN_TOTAL", "DEFAULT_MAX_MISSING_FRACTION"] diff --git a/core/metadata/registry.py b/core/metadata/registry.py index 3299d55a..4e6f343d 100644 --- a/core/metadata/registry.py +++ b/core/metadata/registry.py @@ -187,18 +187,43 @@ def _build_profile_spotify_cache_key(profile_id: int, creds: Dict[str, Any]) -> def get_spotify_client_for_profile(profile_id: Optional[int] = None): - """Get a profile-specific Spotify client or fall back to the global one.""" + """Get a profile-specific Spotify client or fall back to the global one. + + Shared-app model: a profile authenticates its OWN Spotify account through + the GLOBAL app credentials (client_id/secret) and gets its own token cache + (``.spotify_cache_profile_``). A profile that set its own app creds + (legacy) still works. A profile that hasn't connected — no token cache — + falls back to the global/admin client, so nothing changes for them or for + background workers (which run as profile 1).""" if profile_id is None or profile_id == 1: return get_spotify_client() try: - creds = _profile_spotify_credentials_provider(profile_id) - if not creds or not creds.get("client_id"): - return get_spotify_client() + creds = _profile_spotify_credentials_provider(profile_id) or {} except Exception: return get_spotify_client() - cache_key = _build_profile_spotify_cache_key(profile_id, creds) + import os + cache_path = f"config/.spotify_cache_profile_{profile_id}" + own_app = bool(creds.get("client_id") and creds.get("client_secret")) + connected = os.path.exists(cache_path) + # Build a per-profile client when the profile has its OWN app creds (legacy) + # OR has connected via the shared app (its own token cache exists). A profile + # with neither uses the global/admin client — so background workers and + # unconnected users are unaffected. + if not own_app and not connected: + return get_spotify_client() + + # Effective OAuth app creds: the profile's own (legacy) else the global app. + client_id = creds.get("client_id") or _get_config_value("spotify.client_id", "") + client_secret = creds.get("client_secret") or _get_config_value("spotify.client_secret", "") + redirect_uri = (creds.get("redirect_uri") + or _get_config_value("spotify.redirect_uri", "http://127.0.0.1:8888/callback")) + if not client_id: + return get_spotify_client() + + cache_key = _build_profile_spotify_cache_key( + profile_id, {"client_id": client_id, "client_secret": client_secret, "redirect_uri": redirect_uri}) with _client_cache_lock: client = _client_cache.get(cache_key) if client is not None and getattr(client, "sp", None) is not None: @@ -210,11 +235,11 @@ def get_spotify_client_for_profile(profile_id: Optional[int] = None): import spotipy auth_manager = SpotifyOAuth( - client_id=creds["client_id"], - client_secret=creds["client_secret"], - redirect_uri=creds.get("redirect_uri", "http://127.0.0.1:8888/callback"), + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read", - cache_path=f"config/.spotify_cache_profile_{profile_id}", + cache_path=cache_path, state=f"profile_{profile_id}", ) @@ -415,6 +440,14 @@ def get_primary_source_status( ) if source == "spotify": connected = bool(client and client.is_spotify_authenticated()) + # No-auth composite (fallback_source='spotify' + metadata.spotify_free): + # works without authentication, so treat the free path's availability + # as "connected" too. + if not connected and _get_config_value("metadata.spotify_free", False): + try: + connected = bool(client and client.is_spotify_metadata_available()) + except Exception: + connected = False elif source == "hydrabase": connected = bool(client and (client.is_connected() if hasattr(client, "is_connected") else client.is_authenticated())) elif client is not None and hasattr(client, "is_authenticated"): @@ -424,8 +457,17 @@ def get_primary_source_status( except Exception: connected = False + # Report the composite-aware source for DISPLAY: "Spotify (no auth)" is + # stored as fallback_source='spotify' + metadata.spotify_free=true. The raw + # 'spotify' is kept above for the connected/auth checks; consumers that + # label the source (sidebar, dashboard card) get 'spotify_free' so they + # stop mislabeling no-auth as plain Spotify. + display_source = source + if source == "spotify" and _get_config_value("metadata.spotify_free", False): + display_source = "spotify_free" + return { - "source": source, + "source": display_source, "connected": connected, "response_time": round((time.time() - started) * 1000, 1), } diff --git a/core/playlists/sources/spotify_public.py b/core/playlists/sources/spotify_public.py index 51189687..4ab21164 100644 --- a/core/playlists/sources/spotify_public.py +++ b/core/playlists/sources/spotify_public.py @@ -35,14 +35,19 @@ class SpotifyPublicPlaylistSource(PlaylistSource): """``playlist_id`` is a Spotify URL or ``open.spotify.com`` URI.""" from core.spotify_public_scraper import ( parse_spotify_url, - scrape_spotify_embed, + fetch_spotify_public, ) parsed = parse_spotify_url(playlist_id) if not parsed: return None - data = scrape_spotify_embed(parsed["type"], parsed["id"]) + # #838: use the full-fetch wrapper (paginates past the embed widget's + # ~100-track cap), NOT scrape_spotify_embed directly. The rest of the app + # already uses fetch_spotify_public; the adapter calling the capped embed + # scraper is why auto-sync truncated >100-track playlists to 100 while the + # initial discovery got the whole thing. Same return shape, so drop-in. + data = fetch_spotify_public(parsed["type"], parsed["id"]) if not isinstance(data, dict) or data.get("error"): return None diff --git a/core/profile_context.py b/core/profile_context.py new file mode 100644 index 00000000..5f4d6879 --- /dev/null +++ b/core/profile_context.py @@ -0,0 +1,45 @@ +"""Background profile context. + +Work that runs OUTSIDE a web request — the automation engine, scheduled jobs — +has no Flask session, so ``get_current_profile_id()`` falls back to admin +(profile 1). That's wrong for an automation owned by a non-admin: their +playlist pull, their per-profile writes, should act as THEM. + +This lets the engine declare "the work below is running for profile X" around a +unit of background work (set/reset in a try/finally). ``get_current_profile_id`` +consults it only when there's no real request — so an actual logged-in session +always wins, and nothing changes for foreground/admin paths. Built on a +``ContextVar`` so the value is scoped to the running call and reset cleanly, +even on thread-pool reuse. +""" + +from __future__ import annotations + +import contextvars + +_background_profile_id: "contextvars.ContextVar[int | None]" = contextvars.ContextVar( + "background_profile_id", default=None +) + + +def set_background_profile(profile_id): + """Declare the profile for the current background unit of work. Returns a + token to pass to reset_background_profile (use in try/finally).""" + return _background_profile_id.set(profile_id) + + +def reset_background_profile(token) -> None: + """Restore the previous background profile (clears the override).""" + try: + _background_profile_id.reset(token) + except Exception: + # Token from a different context — clear to the default rather than leak. + _background_profile_id.set(None) + + +def get_background_profile(): + """The background profile in effect, or None if none is set.""" + return _background_profile_id.get() + + +__all__ = ["set_background_profile", "reset_background_profile", "get_background_profile"] diff --git a/core/security/auth_proxy.py b/core/security/auth_proxy.py new file mode 100644 index 00000000..f59c6824 --- /dev/null +++ b/core/security/auth_proxy.py @@ -0,0 +1,40 @@ +"""Trust an authenticated-user header from a forward-auth proxy. + +When SoulSync sits behind an auth proxy (Authelia / Authentik / oauth2-proxy), the +proxy authenticates the user and passes their identity in a header (commonly +``Remote-User``). With ``security.auth_proxy_header`` set to that header name, +SoulSync treats a request carrying it as already-authenticated and lets it past the +launch lock — the proxy is the gatekeeper. + +OFF by default (empty header name) → a strict no-op; the launch PIN behaves exactly +as before. + +⚠️ SECURITY: only enable this behind a proxy you control that STRIPS any +client-supplied copy of the header. Otherwise a direct client could send +``Remote-User: admin`` and walk straight in. This is why it's opt-in and never on +by default. +""" + +from __future__ import annotations + +from typing import Callable, Optional + + +def trusted_proxy_user(get_header: Callable[[str], Optional[str]], + header_name: str) -> Optional[str]: + """Return the authenticated username from the configured proxy header, or None. + + ``get_header`` is a ``request.headers.get``-style callable. ``header_name`` is + the configured header (e.g. ``Remote-User``); empty/None disables the feature + (always returns None), so a non-proxy install is unaffected. + """ + if not header_name: + return None + try: + value = (get_header(header_name) or "").strip() + except Exception: + return None + return value or None + + +__all__ = ["trusted_proxy_user"] diff --git a/core/security/launch_lock.py b/core/security/launch_lock.py index 8b8ce52a..2a0a75af 100644 --- a/core/security/launch_lock.py +++ b/core/security/launch_lock.py @@ -30,6 +30,9 @@ from __future__ import annotations _ALLOWED_GET = frozenset({ '/api/profiles', # profile picker list (multi-profile launch) '/api/profiles/current', # how the frontend detects the lock state + '/api/setup/status', # #842: first-run check runs before the PIN screen; + # blocking it made the frontend think setup wasn't + # done and re-launch the wizard on every visit. }) # POST endpoints that drive selection + unlock. Selecting a profile only sets diff --git a/core/security/login_gate.py b/core/security/login_gate.py new file mode 100644 index 00000000..87fb0375 --- /dev/null +++ b/core/security/login_gate.py @@ -0,0 +1,55 @@ +"""Pure gate decision for opt-in username/password login mode. + +When ``security.require_login`` is on, every request must come from an +authenticated session; unauthenticated requests are blocked except the page shell, +the login/logout flow, and the key-authed public API. This is the per-user +equivalent of (and replacement for) the shared launch-PIN gate. + +Deliberately does NOT allowlist the profile LIST or picker — in login mode you log +in by typing your name + password, you don't pick from an exposed roster. +""" + +from __future__ import annotations + +# GET endpoints the login screen itself needs before auth. +_ALLOWED_GET = frozenset({ + '/api/profiles/current', # how the frontend detects login state + '/api/setup/status', # first-run check runs before the login screen + '/api/auth/recovery-question', # forgot-password: fetch the security question +}) + +# POST endpoints that drive the login flow. +_ALLOWED_POST = frozenset({ + '/api/auth/login', + '/api/auth/logout', + '/api/auth/recovery-reset', # forgot-password: answer + set a new password +}) + + +def login_request_is_blocked(path: str, method: str, *, + require_login: bool, authenticated: bool) -> bool: + """True when the login gate must reject this request (login mode on + the + session isn't authenticated and the path isn't part of the login flow).""" + if not require_login or authenticated: + return False + + path = path or '' + method = (method or 'GET').upper() + + # Page shell + assets needed to render the login screen. + if path == '/' or path.startswith('/static/') or path.startswith('/favicon'): + return False + + # Key-authed public API governs itself (its own key auth). + if path.startswith('/api/v1/') and not path.startswith('/api/v1/api-keys-internal'): + return False + + if method == 'GET' and path in _ALLOWED_GET: + return False + if method == 'POST' and path in _ALLOWED_POST: + return False + + return True + + +__all__ = ['login_request_is_blocked'] diff --git a/core/security/rate_limit.py b/core/security/rate_limit.py new file mode 100644 index 00000000..1fd7b56d --- /dev/null +++ b/core/security/rate_limit.py @@ -0,0 +1,54 @@ +"""Lenient in-memory failed-attempt limiter for the launch-PIN unlock. + +Brute-force protection for a publicly-exposed instance. Deliberately lenient: only +a FLOOD of failures from one client trips it, and a single success clears that +client immediately — so a legitimate user typing their PIN (even with a few typos) +never hits it. Failures age out on their own, so a tripped client self-heals +without any persistent lockout state. + +Keyed by client IP. In-memory is fine here: the launch lock is a coarse gate, not +per-account auth, and a process restart simply forgets attempts (fail-open, which +is correct for a self-hosted convenience lock). +""" + +from __future__ import annotations + +from collections import defaultdict +from typing import Dict, List, Tuple + + +class AttemptLimiter: + def __init__(self, max_attempts: int = 10, window_seconds: int = 300): + """``max_attempts`` failures within ``window_seconds`` → locked until the + oldest failure in the window ages out.""" + self.max_attempts = max_attempts + self.window = window_seconds + self._failures: Dict[str, List[float]] = defaultdict(list) + + def _prune(self, key: str, now: float) -> List[float]: + recent = [t for t in self._failures.get(key, []) if now - t < self.window] + if recent: + self._failures[key] = recent + else: + self._failures.pop(key, None) + return recent + + def is_locked(self, key: str, now: float) -> Tuple[bool, int]: + """(locked, retry_after_seconds). retry_after is when the oldest in-window + failure expires, so the client unlocks naturally.""" + recent = self._prune(key, now) + if len(recent) >= self.max_attempts: + retry_after = int(self.window - (now - min(recent))) + 1 + return True, max(retry_after, 1) + return False, 0 + + def record_failure(self, key: str, now: float) -> None: + self._prune(key, now) + self._failures[key].append(now) + + def record_success(self, key: str) -> None: + """A correct entry clears that client's failure history immediately.""" + self._failures.pop(key, None) + + +__all__ = ["AttemptLimiter"] diff --git a/core/security/reverse_proxy.py b/core/security/reverse_proxy.py new file mode 100644 index 00000000..ab1a1689 --- /dev/null +++ b/core/security/reverse_proxy.py @@ -0,0 +1,60 @@ +"""Opt-in reverse-proxy mode. + +Default OFF. When off this is a strict no-op: the Flask app is left exactly as it +was, ``X-Forwarded-*`` headers are NOT trusted (so a direct client can't spoof its +IP/scheme), and the session cookie keeps Flask's defaults. So a normal direct / +LAN install is byte-for-byte unchanged. + +Only when the operator explicitly sets ``security.trust_reverse_proxy: true`` — +they're running behind nginx / Caddy / Traefik that terminates TLS — do we: + - trust the proxy's ``X-Forwarded-For/Proto/Host/Port`` (correct client IP, + HTTPS detection, redirects), and + - mark the session cookie ``Secure`` (HTTPS-only) + ``SameSite=Lax``. + +Gated this way the security/UX change is scoped strictly to people who turned it +on; everyone else is untouched. +""" + +from __future__ import annotations + +CONFIG_KEY = "security.trust_reverse_proxy" + + +def apply_reverse_proxy_mode(app, config_get) -> bool: + """Apply reverse-proxy hardening to ``app`` iff the operator enabled it. + + ``config_get`` is a ``config_manager.get``-style callable ``(key, default)``. + Returns True if proxy mode was enabled, False (no-op) otherwise. Never raises + out — a failure to enable falls back to the safe no-op behaviour. + """ + try: + if not config_get(CONFIG_KEY, False): + return False + from werkzeug.middleware.proxy_fix import ProxyFix + # Trust exactly one proxy hop for each forwarded header. + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1) + app.config["SESSION_COOKIE_SECURE"] = True + app.config["SESSION_COOKIE_SAMESITE"] = "Lax" + + # Security headers — registered ONLY in proxy mode (so a direct/LAN install + # gets none of them). Conservative set that won't break a same-origin app: + # nosniff, clickjacking protection, and HSTS (safe: only honoured over the + # HTTPS the proxy terminates). No CSP here — it needs per-deployment tuning + # and is better added at the proxy. setdefault() so we never clobber a + # header the proxy already set. + @app.after_request + def _security_headers(response): + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("X-Frame-Options", "SAMEORIGIN") + response.headers.setdefault( + "Strict-Transport-Security", "max-age=31536000; includeSubDomains" + ) + return response + + return True + except Exception: + # If anything goes wrong, behave like off — never break startup over this. + return False + + +__all__ = ["apply_reverse_proxy_mode", "CONFIG_KEY"] diff --git a/database/music_database.py b/database/music_database.py index d87e92de..9c196d25 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -460,7 +460,10 @@ class MusicDatabase: self._add_profile_support_v4(cursor) self._add_profile_settings(cursor) self._add_profile_listenbrainz_support(cursor) + self._add_profile_password_support(cursor) + self._add_profile_recovery_support(cursor) self._add_profile_service_credentials(cursor) + self._add_service_credential_sets(cursor) self._add_soul_id_columns(cursor) self._add_listening_history_table(cursor) @@ -3853,6 +3856,228 @@ class MusicDatabase: except Exception as e: logger.error(f"Error in per-profile service credentials migration: {e}") + def _add_service_credential_sets(self, cursor): + """Named, switchable credential sets per auth service + each profile's + selection of which set is active (Phase 0 foundation). + + Additive only — two new tables, no change to existing tables/columns. + Dormant until the resolver + UI are wired in a later phase, so this + migration changes no runtime behaviour for existing installs. + """ + try: + cursor.execute("SELECT value FROM metadata WHERE key = 'service_credentials_v1' LIMIT 1") + if cursor.fetchone(): + return # Already migrated + + logger.info("Applying service-credential-sets migration...") + + # Admin-created named credential sets. `payload` is a Fernet-encrypted + # JSON blob (same key as per-profile tokens), so secrets stay encrypted + # at rest. UNIQUE(service, label) keeps pill names distinct per service. + cursor.execute(""" + CREATE TABLE IF NOT EXISTS service_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service TEXT NOT NULL, + label TEXT NOT NULL, + payload TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(service, label) + ) + """) + + # Per-profile selection of which credential set is active for a + # service. A missing row (or NULL credential_id) means "fall back to + # the global/admin default" — so a profile never breaks if its + # chosen set is later removed. + cursor.execute(""" + CREATE TABLE IF NOT EXISTS profile_service_credentials ( + profile_id INTEGER NOT NULL, + service TEXT NOT NULL, + credential_id INTEGER, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (profile_id, service) + ) + """) + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_service_credentials_service " + "ON service_credentials (service)" + ) + + cursor.execute( + "INSERT OR REPLACE INTO metadata (key, value) VALUES ('service_credentials_v1', '1')" + ) + logger.info("Service-credential-sets migration completed") + except Exception as e: + logger.error(f"Error in service-credential-sets migration: {e}") + + # ── Service credential sets (named, switchable per profile) ────────────── + + def create_service_credential(self, service: str, label: str, payload: dict): + """Create a named credential set for a service. Returns the new id, or + None on failure / duplicate (service, label). Payload is encrypted.""" + try: + from config.settings import config_manager + enc = config_manager._encrypt_value(payload) if payload else None + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "INSERT INTO service_credentials (service, label, payload) VALUES (?, ?, ?)", + (service, label, enc), + ) + conn.commit() + return cursor.lastrowid + except sqlite3.IntegrityError: + logger.warning(f"Service credential '{label}' already exists for {service}") + return None + except Exception as e: + logger.error(f"Error creating service credential ({service}/{label}): {e}") + return None + + def update_service_credential(self, credential_id: int, label: str = None, + payload: dict = None) -> bool: + """Update a credential set's label and/or payload. Only provided fields + change. Returns True if a row was updated.""" + try: + from config.settings import config_manager + sets, params = [], [] + if label is not None: + sets.append("label = ?") + params.append(label) + if payload is not None: + sets.append("payload = ?") + params.append(config_manager._encrypt_value(payload) if payload else None) + if not sets: + return False + sets.append("updated_at = CURRENT_TIMESTAMP") + params.append(credential_id) + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + f"UPDATE service_credentials SET {', '.join(sets)} WHERE id = ?", params + ) + conn.commit() + return cursor.rowcount > 0 + except sqlite3.IntegrityError: + logger.warning(f"Rename of credential {credential_id} collides with an existing label") + return False + except Exception as e: + logger.error(f"Error updating service credential {credential_id}: {e}") + return False + + def delete_service_credential(self, credential_id: int) -> bool: + """Delete a credential set and clear any profile selections that point + at it (so those profiles fall back to the global default). Returns True + if the set existed.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "UPDATE profile_service_credentials SET credential_id = NULL, " + "updated_at = CURRENT_TIMESTAMP WHERE credential_id = ?", + (credential_id,), + ) + cursor.execute("DELETE FROM service_credentials WHERE id = ?", (credential_id,)) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"Error deleting service credential {credential_id}: {e}") + return False + + def list_service_credentials(self, service: str = None): + """List credential sets (metadata only — never the payload). Optionally + filtered to one service. Returns dicts: id, service, label, timestamps.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + if service: + cursor.execute( + "SELECT id, service, label, created_at, updated_at FROM service_credentials " + "WHERE service = ? ORDER BY label COLLATE NOCASE", + (service,), + ) + else: + cursor.execute( + "SELECT id, service, label, created_at, updated_at FROM service_credentials " + "ORDER BY service, label COLLATE NOCASE" + ) + return [ + {'id': r[0], 'service': r[1], 'label': r[2], + 'created_at': r[3], 'updated_at': r[4]} + for r in cursor.fetchall() + ] + except Exception as e: + logger.error(f"Error listing service credentials: {e}") + return [] + + def get_service_credential(self, credential_id: int): + """Get a credential set WITH its decrypted payload, or None. For the + resolver / client wiring — not for shipping to the browser.""" + try: + from config.settings import config_manager + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT id, service, label, payload FROM service_credentials WHERE id = ?", + (credential_id,), + ) + row = cursor.fetchone() + if not row: + return None + payload = config_manager._decrypt_value(row[3]) if row[3] else {} + return {'id': row[0], 'service': row[1], 'label': row[2], + 'payload': payload if isinstance(payload, dict) else {}} + except Exception as e: + logger.error(f"Error reading service credential {credential_id}: {e}") + return None + + def set_profile_service_credential(self, profile_id: int, service: str, + credential_id) -> bool: + """Select which credential set is active for a profile + service. + Pass credential_id=None to clear (fall back to global). Upsert.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "INSERT INTO profile_service_credentials (profile_id, service, credential_id) " + "VALUES (?, ?, ?) " + "ON CONFLICT(profile_id, service) DO UPDATE SET " + "credential_id = excluded.credential_id, updated_at = CURRENT_TIMESTAMP", + (profile_id, service, credential_id), + ) + conn.commit() + return True + except Exception as e: + logger.error(f"Error setting profile {profile_id} {service} credential: {e}") + return False + + def get_profile_service_credential_id(self, profile_id: int, service: str): + """Return the credential_id a profile selected for a service, or None.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT credential_id FROM profile_service_credentials " + "WHERE profile_id = ? AND service = ?", + (profile_id, service), + ) + row = cursor.fetchone() + return row[0] if row else None + except Exception as e: + logger.error(f"Error reading profile {profile_id} {service} selection: {e}") + return None + + def resolve_profile_service_credential(self, profile_id: int, service: str): + """Resolve a profile's ACTIVE credential payload for a service: the + decrypted payload of its selected set, or None when it hasn't selected + one (or the set was deleted) — caller then uses the global/admin default. + Stale-safe: a dangling selection resolves to None, not an error.""" + cred_id = self.get_profile_service_credential_id(profile_id, service) + if not cred_id: + return None + cred = self.get_service_credential(cred_id) + return cred['payload'] if cred else None + def _add_soul_id_columns(self, cursor): """Add soul_id columns to artists, albums, and tracks tables.""" try: @@ -4524,6 +4749,48 @@ class MusicDatabase: logger.error(f"Error setting Spotify tokens for profile {profile_id}: {e}") return False + def set_profile_tidal_tokens(self, profile_id: int, access_token: str, refresh_token: str) -> bool: + """Save Tidal OAuth tokens for a profile (encrypted). Used by the + per-profile Tidal client's token refresh — keeps a profile's refresh from + ever touching the global tidal_tokens slot.""" + try: + from config.settings import config_manager + enc_access = config_manager._encrypt_value(access_token) if access_token else None + enc_refresh = config_manager._encrypt_value(refresh_token) if refresh_token else None + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + UPDATE profiles + SET tidal_access_token = ?, tidal_refresh_token = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (enc_access, enc_refresh, profile_id)) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"Error setting Tidal tokens for profile {profile_id}: {e}") + return False + + def get_profile_tidal(self, profile_id: int) -> Dict[str, Any]: + """Get decrypted Tidal tokens for a profile ({} if none).""" + try: + from config.settings import config_manager + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT tidal_access_token, tidal_refresh_token FROM profiles WHERE id = ?", + (profile_id,)) + row = cursor.fetchone() + if not row or not row[0]: + return {} + return { + 'access_token': config_manager._decrypt_value(row[0]) if row[0] else '', + 'refresh_token': config_manager._decrypt_value(row[1]) if row[1] else '', + } + except Exception as e: + logger.error(f"Error getting Tidal tokens for profile {profile_id}: {e}") + return {} + def set_profile_server_library(self, profile_id: int, server_type: str, library_id: str = None, user_id: str = None) -> bool: """Save media server library/user selection for a profile.""" @@ -4979,6 +5246,9 @@ class MusicDatabase: 'avatar_url': row['avatar_url'] if 'avatar_url' in columns else None, 'is_admin': bool(row['is_admin']), 'has_pin': row['pin_hash'] is not None, + 'has_password': row['password_hash'] is not None if 'password_hash' in columns else False, + 'has_recovery': row['recovery_answer_hash'] is not None if 'recovery_answer_hash' in columns else False, + 'recovery_question': row['recovery_question'] if 'recovery_question' in columns else None, 'home_page': row['home_page'] if 'home_page' in columns else None, 'allowed_pages': json.loads(ap_raw) if ap_raw else None, 'can_download': bool(row['can_download']) if 'can_download' in columns else True, @@ -5009,6 +5279,9 @@ class MusicDatabase: 'avatar_url': row['avatar_url'] if 'avatar_url' in columns else None, 'is_admin': bool(row['is_admin']), 'has_pin': row['pin_hash'] is not None, + 'has_password': row['password_hash'] is not None if 'password_hash' in columns else False, + 'has_recovery': row['recovery_answer_hash'] is not None if 'recovery_answer_hash' in columns else False, + 'recovery_question': row['recovery_question'] if 'recovery_question' in columns else None, 'home_page': row['home_page'] if 'home_page' in columns else None, 'allowed_pages': json.loads(ap_raw) if ap_raw else None, 'can_download': bool(row['can_download']) if 'can_download' in columns else True, @@ -5110,6 +5383,172 @@ class MusicDatabase: logger.error(f"Error verifying PIN for profile {profile_id}: {e}") return False + # ── Per-profile LOGIN password (opt-in username/password mode) ──────────── + # Separate from the quick-switch PIN on purpose: the PIN is a low-stakes + # convenience on a trusted LAN; the password authenticates an account for + # public exposure. Conflating them would make logins as weak as a 4-digit PIN. + + def set_profile_password(self, profile_id: int, password: str) -> bool: + """Set (or clear, when password is falsy) a profile's login password.""" + try: + from werkzeug.security import generate_password_hash + pwd_hash = generate_password_hash(password, method='pbkdf2:sha256') if password else None + with self._get_connection() as conn: + conn.execute("UPDATE profiles SET password_hash = ? WHERE id = ?", (pwd_hash, profile_id)) + conn.commit() + return True + except Exception as e: + logger.error(f"Error setting password for profile {profile_id}: {e}") + return False + + def verify_profile_password(self, profile_id: int, password: str) -> bool: + """Verify a profile's login password. Unlike the PIN, a profile with NO + password set is NOT loginable (returns False) — you can't authenticate to + an account that has no credential.""" + try: + from werkzeug.security import check_password_hash + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT password_hash FROM profiles WHERE id = ?", (profile_id,)) + row = cursor.fetchone() + if not row or not row['password_hash']: + return False # no password set → cannot log in + return check_password_hash(row['password_hash'], password) + except Exception as e: + logger.error(f"Error verifying password for profile {profile_id}: {e}") + return False + + def profile_has_password(self, profile_id: int) -> bool: + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT password_hash FROM profiles WHERE id = ?", (profile_id,)) + row = cursor.fetchone() + return bool(row and row['password_hash']) + except Exception as e: + logger.error(f"Error checking password for profile {profile_id}: {e}") + return False + + def get_profile_by_name(self, name: str) -> Optional[Dict[str, Any]]: + """Look up a profile by name (the login username), case-insensitive.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT id, name, is_admin FROM profiles WHERE LOWER(name) = LOWER(?)", + (name or '',)) + row = cursor.fetchone() + if not row: + return None + return {'id': row['id'], 'name': row['name'], 'is_admin': bool(row['is_admin'])} + except Exception as e: + logger.error(f"Error looking up profile by name '{name}': {e}") + return None + + def _add_profile_password_support(self, cursor): + """Add a per-profile login password column (separate from pin_hash).""" + try: + cursor.execute("SELECT value FROM metadata WHERE key = 'profiles_password_v1' LIMIT 1") + if cursor.fetchone(): + return # Already migrated + logger.info("Applying per-profile login-password migration...") + try: + cursor.execute("ALTER TABLE profiles ADD COLUMN password_hash TEXT DEFAULT NULL") + except sqlite3.OperationalError: + pass # Column already exists + cursor.execute( + "INSERT OR REPLACE INTO metadata (key, value) VALUES ('profiles_password_v1', '1')") + logger.info("Per-profile login-password migration completed") + except Exception as e: + logger.error(f"Error in login-password migration: {e}") + + # ── Login-password recovery (security question + answer) ────────────────── + + @staticmethod + def _normalize_recovery_answer(answer: str) -> str: + """Forgiving match: trim + lowercase + collapse internal whitespace.""" + return ' '.join((answer or '').strip().lower().split()) + + def set_profile_recovery(self, profile_id: int, question: str, answer: str) -> bool: + """Set (or clear, when either is empty) a profile's recovery Q + answer.""" + try: + from werkzeug.security import generate_password_hash + q = (question or '').strip() + norm = self._normalize_recovery_answer(answer) + if not q or not norm: + question_val, answer_hash = None, None # clear + else: + question_val = q + answer_hash = generate_password_hash(norm, method='pbkdf2:sha256') + with self._get_connection() as conn: + conn.execute( + "UPDATE profiles SET recovery_question = ?, recovery_answer_hash = ? WHERE id = ?", + (question_val, answer_hash, profile_id)) + conn.commit() + return True + except Exception as e: + logger.error(f"Error setting recovery for profile {profile_id}: {e}") + return False + + def get_profile_recovery_question(self, profile_id: int) -> Optional[str]: + """The recovery question text, or None if none set.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT recovery_question FROM profiles WHERE id = ?", (profile_id,)) + row = cursor.fetchone() + return row['recovery_question'] if row and row['recovery_question'] else None + except Exception as e: + logger.error(f"Error reading recovery question for profile {profile_id}: {e}") + return None + + def verify_profile_recovery_answer(self, profile_id: int, answer: str) -> bool: + """Verify the recovery answer. No recovery set → never verifies.""" + try: + from werkzeug.security import check_password_hash + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT recovery_answer_hash FROM profiles WHERE id = ?", (profile_id,)) + row = cursor.fetchone() + if not row or not row['recovery_answer_hash']: + return False + return check_password_hash(row['recovery_answer_hash'], self._normalize_recovery_answer(answer)) + except Exception as e: + logger.error(f"Error verifying recovery answer for profile {profile_id}: {e}") + return False + + def profile_has_recovery(self, profile_id: int) -> bool: + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT recovery_answer_hash FROM profiles WHERE id = ?", (profile_id,)) + row = cursor.fetchone() + return bool(row and row['recovery_answer_hash']) + except Exception as e: + logger.error(f"Error checking recovery for profile {profile_id}: {e}") + return False + + def _add_profile_recovery_support(self, cursor): + """Add recovery question + answer-hash columns (login-password recovery).""" + try: + cursor.execute("SELECT value FROM metadata WHERE key = 'profiles_recovery_v1' LIMIT 1") + if cursor.fetchone(): + return # Already migrated + logger.info("Applying per-profile recovery-question migration...") + for col_sql in ( + "ALTER TABLE profiles ADD COLUMN recovery_question TEXT DEFAULT NULL", + "ALTER TABLE profiles ADD COLUMN recovery_answer_hash TEXT DEFAULT NULL", + ): + try: + cursor.execute(col_sql) + except sqlite3.OperationalError: + pass # Column already exists + cursor.execute( + "INSERT OR REPLACE INTO metadata (key, value) VALUES ('profiles_recovery_v1', '1')") + logger.info("Per-profile recovery-question migration completed") + except Exception as e: + logger.error(f"Error in recovery-question migration: {e}") + def close(self): """Close database connection (no-op since we create connections per operation)""" # Each operation creates and closes its own connection, so nothing to do here @@ -6366,17 +6805,22 @@ class MusicDatabase: logger.error(f"Error searching artists with query '{query}': {e}") return [] - def search_tracks(self, title: str = "", artist: str = "", limit: int = 50, server_source: str = None) -> List[DatabaseTrack]: - """Search tracks by title and/or artist name with Unicode-aware fuzzy matching""" + def search_tracks(self, title: str = "", artist: str = "", limit: int = 50, server_source: str = None, + rank_artist: str = None) -> List[DatabaseTrack]: + """Search tracks by title and/or artist name with Unicode-aware fuzzy matching. + + ``rank_artist`` is a relevance-only hint (never filters): when given, rows + by that artist rank to the top so an exact title+artist match wins over + same-title tracks by other artists.""" try: if not title and not artist: return [] - + conn = self._get_connection() cursor = conn.cursor() - + # STRATEGY 1: Try basic SQL LIKE search first (fastest) - basic_results = self._search_tracks_basic(cursor, title, artist, limit, server_source) + basic_results = self._search_tracks_basic(cursor, title, artist, limit, server_source, rank_artist) if basic_results: logger.debug(f"Basic search found {len(basic_results)} results") @@ -6416,14 +6860,18 @@ class MusicDatabase: logger.error(f"API: Error searching tracks with title='{title}', artist='{artist}': {e}") return [] - def _search_tracks_basic(self, cursor, title: str, artist: str, limit: int, server_source: str = None) -> List[DatabaseTrack]: + def _search_tracks_basic(self, cursor, title: str, artist: str, limit: int, server_source: str = None, + rank_artist: str = None) -> List[DatabaseTrack]: """Basic SQL LIKE search - fastest method""" - rows = self._search_tracks_basic_rows(cursor, title, artist, limit, server_source) + rows = self._search_tracks_basic_rows(cursor, title, artist, limit, server_source, rank_artist) return self._rows_to_tracks(rows) def _search_tracks_basic_rows(self, cursor, title: str, artist: str, limit: int, - server_source: Optional[str] = None): - """Basic SQL LIKE search returning raw rows (shared by DatabaseTrack and dict-returning callers).""" + server_source: Optional[str] = None, rank_artist: Optional[str] = None): + """Basic SQL LIKE search returning raw rows (shared by DatabaseTrack and dict-returning callers). + + ``rank_artist`` is a relevance-only hint (does NOT filter): when given, + rows by that artist sort to the top so an exact title+artist match wins.""" where_conditions = [] params = [] @@ -6446,6 +6894,33 @@ class MusicDatabase: return [] where_clause = " AND ".join(where_conditions) + + # Relevance ordering. The old `ORDER BY tracks.title` was case-SENSITIVE + # (SQLite BINARY collation sorts 'B' before 'b'), so a lowercase exact + # title like Billie Eilish's "bad guy" sorted BELOW every capitalised + # "Bad Guy" and got cut off by LIMIT. Now: exact title match first, then + # prefix, then — when an artist is given — exact/contains artist match, + # finally case-insensitive alphabetical. unidecode_lower folds case + + # accents, matching the WHERE clause. + order_parts, order_params = [], [] + if title: + norm_title = self._normalize_for_comparison(title) + order_parts.append( + "CASE WHEN unidecode_lower(tracks.title) = ? THEN 0 " + "WHEN unidecode_lower(tracks.title) LIKE ? THEN 1 ELSE 2 END") + order_params.extend([norm_title, f"{norm_title}%"]) + _rank_artist = artist or rank_artist + if _rank_artist: + norm_artist = self._normalize_for_comparison(_rank_artist) + order_parts.append( + "CASE WHEN unidecode_lower(artists.name) = ? THEN 0 " + "WHEN unidecode_lower(artists.name) LIKE ? THEN 1 ELSE 2 END") + order_params.extend([norm_artist, f"%{norm_artist}%"]) + order_parts.append("unidecode_lower(tracks.title)") + order_parts.append("unidecode_lower(artists.name)") + order_by = ", ".join(order_parts) + + params.extend(order_params) params.append(limit) cursor.execute(f""" @@ -6454,7 +6929,7 @@ class MusicDatabase: JOIN artists ON tracks.artist_id = artists.id JOIN albums ON tracks.album_id = albums.id WHERE {where_clause} - ORDER BY tracks.title, artists.name + ORDER BY {order_by} LIMIT ? """, params) diff --git a/tests/automation/test_engine_profile_context.py b/tests/automation/test_engine_profile_context.py new file mode 100644 index 00000000..ba03731d --- /dev/null +++ b/tests/automation/test_engine_profile_context.py @@ -0,0 +1,64 @@ +"""The engine runs each automation AS its owner profile (per-profile automations). + +A non-admin's scheduled job must execute with their profile in the background +context, so get_current_profile_id() / the per-profile clients act as them — not +admin. Admin/system automations (profile 1) are unchanged. The context must be +reset after every run so a pooled thread can't leak it. +""" + +from unittest.mock import MagicMock + +import pytest + +from core.automation_engine import AutomationEngine +from core.profile_context import get_background_profile + + +def _engine(owner_profile_id): + db = MagicMock() + db.get_automation.return_value = { + 'id': 1, 'name': 'Auto-Sync', 'enabled': True, + 'action_type': 'sync_playlist', 'action_config': '{}', + 'trigger_type': 'interval_hours', 'trigger_config': '{"hours": 1}', + 'profile_id': owner_profile_id, + } + db.update_automation_run = MagicMock(return_value=True) + eng = AutomationEngine(db) + eng._running = True + seen = {} + eng._action_handlers['sync_playlist'] = { + 'handler': lambda config: seen.update(profile=get_background_profile()) or {'status': 'completed'}, + 'guard': None, + } + return eng, seen + + +def test_nonadmin_owned_automation_runs_as_owner(): + eng, seen = _engine(owner_profile_id=4) + eng.run_automation(1, skip_delay=True) + assert seen['profile'] == 4 # handler ran AS profile 4 + assert get_background_profile() is None # reset after the run + + +def test_admin_owned_automation_runs_as_admin(): + eng, seen = _engine(owner_profile_id=1) + eng.run_automation(1, skip_delay=True) + assert seen['profile'] == 1 # unchanged for admin/system + assert get_background_profile() is None + + +def test_explicit_trigger_profile_overrides_owner(): + # A manual trigger (run_automation(profile_id=...)) wins over the owner. + eng, seen = _engine(owner_profile_id=4) + eng.run_automation(1, skip_delay=True, profile_id=9) + assert seen['profile'] == 9 + + +def test_context_reset_even_when_handler_raises(): + eng, _ = _engine(owner_profile_id=4) + eng._action_handlers['sync_playlist'] = { + 'handler': lambda config: (_ for _ in ()).throw(RuntimeError('boom')), + 'guard': None, + } + eng.run_automation(1, skip_delay=True) # error is caught + stored + assert get_background_profile() is None # finally reset it diff --git a/tests/discovery/test_discovery_endpoints.py b/tests/discovery/test_discovery_endpoints.py index 2f1b0ace..5aa80d27 100644 --- a/tests/discovery/test_discovery_endpoints.py +++ b/tests/discovery/test_discovery_endpoints.py @@ -955,3 +955,71 @@ def test_snapshot_exception_returns_500(): raise RuntimeError('db down') body, code = save_bubble_snapshot(lambda: {'bubbles': [1]}, **_snap_kwargs(_BoomDB())) assert code == 500 and body == {'success': False, 'error': 'db down'} + + +def test_update_match_no_state_but_originals_saves_cache(): + """#843: the in-memory discovery state can be gone (server restart, or an + imported playlist not discovered this run) while the card is still shown from + persisted data. A manual fix must still save the match to the discovery cache + from the client-provided originals instead of 404ing.""" + from core.discovery.endpoints import update_discovery_match + db = _FakeCacheDB() + gj, kw, _ = _update_kwargs(cache_db=db, json_data={ + 'identifier': 'gone-from-memory', 'track_index': 0, + 'original_name': 'Acid Dream', + # multi-artist joined string, exactly like the #843 reporter's track + 'original_artist': 'Cherrymoon Traxx, Hermol, SBM, BELS', + 'spotify_track': { + 'id': 'sp1', 'name': 'Acid Dream (The Prophet remix)', + 'artists': ['Cherry Moon Trax'], 'album': 'X', 'duration_ms': 1000, + }, + }) + body, code = update_discovery_match({}, gj, **kw) # empty in-memory states + + assert code == 200 and body['success'] is True + assert body['result'] is None # nothing to update in memory + # the durable cache match WAS written — and CRUCIALLY keyed by the FIRST + # artist, matching every in-memory/sync path (which use artists[0]). A + # full-string key here would silently never be looked up on sync. + assert len(db.saved) == 1 + saved = db.saved[0] + assert saved[1] == 'cherrymoon traxx' # cache_key artist = first artist (not the full string) + assert saved[6] == 'Cherrymoon Traxx' # original_artist reduced to first + + +def test_update_match_no_state_key_matches_in_memory_key(): + """The no-state save and the normal in-memory save must produce the SAME cache + key for the same multi-artist track — else the fix wouldn't apply on sync.""" + from core.discovery.endpoints import update_discovery_match + sp = {'id': 'sp1', 'name': 'M', 'artists': ['Z'], 'album': 'A', 'duration_ms': 1} + + # in-memory path: result carries the original track as an artists LIST + db1 = _FakeCacheDB() + state = {'discovery_results': [{'tidal_track': {'name': 'Acid Dream', + 'artists': ['Cherrymoon Traxx', 'Hermol', 'SBM', 'BELS']}}], + 'spotify_matches': 0} + gj1, kw1, _ = _update_kwargs(cache_db=db1, json_data={ + 'identifier': 'p', 'track_index': 0, 'spotify_track': sp}) + update_discovery_match({'p': state}, gj1, **kw1) + + # no-state path: client sends the joined string + db2 = _FakeCacheDB() + gj2, kw2, _ = _update_kwargs(cache_db=db2, json_data={ + 'identifier': 'p', 'track_index': 0, 'spotify_track': sp, + 'original_name': 'Acid Dream', + 'original_artist': 'Cherrymoon Traxx, Hermol, SBM, BELS'}) + update_discovery_match({}, gj2, **kw2) + + # same cache key (name, artist) → the fix applies identically either way + assert db1.saved[0][0] == db2.saved[0][0] + assert db1.saved[0][1] == db2.saved[0][1] + + +def test_update_match_no_state_and_no_originals_still_404(): + """Without an in-memory state AND without originals to key the cache, there's + genuinely nothing to do — keep the 404 (the existing safety).""" + from core.discovery.endpoints import update_discovery_match + gj, kw, _ = _update_kwargs(json_data={ + 'identifier': 'gone', 'track_index': 0, 'spotify_track': {'id': 'x'}}) + body, code = update_discovery_match({}, gj, **kw) + assert code == 404 and body == {'error': 'Discovery state not found'} diff --git a/tests/downloads/test_downloads_status.py b/tests/downloads/test_downloads_status.py index 522137e5..7050e3e6 100644 --- a/tests/downloads/test_downloads_status.py +++ b/tests/downloads/test_downloads_status.py @@ -761,3 +761,66 @@ def test_unified_response_caps_persistent_history_tail(): assert requested_limits == [50] assert len(out['downloads']) == 50 assert out['total'] == 50 + + +# --------------------------------------------------------------------------- +# #836 — a rejected slskd transfer must not hang the task at 'downloading' +# forever. The monitor normally retries; when it can't make progress, a backstop +# in the status formatter marks the task failed after a grace window so the +# worker frees and the batch can complete. +# --------------------------------------------------------------------------- + +def test_rejected_within_grace_keeps_downloading_for_monitor(): + import time + deps, _ = _build_deps() + now = time.time() + download_tasks['t1'] = { + 'track_index': 0, 'status': 'downloading', 'track_info': {}, + 'filename': 'song.flac', 'username': 'u1', + 'status_change_time': now - 5, + } + live = {'u1::song.flac': {'state': 'Completed, Rejected', 'percentComplete': 0}} + batch = {'phase': 'downloading', 'queue': ['t1']} + out = st.build_batch_status_data('b1', batch, live, deps) + # inside the grace window — give the monitor its chance, don't fail yet + assert out['tasks'][0]['status'] == 'downloading' + assert download_tasks['t1']['status'] == 'downloading' + + +def test_rejected_beyond_grace_marks_failed_and_frees_worker(): + import time + completed = [] + deps, _ = _build_deps() + deps.on_download_completed = lambda b, t, s: completed.append((b, t, s)) + now = time.time() + download_tasks['t1'] = { + 'track_index': 0, 'status': 'downloading', 'track_info': {}, + 'filename': 'song.flac', 'username': 'u1', + # error persisted past the grace, no monitor transition since + 'status_change_time': now - 130, + '_error_state_since': now - (st.ERROR_STATE_TERMINAL_GRACE_SECONDS + 30), + } + live = {'u1::song.flac': {'state': 'Completed, Rejected', 'errorMessage': 'peer rejected'}} + batch = {'phase': 'downloading', 'queue': ['t1']} + out = st.build_batch_status_data('b1', batch, live, deps) + assert out['tasks'][0]['status'] == 'failed' + assert download_tasks['t1']['status'] == 'failed' + assert download_tasks['t1']['error_message'] == 'peer rejected' + time.sleep(0.05) # completion callback runs on a daemon thread + assert completed == [('b1', 't1', False)] # batch can now finish + + +def test_manual_pick_rejected_fails_immediately_without_grace(): + import time + deps, _ = _build_deps() + now = time.time() + download_tasks['t1'] = { + 'track_index': 0, 'status': 'downloading', 'track_info': {}, + 'filename': 'song.flac', 'username': 'u1', + '_user_manual_pick': True, 'status_change_time': now, + } + live = {'u1::song.flac': {'state': 'Completed, Rejected'}} + batch = {'phase': 'downloading', 'queue': ['t1']} + out = st.build_batch_status_data('b1', batch, live, deps) + assert out['tasks'][0]['status'] == 'failed' # immediate, no 60s wait + assert download_tasks['t1']['status'] == 'failed' diff --git a/tests/downloads/test_file_finder.py b/tests/downloads/test_file_finder.py index a012faa3..5e7d6291 100644 --- a/tests/downloads/test_file_finder.py +++ b/tests/downloads/test_file_finder.py @@ -343,3 +343,56 @@ def test_fuzzy_rejects_low_similarity(tmp_path): if __name__ == '__main__': pytest.main([__file__, '-v']) + + +# --------------------------------------------------------------------------- +# Issue #835 — a '/' in a YouTube/Tidal title is part of the NAME, not a path +# separator. The encoded ``id||title`` finder previously basename-split the +# title ("YouSeeBIGGIRL/T:T" -> "T:T"), so the real on-disk file (with the +# slash sanitised) never matched and valid downloads got quarantined. +# --------------------------------------------------------------------------- + +from core.downloads.file_finder import _extract_basename + + +def test_encoded_title_with_slash_is_not_basename_split(): + # The Sawano AoT track. The id||title encoding must keep the whole title. + assert _extract_basename('vy63u2hKoPE||YouSeeBIGGIRL/T:T') == 'YouSeeBIGGIRL/T:T' + + +def test_finds_youtube_file_whose_title_contains_a_slash(tmp_path): + downloads = tmp_path / 'downloads' + # On disk the slash is sanitised to a look-alike and the colon spaced out, + # exactly as in the issue screenshot. + target = downloads / 'YouSeeBIGGIRL∕T: T.mp3' + _touch(target) + + found, location = find_completed_audio_file( + str(downloads), 'vy63u2hKoPE||YouSeeBIGGIRL/T:T', + ) + + assert found == str(target), 'pre-#835 this truncated the target to "T:T" and missed the file' + assert location == 'downloads' + + +def test_slash_title_does_not_match_an_unrelated_file(tmp_path): + # Guard against the fix being too loose: a different track must NOT match. + downloads = tmp_path / 'downloads' + _touch(downloads / 'Some Totally Different Song.mp3') + + found, _ = find_completed_audio_file( + str(downloads), 'vy63u2hKoPE||YouSeeBIGGIRL/T:T', + ) + assert found is None + + +def test_real_soulseek_path_still_basenamed(tmp_path): + # Regression: a genuine remote PATH must still resolve to its last segment. + downloads = tmp_path / 'downloads' + target = downloads / '01 - Suddenly.flac' + _touch(target) + assert _extract_basename(r'shared\Billy Ocean\Very Best Of\01 - Suddenly.flac') == '01 - Suddenly.flac' + found, _ = find_completed_audio_file( + str(downloads), r'shared\Billy Ocean\Very Best Of\01 - Suddenly.flac', + ) + assert found == str(target) diff --git a/tests/test_admin_gating.py b/tests/test_admin_gating.py new file mode 100644 index 00000000..6ca61f56 --- /dev/null +++ b/tests/test_admin_gating.py @@ -0,0 +1,82 @@ +"""Phase 3: server-side admin gating of shared/global-destructive endpoints. + +The audit found these were callable by any profile (UI hid them, the API didn't). +For a real multi-user setup that's unsafe — a non-admin could restore/vacuum the +DB, wipe the shared library, clear the Plex library, or mint API keys. These +assert the @admin_only gate now blocks non-admins, that admin is NOT blocked +(zero change for single-profile installs, where everyone is the default admin), +and crucially that a PROFILE-SCOPED op (clearing your OWN wishlist) was NOT +over-gated. +""" + +from __future__ import annotations + +import os +import tempfile + +import pytest + +_TMP = tempfile.mkdtemp(prefix='soulsync-testdb-gate-') +os.environ['DATABASE_PATH'] = os.path.join(_TMP, 'gate.db') +os.environ['SOULSYNC_TEST_DB_READY'] = '1' + +web_server = pytest.importorskip('web_server') + + +# (method, path) for every endpoint that must be admin-only. +GATED = [ + ('GET', '/api/v1/api-keys-internal'), + ('POST', '/api/v1/api-keys-internal/generate'), + ('DELETE', '/api/v1/api-keys-internal/revoke/abc'), + ('POST', '/api/plex/clear-library'), + ('PUT', '/api/library/clear-match'), + ('DELETE', '/api/library/track/123'), + ('DELETE', '/api/library/album/123'), + ('POST', '/api/library/tracks/delete-batch'), + ('POST', '/api/database/update'), + ('POST', '/api/database/update/stop'), + ('POST', '/api/database/backup'), + ('DELETE', '/api/database/backups/x.db'), + ('POST', '/api/database/backups/x.db/restore'), + ('POST', '/api/database/maintenance/vacuum'), + ('DELETE', '/api/metadata-cache/clear'), + ('DELETE', '/api/metadata-cache/clear-musicbrainz'), + ('POST', '/api/metadata-cache/evict'), +] + + +@pytest.fixture +def client(): + return web_server.app.test_client() + + +@pytest.fixture +def nonadmin(client): + pid = web_server.get_database().create_profile(name=f'u_{os.urandom(3).hex()}') + with client.session_transaction() as sess: + sess['profile_id'] = pid + return pid + + +def _call(client, method, path): + return client.open(path, method=method, json={}) + + +@pytest.mark.parametrize('method,path', GATED) +def test_nonadmin_blocked(client, nonadmin, method, path): + # @admin_only returns 403 BEFORE the view body runs, so this never triggers + # the underlying destructive operation — safe to assert across all of them. + assert _call(client, method, path).status_code == 403, f"{method} {path} should be 403 for non-admin" + + +def test_admin_not_blocked_by_the_gate(client): + # Default session = profile 1 (admin). Prove the gate lets admin through on a + # SAFE, read-only gated endpoint (listing API keys) — confirming the no-change + # guarantee for single-profile installs without triggering a destructive op. + assert client.get('/api/v1/api-keys-internal').status_code != 403 + + +def test_profile_scoped_wishlist_clear_not_overgated(client, nonadmin): + # Clearing your OWN wishlist is profile-scoped data — a non-admin MUST still + # be allowed. This is the guard against a blanket sweep. + assert _call(client, 'POST', '/api/wishlist/clear').status_code != 403 diff --git a/tests/test_artist_sync_stale_guard.py b/tests/test_artist_sync_stale_guard.py new file mode 100644 index 00000000..0da6a1ac --- /dev/null +++ b/tests/test_artist_sync_stale_guard.py @@ -0,0 +1,107 @@ +"""Artist 'Sync' = a single-artist deep scan: stale removal is a server-diff +(tracks the media server no longer has), with the same safety net + admin gate as +the whole-library deep scan. #828 pattern.""" + +from __future__ import annotations + +import os +import tempfile +from types import SimpleNamespace + +import pytest + +_TMP = tempfile.mkdtemp(prefix='soulsync-testdb-sync2-') +os.environ['DATABASE_PATH'] = os.path.join(_TMP, 's.db') +os.environ['SOULSYNC_TEST_DB_READY'] = '1' + +web_server = pytest.importorskip('web_server') + + +@pytest.fixture +def client(): + return web_server.app.test_client() + + +def _seed(artist_id, track_ids): + """Plex artist + album + tracks (server_source='plex').""" + db = web_server.get_database() + aid, album_id = str(artist_id), artist_id * 10 + with db._get_connection() as conn: + conn.execute("INSERT OR REPLACE INTO artists (id, name, server_source) VALUES (?, ?, 'plex')", + (aid, f'Artist {aid}')) + conn.execute("INSERT OR REPLACE INTO albums (id, title, artist_id) VALUES (?, 'Alb', ?)", + (album_id, aid)) + for tid in track_ids: + conn.execute("INSERT OR REPLACE INTO tracks (id, album_id, artist_id, title, track_number, " + "duration, file_path, server_source) VALUES (?, ?, ?, ?, 1, 100, ?, 'plex')", + (tid, album_id, aid, f'T{tid}', f'/m/{tid}.flac')) + conn.commit() + + +def _track_ids(artist_id): + db = web_server.get_database() + with db._get_connection() as conn: + return {r['id'] for r in conn.execute("SELECT id FROM tracks WHERE artist_id = ?", (str(artist_id),))} + + +def _mock_server_pull(monkeypatch, *, seen, success=True): + """Fake the media server returning `seen` track IDs for the artist.""" + class _FakeServer: + def fetchItem(self, _id): + return SimpleNamespace(title=None) # truthy artist, no name change + + class _FakePlex: + server = _FakeServer() + + class _FakeEngine: + def client(self, name): + return _FakePlex() if name == 'plex' else None + + monkeypatch.setattr(web_server, 'media_server_engine', _FakeEngine()) + + class _FakeWorker: + def __init__(self, *a, **k): + self.database = None + def _process_artist_with_content(self, server_artist, skip_existing_tracks=False, seen_track_ids=None): + if seen_track_ids is not None: + seen_track_ids.update(seen) + return (success, 'ok', 0, 0) + + monkeypatch.setattr('core.database_update_worker.DatabaseUpdateWorker', _FakeWorker) + + +def test_removes_tracks_the_server_no_longer_has(client, monkeypatch): + _seed(7001, [f't{i}' for i in range(1, 11)]) # t1..t10 in DB + _mock_server_pull(monkeypatch, seen={f't{i}' for i in range(1, 9)}) # server has t1..t8 + body = client.post('/api/library/artist/7001/sync').get_json() + assert body['success'] and body['removal_skipped'] is False + assert body['stale_removed'] == 2 # t9,t10 gone from server + assert _track_ids(7001) == {f't{i}' for i in range(1, 9)} + + +def test_guard_skips_when_most_tracks_unseen(client, monkeypatch): + _seed(7002, [f't{i}' for i in range(1, 11)]) + _mock_server_pull(monkeypatch, seen={'t1'}) # 9/10 unseen → flaky response + body = client.post('/api/library/artist/7002/sync').get_json() + assert body['removal_skipped'] is True + assert body['stale_removed'] == 0 + assert len(_track_ids(7002)) == 10 # nothing deleted + + +def test_failed_pull_skips_removal(client, monkeypatch): + _seed(7003, [f't{i}' for i in range(1, 11)]) + _mock_server_pull(monkeypatch, seen=set(), success=False) # pull failed → no trustworthy view + body = client.post('/api/library/artist/7003/sync').get_json() + assert body['removal_skipped'] is True + assert body['stale_removed'] == 0 + assert len(_track_ids(7003)) == 10 + + +def test_sync_is_admin_only(client, monkeypatch): + _seed(7004, ['t1', 't2']) + _mock_server_pull(monkeypatch, seen={'t1', 't2'}) + nonadmin = web_server.get_database().create_profile(name=f'u_{os.urandom(3).hex()}') + with client.session_transaction() as sess: + sess['profile_id'] = nonadmin + assert client.post('/api/library/artist/7004/sync').status_code == 403 + assert len(_track_ids(7004)) == 2 # untouched diff --git a/tests/test_auth_proxy.py b/tests/test_auth_proxy.py new file mode 100644 index 00000000..2883bf73 --- /dev/null +++ b/tests/test_auth_proxy.py @@ -0,0 +1,31 @@ +"""Forward-auth proxy header trust (Tier 3): OFF by default → no-op; when the +operator configures a header, a request carrying it is treated as authenticated.""" + +from __future__ import annotations + +from core.security.auth_proxy import trusted_proxy_user + + +def _headers(d): + return lambda name: d.get(name) + + +def test_off_when_no_header_configured(): + # empty header name → feature disabled → always None (direct install unaffected) + assert trusted_proxy_user(_headers({'Remote-User': 'alice'}), '') is None + assert trusted_proxy_user(_headers({'Remote-User': 'alice'}), None) is None + + +def test_returns_user_when_header_present(): + assert trusted_proxy_user(_headers({'Remote-User': 'alice'}), 'Remote-User') == 'alice' + + +def test_none_when_configured_header_absent_or_blank(): + assert trusted_proxy_user(_headers({}), 'Remote-User') is None + assert trusted_proxy_user(_headers({'Remote-User': ' '}), 'Remote-User') is None + + +def test_get_header_exception_is_safe(): + def boom(_name): + raise RuntimeError('header lookup blew up') + assert trusted_proxy_user(boom, 'Remote-User') is None diff --git a/tests/test_credentials_endpoints.py b/tests/test_credentials_endpoints.py new file mode 100644 index 00000000..7cfebc6f --- /dev/null +++ b/tests/test_credentials_endpoints.py @@ -0,0 +1,413 @@ +"""Phase 1: service-credential-set admin endpoints (real app, real HTTP). + +These import the actual web_server app and drive the endpoints through a Flask +test client — the only way to verify the @admin_only gating and the request +validation wrappers for real. Secrets must never come back in any response. + +Heavy (imports web_server once), so isolated in its own module. The default +session is profile 1 (admin); a non-admin session is simulated to prove the +gate blocks writes. +""" + +from __future__ import annotations + +import os +import tempfile + +import pytest + +# Redirect the DB before importing web_server so it never touches a real library. +_TMP = tempfile.mkdtemp(prefix='soulsync-testdb-cred-') +os.environ['DATABASE_PATH'] = os.path.join(_TMP, 'creds_ep.db') +os.environ['SOULSYNC_TEST_DB_READY'] = '1' + +web_server = pytest.importorskip('web_server') + + +@pytest.fixture +def client(): + return web_server.app.test_client() + + +@pytest.fixture +def nonadmin_profile(): + """Create a real non-admin profile and yield its id.""" + db = web_server.get_database() + pid = db.create_profile(name=f'tester_{os.urandom(3).hex()}', avatar_color='#fff') + yield pid + + +# ── admin happy paths ──────────────────────────────────────────────────────── + +def test_admin_create_list_update_delete_roundtrip(client): + r = client.post('/api/credentials', json={ + 'service': 'plex', 'label': 'Living Room', + 'payload': {'base_url': 'http://plex:32400', 'token': 'sekret'}}) + assert r.status_code == 200 and r.get_json()['success'] + cid = r.get_json()['id'] + + # list shows it, and NEVER leaks the payload/secret + body = client.get('/api/credentials').get_json() + assert any(c['label'] == 'Living Room' for c in body['services']['plex']) + assert 'sekret' not in str(body) and 'payload' not in str(body) + + # update label + assert client.put(f'/api/credentials/{cid}', json={'label': 'Den'}).get_json()['success'] + body = client.get('/api/credentials').get_json() + assert any(c['label'] == 'Den' for c in body['services']['plex']) + + # delete + assert client.delete(f'/api/credentials/{cid}').get_json()['success'] + body = client.get('/api/credentials').get_json() + assert not any(c['id'] == cid for c in body['services']['plex']) + + +# ── validation ─────────────────────────────────────────────────────────────── + +def test_create_rejects_missing_fields(client): + r = client.post('/api/credentials', json={ + 'service': 'plex', 'label': 'X', 'payload': {'base_url': 'http://p'}}) + assert r.status_code == 400 and 'token' in r.get_json()['error'] + + +def test_create_rejects_unsupported_service(client): + r = client.post('/api/credentials', json={'service': 'itunes', 'label': 'X', 'payload': {}}) + assert r.status_code == 400 + + +def test_create_rejects_blank_label(client): + r = client.post('/api/credentials', json={ + 'service': 'deezer', 'label': ' ', 'payload': {'arl': 'x'}}) + assert r.status_code == 400 + + +def test_duplicate_label_conflict(client): + p = {'service': 'qobuz', 'label': 'Dup', 'payload': {'user_auth_token': 't'}} + assert client.post('/api/credentials', json=p).status_code == 200 + assert client.post('/api/credentials', json=p).status_code == 409 + + +def test_update_missing_set_404(client): + assert client.put('/api/credentials/999999', json={'label': 'x'}).status_code == 404 + + +def test_delete_missing_set_404(client): + assert client.delete('/api/credentials/999999').status_code == 404 + + +# ── the security gate: non-admin cannot manage credential sets ─────────────── + +def test_nonadmin_blocked_from_all_credential_writes(client, nonadmin_profile): + with client.session_transaction() as sess: + sess['profile_id'] = nonadmin_profile + assert client.get('/api/credentials').status_code == 403 + assert client.post('/api/credentials', json={ + 'service': 'plex', 'label': 'Sneaky', + 'payload': {'base_url': 'http://p', 'token': 't'}}).status_code == 403 + assert client.put('/api/credentials/1', json={'label': 'x'}).status_code == 403 + assert client.delete('/api/credentials/1').status_code == 403 + + +# ── Phase 2: per-profile selection (any profile selects among existing sets) ── + +def test_profile_selects_among_existing_sets(client, nonadmin_profile): + # Admin creates two Spotify sets. + a = client.post('/api/credentials', json={'service': 'spotify', 'label': 'Acct A', + 'payload': {'client_id': 'a', 'client_secret': 's'}}).get_json()['id'] + b = client.post('/api/credentials', json={'service': 'spotify', 'label': 'Acct B', + 'payload': {'client_id': 'b', 'client_secret': 's'}}).get_json()['id'] + + # Switch to a non-admin session — it can still READ options + SELECT. + with client.session_transaction() as sess: + sess['profile_id'] = nonadmin_profile + + svc = client.get('/api/profiles/me/services').get_json()['services']['spotify'] + assert {o['id'] for o in svc['options']} == {a, b} + assert svc['selected_id'] is None + assert 'secret' not in str(svc) and 's' not in [o.get('client_secret') for o in svc['options'] if 'client_secret' in o] + + assert client.post('/api/profiles/me/services/select', + json={'service': 'spotify', 'credential_id': b}).get_json()['success'] + svc = client.get('/api/profiles/me/services').get_json()['services']['spotify'] + assert svc['selected_id'] == b + + # Clear → back to None + assert client.post('/api/profiles/me/services/select', + json={'service': 'spotify', 'credential_id': None}).get_json()['success'] + assert client.get('/api/profiles/me/services').get_json()['services']['spotify']['selected_id'] is None + + +def test_select_rejects_wrong_service_or_missing_set(client): + sp = client.post('/api/credentials', json={'service': 'spotify', 'label': 'X', + 'payload': {'client_id': 'a', 'client_secret': 's'}}).get_json()['id'] + # Selecting a spotify set under 'tidal' must be rejected. + assert client.post('/api/profiles/me/services/select', + json={'service': 'tidal', 'credential_id': sp}).status_code == 400 + # Nonexistent id rejected. + assert client.post('/api/profiles/me/services/select', + json={'service': 'spotify', 'credential_id': 999999}).status_code == 400 + # Unsupported service rejected. + assert client.post('/api/profiles/me/services/select', + json={'service': 'itunes', 'credential_id': None}).status_code == 400 + + +# ── Quick-switch: active source/server/download (admin=global, non-admin read-only) ── + +def test_active_sources_read_shape(client): + a = client.get('/api/profiles/me/active-sources').get_json() + assert a['success'] and a['editable'] is True # default session = admin + assert a['metadata']['active'] and len(a['metadata']['options']) == 6 + assert len(a['server']['options']) == 4 + assert 'mode' in a['download'] and isinstance(a['download']['hybrid_order'], list) + + +def test_admin_sets_global_active_sources(client): + assert client.post('/api/profiles/active-sources', json={'metadata_source': 'itunes'}).get_json()['success'] + assert client.get('/api/profiles/me/active-sources').get_json()['metadata']['active'] == 'itunes' + # hybrid + order round-trips + client.post('/api/profiles/active-sources', json={'download_mode': 'hybrid', 'hybrid_order': ['hifi', 'soulseek']}) + dl = client.get('/api/profiles/me/active-sources').get_json()['download'] + assert dl['mode'] == 'hybrid' and dl['hybrid_order'] == ['hifi', 'soulseek'] + + +def test_active_sources_rejects_bad_values(client): + assert client.post('/api/profiles/active-sources', json={'metadata_source': 'nope'}).status_code == 400 + assert client.post('/api/profiles/active-sources', json={'media_server': 'nope'}).status_code == 400 + assert client.post('/api/profiles/active-sources', json={'download_mode': 'nope'}).status_code == 400 + + +def test_active_sources_nonadmin_readonly_and_blocked(client, nonadmin_profile): + with client.session_transaction() as sess: + sess['profile_id'] = nonadmin_profile + assert client.get('/api/profiles/me/active-sources').get_json()['editable'] is False + assert client.post('/api/profiles/active-sources', json={'metadata_source': 'deezer'}).status_code == 403 + + +def test_spotify_free_composite_roundtrips_like_settings(client): + # "Spotify (no auth)" is stored as fallback_source=spotify + spotify_free=true + # (the same composite the Settings page uses) — the modal must report it as + # active='spotify_free', not raw 'spotify'. + from config.settings import config_manager + assert client.post('/api/profiles/active-sources', json={'metadata_source': 'spotify_free'}).get_json()['success'] + assert config_manager.get('metadata.fallback_source') == 'spotify' + assert config_manager.get('metadata.spotify_free') is True + assert client.get('/api/profiles/me/active-sources').get_json()['metadata']['active'] == 'spotify_free' + # Switching to plain spotify clears the flag. + client.post('/api/profiles/active-sources', json={'metadata_source': 'spotify'}) + assert config_manager.get('metadata.spotify_free') is False + assert client.get('/api/profiles/me/active-sources').get_json()['metadata']['active'] == 'spotify' + + +# ── My Accounts: per-profile connection status (Spotify) ────────────────────── + +def test_connections_status_unconnected(client, nonadmin_profile): + with client.session_transaction() as sess: + sess['profile_id'] = nonadmin_profile + body = client.get('/api/profiles/me/connections').get_json() + assert body['success'] and body['is_admin'] is False + assert body['connections']['spotify']['connected'] is False + + +def test_admin_connections_marks_admin(client): + body = client.get('/api/profiles/me/connections').get_json() + assert body['is_admin'] is True + + +def test_disconnect_admin_spotify_rejected(client): + # Admin's Spotify is the app account (Settings) — not disconnectable here. + assert client.post('/api/profiles/me/connections/spotify/disconnect').status_code == 400 + + +# ── Tidal: per-profile connect status + the token-save-redirect safety ──────── + +def test_tidal_connection_status_and_disconnect(client, nonadmin_profile): + db = web_server.get_database() + with client.session_transaction() as sess: + sess['profile_id'] = nonadmin_profile + # unconnected + assert client.get('/api/profiles/me/connections').get_json()['connections']['tidal']['connected'] is False + # seed tokens → connected + db.set_profile_tidal_tokens(nonadmin_profile, 'acc-tok', 'ref-tok') + assert client.get('/api/profiles/me/connections').get_json()['connections']['tidal']['connected'] is True + # disconnect → cleared + assert client.post('/api/profiles/me/connections/tidal/disconnect').get_json()['success'] + assert db.get_profile_tidal(nonadmin_profile) == {} + assert client.get('/api/profiles/me/connections').get_json()['connections']['tidal']['connected'] is False + + +def test_disconnect_unsupported_service_400(client, nonadmin_profile): + with client.session_transaction() as sess: + sess['profile_id'] = nonadmin_profile + assert client.post('/api/profiles/me/connections/deezer/disconnect').status_code == 400 + + +def test_tidal_token_refresh_redirects_to_profile_not_global(client, nonadmin_profile): + # THE safety guarantee: a per-profile Tidal client's token save must write to + # the PROFILE, never the global tidal_tokens slot the app runs on. + from config.settings import config_manager + db = web_server.get_database() + config_manager.set('tidal_tokens', {'access_token': 'ADMIN-ACC', 'refresh_token': 'ADMIN-REF'}) + db.set_profile_tidal_tokens(nonadmin_profile, 'p-acc', 'p-ref') + web_server.clear_profile_tidal_client(nonadmin_profile) + + c = web_server.get_tidal_client_for_profile(nonadmin_profile) + assert c is not web_server.tidal_client # a dedicated per-profile client + # simulate a refresh writing new tokens + c.access_token = 'p-acc-NEW' + c.refresh_token = 'p-ref-NEW' + c._save_tokens() + + assert db.get_profile_tidal(nonadmin_profile) == {'access_token': 'p-acc-NEW', 'refresh_token': 'p-ref-NEW'} + # global slot untouched + assert config_manager.get('tidal_tokens') == {'access_token': 'ADMIN-ACC', 'refresh_token': 'ADMIN-REF'} + + +def test_tidal_admin_and_unconnected_use_global_client(client): + assert web_server.get_tidal_client_for_profile(1) is web_server.tidal_client + assert web_server.get_tidal_client_for_profile(None) is web_server.tidal_client + assert web_server.get_tidal_client_for_profile(987654) is web_server.tidal_client + + +# ── ListenBrainz: per-profile connect status + disconnect (token-paste) ─────── + +def test_listenbrainz_connection_status_and_disconnect(client, nonadmin_profile): + db = web_server.get_database() + with client.session_transaction() as sess: + sess['profile_id'] = nonadmin_profile + # unconnected + conns = client.get('/api/profiles/me/connections').get_json()['connections'] + assert 'listenbrainz' in conns and conns['listenbrainz']['connected'] is False + # seed a token directly (POST validates against the live API; this tests the + # status + disconnect wiring without a network call) + db.set_profile_listenbrainz(nonadmin_profile, 'lb-token', '', 'lbuser') + conns = client.get('/api/profiles/me/connections').get_json()['connections'] + assert conns['listenbrainz']['connected'] is True + assert conns['listenbrainz']['account'] == 'lbuser' + # disconnect via the generic endpoint + assert client.post('/api/profiles/me/connections/listenbrainz/disconnect').get_json()['success'] + assert client.get('/api/profiles/me/connections').get_json()['connections']['listenbrainz']['connected'] is False + + +# ── Background profile context drives get_current_profile_id() (part 1) ──────── + +def test_background_profile_override_when_no_request(): + # Outside a web request, get_current_profile_id() honours the engine's + # background override; admin (default) and cleared state stay profile 1. + from core.profile_context import set_background_profile, reset_background_profile + assert web_server.get_current_profile_id() == 1 # no override → admin + tok = set_background_profile(7) + try: + assert web_server.get_current_profile_id() == 7 # acts as the owner + finally: + reset_background_profile(tok) + assert web_server.get_current_profile_id() == 1 # reset → admin + + +def test_real_session_still_wins_over_background(client, nonadmin_profile): + # A genuine request's session profile must override any background context. + from core.profile_context import set_background_profile, reset_background_profile + with client.session_transaction() as sess: + sess['profile_id'] = nonadmin_profile + tok = set_background_profile(999) # a bogus background override + try: + # the request resolves to the SESSION profile, not the background one + body = client.get('/api/profiles/me/connections').get_json() + assert body['is_admin'] is False # it's the non-admin session, not 999/admin + finally: + reset_background_profile(tok) + + +# ── Part 2: the playlist SOURCE adapters read per-profile (sync handlers) ────── +# bootstrap now passes get_*_client_for_profile as the source adapters' +# client_getter; these prove that composition resolves per the current profile +# context and stays on the global client for admin (the existing pipelines). + +def test_spotify_source_adapter_resolves_per_profile(monkeypatch): + from core.playlists.sources.spotify import SpotifyPlaylistSource + from core.metadata import registry + # The real global Spotify client isn't a stable singleton across the suite, + # so pin it to a sentinel for an order-independent identity check. + sentinel = object() + monkeypatch.setattr(registry, 'get_spotify_client', lambda *a, **k: sentinel) + registry.register_profile_spotify_credentials_provider(lambda pid: None) + src = SpotifyPlaylistSource(web_server.get_spotify_client_for_profile) + # admin / no override -> the global resolver (the sentinel) + assert src._client() is sentinel + # unconnected background owner override -> safe global fallback, re-resolved per call + from core.profile_context import set_background_profile, reset_background_profile + tok = set_background_profile(424242) + try: + assert src._client() is sentinel + finally: + reset_background_profile(tok) + + +def test_tidal_source_adapter_resolves_per_profile(): + from core.playlists.sources.tidal import TidalPlaylistSource + src = TidalPlaylistSource(web_server.get_tidal_client_for_profile) + assert src._client() is web_server.tidal_client # admin -> global, unchanged + + +def test_real_app_not_in_reverse_proxy_mode_by_default(): + # Direct/LAN installs (no security.trust_reverse_proxy set) must not get + # ProxyFix or a forced-Secure cookie — proves zero impact for normal users. + from werkzeug.middleware.proxy_fix import ProxyFix + assert not isinstance(web_server.app.wsgi_app, ProxyFix) + assert web_server.app.config.get('SESSION_COOKIE_SECURE') in (None, False) + assert web_server.app.config.get('SESSION_COOKIE_SAMESITE') is None + + +def test_verify_launch_pin_rate_limited_after_flood(client): + # A flood of WRONG PINs from one IP gets 429; cleaned up so neither the lock + # nor the temp PIN leaks to other tests (the limiter is a process singleton). + from werkzeug.security import generate_password_hash + db = web_server.get_database() + with db._get_connection() as conn: # admin needs a PIN so wrong ones actually fail + conn.execute("UPDATE profiles SET pin_hash = ? WHERE id = 1", + (generate_password_hash('1234', method='pbkdf2:sha256'),)) + conn.commit() + web_server._launch_pin_limiter.record_success('127.0.0.1') # clean slate + try: + for _ in range(10): + assert client.post('/api/profiles/verify-launch-pin', + json={'pin': 'definitely-wrong'}).status_code == 401 + r = client.post('/api/profiles/verify-launch-pin', json={'pin': 'definitely-wrong'}) + assert r.status_code == 429 + assert 'Retry-After' in r.headers + finally: + web_server._launch_pin_limiter.record_success('127.0.0.1') + with db._get_connection() as conn: + conn.execute("UPDATE profiles SET pin_hash = NULL WHERE id = 1") + conn.commit() + + +def test_auth_proxy_header_satisfies_launch_lock(client, monkeypatch): + # Lock on + Remote-User trusted → a request with the header passes the gate. + real_get = web_server.config_manager.get + def fake_get(key, default=None): + if key == 'security.require_pin_on_launch': + return True + if key == 'security.auth_proxy_header': + return 'Remote-User' + return real_get(key, default) + monkeypatch.setattr(web_server.config_manager, 'get', fake_get) + + assert client.get('/api/profiles/me/connections').status_code == 401 # no header → locked + assert client.get('/api/profiles/me/connections', + headers={'Remote-User': 'alice'}).status_code == 200 # trusted → in + + +def test_spoofed_auth_proxy_header_ignored_when_feature_off(client, monkeypatch): + # THE safety pin: feature OFF (default) → a client-sent Remote-User must NOT + # bypass the lock. Only an operator who explicitly configured it gets the trust. + real_get = web_server.config_manager.get + def fake_get(key, default=None): + if key == 'security.require_pin_on_launch': + return True + if key == 'security.auth_proxy_header': + return '' # OFF (default) + return real_get(key, default) + monkeypatch.setattr(web_server.config_manager, 'get', fake_get) + + assert client.get('/api/profiles/me/connections', + headers={'Remote-User': 'admin'}).status_code == 401 # spoof ignored → still locked diff --git a/tests/test_issue_837_find_add_preserves_playlist.py b/tests/test_issue_837_find_add_preserves_playlist.py new file mode 100644 index 00000000..8ee5a2d2 --- /dev/null +++ b/tests/test_issue_837_find_add_preserves_playlist.py @@ -0,0 +1,94 @@ +"""#837 — manual Find & Add must NOT recreate the Jellyfin/Emby playlist. + +Reporter (carlosjfcasero, Emby/Jellyfin): automations + auto-sync respect the +'append' sync mode and preserve the playlist's description/image, but manually +matching a missing track ("Find & add") recreated the whole playlist and wiped +them. Root cause: the add-track endpoint's Jellyfin branch called +`update_playlist(full list)` (delete + recreate) instead of the in-place +`append_to_playlist`. These pin that the endpoint now appends in place. + +(Emby routes through the 'jellyfin' branch — no separate emby branch exists.) +""" + +from __future__ import annotations + +import os +import tempfile +from types import SimpleNamespace + +import pytest + +_TMP = tempfile.mkdtemp(prefix='soulsync-testdb-837-') +os.environ['DATABASE_PATH'] = os.path.join(_TMP, 'i837.db') +os.environ['SOULSYNC_TEST_DB_READY'] = '1' + +web_server = pytest.importorskip('web_server') + +_GUID = 'aaaaaaaa-bbbb-cccc-dddd-000000000001' + + +class _FakeJellyfin: + def __init__(self, existing=None): + self.existing = existing or [] + self.append_calls = [] + self.update_calls = [] + + def get_playlist_tracks(self, pid): + return [SimpleNamespace(ratingKey=str(r)) for r in self.existing] + + def append_to_playlist(self, name, tracks): + self.append_calls.append((name, [getattr(t, 'id', None) for t in tracks])) + return True + + def update_playlist(self, name, tracks): # the destructive recreate path + self.update_calls.append((name, tracks)) + return True + + +class _FakeEngine: + def __init__(self, jf): + self._jf = jf + + def client(self, name): + return self._jf if name == 'jellyfin' else None + + +@pytest.fixture +def client(): + return web_server.app.test_client() + + +def _wire(monkeypatch, jf): + monkeypatch.setattr(web_server, 'media_server_engine', _FakeEngine(jf)) + monkeypatch.setattr(web_server.config_manager, 'get_active_media_server', lambda: 'jellyfin') + # the durable source->server match write touches the DB; not under test here + monkeypatch.setattr(web_server, '_persist_find_and_add_match', lambda *a, **k: None) + + +def test_find_and_add_appends_in_place_not_recreate(client, monkeypatch): + jf = _FakeJellyfin(existing=[]) # the missing track isn't on the server yet + _wire(monkeypatch, jf) + + resp = client.post('/api/server/playlist/PL1/add-track', + json={'track_id': _GUID, 'playlist_name': 'Disney'}) + body = resp.get_json() + + assert body['success'] and body['message'] == 'Track added' + assert len(jf.append_calls) == 1, 'should append in place' + assert jf.update_calls == [], 'must NOT recreate the playlist (#837)' + # append_to_playlist reads `.id` off the track — the endpoint must set it + assert jf.append_calls[0][1] == [_GUID] + + +def test_find_and_add_link_to_existing_track_touches_nothing(client, monkeypatch): + # Matching a source to a track already in the playlist is a LINK, not an add. + jf = _FakeJellyfin(existing=[_GUID]) + _wire(monkeypatch, jf) + + resp = client.post('/api/server/playlist/PL1/add-track', + json={'track_id': _GUID, 'playlist_name': 'Disney', + 'source_track_id': 'spotify-xyz'}) + body = resp.get_json() + + assert body['success'] and body['message'] == 'Track linked' + assert jf.append_calls == [] and jf.update_calls == [] diff --git a/tests/test_launch_lock_gate.py b/tests/test_launch_lock_gate.py index 1e897815..9dc062c6 100644 --- a/tests/test_launch_lock_gate.py +++ b/tests/test_launch_lock_gate.py @@ -77,6 +77,12 @@ def test_locked_allows_unlock_flow(): assert L('/api/profiles/logout', 'POST') is False +def test_locked_allows_setup_status(): + # #842: the first-run check runs before the PIN screen. Blocking it made the + # frontend think setup was incomplete and relaunch the wizard every visit. + assert L('/api/setup/status', 'GET') is False + + def test_locked_allows_keyauthed_public_api(): # The public REST API carries its own @require_api_key, so a launch-locked # UI must not break a legitimate headless key holder. diff --git a/tests/test_login_endpoints.py b/tests/test_login_endpoints.py new file mode 100644 index 00000000..390b8d3b --- /dev/null +++ b/tests/test_login_endpoints.py @@ -0,0 +1,165 @@ +"""Username/password login endpoints + gate (opt-in login mode).""" + +from __future__ import annotations + +import os +import tempfile + +import pytest + +_TMP = tempfile.mkdtemp(prefix='soulsync-testdb-login-') +os.environ['DATABASE_PATH'] = os.path.join(_TMP, 'l.db') +os.environ['SOULSYNC_TEST_DB_READY'] = '1' + +web_server = pytest.importorskip('web_server') + + +@pytest.fixture +def client(): + return web_server.app.test_client() + + +def _enable_login(monkeypatch): + real_get = web_server.config_manager.get + monkeypatch.setattr(web_server.config_manager, 'get', + lambda k, d=None: True if k == 'security.require_login' else real_get(k, d)) + web_server._login_limiter.record_success('127.0.0.1') # clean slate + + +_GATED = '/api/profiles/me/connections' # a normal, non-allowlisted endpoint + + +def test_login_gate_blocks_then_authenticated_access(client, monkeypatch): + db = web_server.get_database() + pid = db.create_profile(name='LoginUser') + db.set_profile_password(pid, 'secretpw') + _enable_login(monkeypatch) + + assert client.get(_GATED).status_code == 401 # not logged in → blocked + + r = client.post('/api/auth/login', json={'username': 'LoginUser', 'password': 'secretpw'}) + assert r.status_code == 200 and r.get_json()['success'] is True + + assert client.get(_GATED).status_code == 200 # authenticated → in + + assert client.post('/api/auth/logout').get_json()['success'] is True + assert client.get(_GATED).status_code == 401 # logged out → blocked again + + +def test_login_is_case_insensitive_on_username(client, monkeypatch): + db = web_server.get_database() + db.set_profile_password(db.create_profile(name='CaseUser'), 'pw') + _enable_login(monkeypatch) + assert client.post('/api/auth/login', json={'username': 'caseuser', 'password': 'pw'}).status_code == 200 + + +def test_wrong_password_401_generic(client, monkeypatch): + db = web_server.get_database() + db.set_profile_password(db.create_profile(name='WrongPwUser'), 'right') + _enable_login(monkeypatch) + r = client.post('/api/auth/login', json={'username': 'WrongPwUser', 'password': 'nope'}) + assert r.status_code == 401 + assert 'username or password' in r.get_json()['error'].lower() # generic — no name-leak + + +def test_passwordless_profile_cannot_login(client, monkeypatch): + db = web_server.get_database() + db.create_profile(name='NoPwUser') # no password set + _enable_login(monkeypatch) + assert client.post('/api/auth/login', json={'username': 'NoPwUser', 'password': 'x'}).status_code == 401 + + +def test_unknown_user_401(client, monkeypatch): + _enable_login(monkeypatch) + assert client.post('/api/auth/login', json={'username': 'ghost', 'password': 'x'}).status_code == 401 + + +def test_cannot_enable_login_without_admin_password(client): + # admin (1) has no password → enabling login mode is refused (anti-lockout) + web_server.get_database().set_profile_password(1, '') + r = client.post('/api/settings', json={'security': {'require_login': True}}) + assert r.status_code == 400 + assert 'password' in r.get_json().get('error', '').lower() + + +def test_set_password_endpoint(client): + db = web_server.get_database() + pid = db.create_profile(name='SetPwTest') + # admin (default session) can set any profile's login password + r = client.post(f'/api/profiles/{pid}/set-password', json={'password': 'newpw123'}) + body = r.get_json() + assert body['success'] is True and body['has_password'] is True + assert db.verify_profile_password(pid, 'newpw123') is True + # clearing it + assert client.post(f'/api/profiles/{pid}/set-password', json={'password': ''}).get_json()['has_password'] is False + + +def test_profiles_current_signals_login_required(client, monkeypatch): + _enable_login(monkeypatch) + body = client.get('/api/profiles/current').get_json() + assert body.get('login_required') is True # frontend uses this to show the sign-in screen + + +def test_pin_gate_unaffected_when_login_off(client, monkeypatch): + # THE guarantee: with login mode OFF (default) and the launch PIN ON, the PIN + # gate must STILL enforce — the login feature must not weaken or bypass it. + real_get = web_server.config_manager.get + def fake_get(key, default=None): + if key == 'security.require_login': + return False # login OFF (default) + if key == 'security.require_pin_on_launch': + return True # PIN ON + return real_get(key, default) + monkeypatch.setattr(web_server.config_manager, 'get', fake_get) + + # Unverified session, PIN required → the launch-PIN gate still 401s. + assert client.get('/api/profiles/me/connections').status_code == 401 + # And /api/profiles/current reports the PIN screen, NOT login. + body = client.get('/api/profiles/current').get_json() + assert body.get('login_required') is not True + + +def test_everything_normal_when_both_off(client, monkeypatch): + # Default install: login OFF + PIN OFF → no gate at all (today's behavior). + real_get = web_server.config_manager.get + monkeypatch.setattr(web_server.config_manager, 'get', + lambda k, d=None: False if k in ('security.require_login', 'security.require_pin_on_launch') else real_get(k, d)) + assert client.get('/api/profiles/me/connections').status_code == 200 # reachable, unguarded + + +def test_recovery_flow_resets_password(client, monkeypatch): + db = web_server.get_database() + pid = db.create_profile(name='RecoverMe') + db.set_profile_password(pid, 'oldpassword') + db.set_profile_recovery(pid, 'First pet?', 'Rex') + _enable_login(monkeypatch) + + # forgot-password flow is reachable pre-auth + q = client.get('/api/auth/recovery-question?username=RecoverMe').get_json() + assert q['success'] and q['question'] == 'First pet?' + + # wrong answer → 401, password unchanged + bad = client.post('/api/auth/recovery-reset', + json={'username': 'RecoverMe', 'answer': 'Fido', 'new_password': 'newpass1'}) + assert bad.status_code == 401 + assert db.verify_profile_password(pid, 'oldpassword') is True + + # correct answer → password reset + authenticated + ok = client.post('/api/auth/recovery-reset', + json={'username': 'RecoverMe', 'answer': 'rex', 'new_password': 'brandnew1'}) + assert ok.status_code == 200 and ok.get_json()['success'] is True + assert db.verify_profile_password(pid, 'brandnew1') is True + assert db.verify_profile_password(pid, 'oldpassword') is False + + +def test_recovery_question_404_for_unknown(client, monkeypatch): + _enable_login(monkeypatch) + assert client.get('/api/auth/recovery-question?username=ghost').status_code == 404 + + +def test_set_recovery_endpoint(client): + db = web_server.get_database() + pid = db.create_profile(name='SetRec') + r = client.post(f'/api/profiles/{pid}/set-recovery', json={'question': 'Q?', 'answer': 'A'}) + assert r.get_json()['has_recovery'] is True + assert db.verify_profile_recovery_answer(pid, 'a') is True diff --git a/tests/test_login_gate.py b/tests/test_login_gate.py new file mode 100644 index 00000000..51e9699e --- /dev/null +++ b/tests/test_login_gate.py @@ -0,0 +1,36 @@ +"""Pure login-gate decision (opt-in username/password mode).""" + +from __future__ import annotations + +from core.security.login_gate import login_request_is_blocked as blocked + + +def test_off_never_blocks(): + assert blocked('/api/anything', 'GET', require_login=False, authenticated=False) is False + + +def test_authenticated_never_blocked(): + assert blocked('/api/anything', 'GET', require_login=True, authenticated=True) is False + + +def test_unauthenticated_blocked_on_normal_api(): + assert blocked('/api/library', 'GET', require_login=True, authenticated=False) is True + + +def test_login_flow_and_shell_allowed_unauthenticated(): + for p in ('/', '/static/app.js', '/favicon.ico'): + assert blocked(p, 'GET', require_login=True, authenticated=False) is False + assert blocked('/api/auth/login', 'POST', require_login=True, authenticated=False) is False + assert blocked('/api/auth/logout', 'POST', require_login=True, authenticated=False) is False + assert blocked('/api/profiles/current', 'GET', require_login=True, authenticated=False) is False + assert blocked('/api/setup/status', 'GET', require_login=True, authenticated=False) is False + + +def test_profile_list_NOT_exposed_pre_auth(): + # login mode = type your name, don't pick from an exposed roster + assert blocked('/api/profiles', 'GET', require_login=True, authenticated=False) is True + + +def test_key_authed_public_api_allowed(): + assert blocked('/api/v1/search', 'GET', require_login=True, authenticated=False) is False + assert blocked('/api/v1/api-keys-internal', 'GET', require_login=True, authenticated=False) is True diff --git a/tests/test_pin_rate_limit.py b/tests/test_pin_rate_limit.py new file mode 100644 index 00000000..54ee6656 --- /dev/null +++ b/tests/test_pin_rate_limit.py @@ -0,0 +1,50 @@ +"""Launch-PIN brute-force limiter: only a flood of WRONG PINs trips it, a correct +entry clears it instantly, and it self-heals as failures age out — so normal use +is never affected.""" + +from __future__ import annotations + +from core.security.rate_limit import AttemptLimiter + + +def test_under_threshold_is_never_locked(): + lim = AttemptLimiter(max_attempts=10, window_seconds=300) + for i in range(9): # 9 < 10 → never locked + lim.record_failure('1.2.3.4', now=1000 + i) + locked, _ = lim.is_locked('1.2.3.4', now=1010) + assert locked is False + + +def test_flood_trips_the_lock_with_retry_after(): + lim = AttemptLimiter(max_attempts=10, window_seconds=300) + for i in range(10): + lim.record_failure('1.2.3.4', now=1000 + i) + locked, retry_after = lim.is_locked('1.2.3.4', now=1010) + assert locked is True + assert retry_after > 0 + + +def test_success_clears_immediately(): + lim = AttemptLimiter(max_attempts=10, window_seconds=300) + for i in range(10): + lim.record_failure('1.2.3.4', now=1000 + i) + assert lim.is_locked('1.2.3.4', now=1010)[0] is True + lim.record_success('1.2.3.4') # correct PIN + assert lim.is_locked('1.2.3.4', now=1011)[0] is False + + +def test_failures_age_out_self_heal(): + lim = AttemptLimiter(max_attempts=10, window_seconds=300) + for i in range(10): + lim.record_failure('1.2.3.4', now=1000 + i) + assert lim.is_locked('1.2.3.4', now=1010)[0] is True + # well past the window → all failures expired → unlocked + assert lim.is_locked('1.2.3.4', now=2000)[0] is False + + +def test_per_ip_isolation(): + lim = AttemptLimiter(max_attempts=10, window_seconds=300) + for i in range(10): + lim.record_failure('attacker', now=1000 + i) + assert lim.is_locked('attacker', now=1010)[0] is True + assert lim.is_locked('legit-user', now=1010)[0] is False # not punished for someone else diff --git a/tests/test_playlist_sources_adapters.py b/tests/test_playlist_sources_adapters.py index 18ef4d3b..38e7b518 100644 --- a/tests/test_playlist_sources_adapters.py +++ b/tests/test_playlist_sources_adapters.py @@ -915,3 +915,34 @@ def test_mirror_dict_spotify_public_emits_spotify_hint(): extra = _json.loads(d["extra_data"]) assert extra["discovered"] is False assert extra["spotify_hint"]["id"] == "sptrk" + + +def test_spotify_public_adapter_paginates_past_100(monkeypatch): + """#838: auto-sync truncated >100-track playlists to 100 because the adapter + called the embed scraper (≤100) directly instead of the full-fetch wrapper. + With the wrapper, a playlist whose full fetch returns 150 tracks keeps all 150.""" + src = SpotifyPublicPlaylistSource() + + monkeypatch.setattr( + "core.spotify_public_scraper.parse_spotify_url", + lambda url: {"type": "playlist", "id": "big"}, + ) + full = { + "id": "big", "type": "playlist", "name": "Big PL", "subtitle": "owner", + "url": "https://open.spotify.com/playlist/big", "url_hash": "bighash", + "tracks": [ + {"id": f"t{i}", "name": f"Song {i}", "artists": [{"name": "A"}], + "duration_ms": 1000, "is_explicit": False, "track_number": i + 1} + for i in range(150) + ], + } + # The full paginated path succeeds → wrapper returns all 150 (no embed cap). + monkeypatch.setattr( + "core.spotify_public_api.fetch_public_playlist_full", + lambda spotify_id: full, + ) + + detail = src.get_playlist("https://open.spotify.com/playlist/big") + assert detail is not None + assert len(detail.tracks) == 150, "pre-#838 the adapter capped this at 100" + assert detail.meta.track_count == 150 diff --git a/tests/test_profile_context.py b/tests/test_profile_context.py new file mode 100644 index 00000000..29ef17d4 --- /dev/null +++ b/tests/test_profile_context.py @@ -0,0 +1,31 @@ +"""Background profile context (per-profile automations). + +Lets background work (the automation engine) declare which profile it's acting +for, so get_current_profile_id() resolves to the automation's OWNER instead of +admin when there's no web request. A real request must still win; admin/system +(profile 1) and no-override must stay admin. +""" + +from __future__ import annotations + +from core.profile_context import ( + set_background_profile, reset_background_profile, get_background_profile, +) + + +def test_set_reset_roundtrip(): + assert get_background_profile() is None + tok = set_background_profile(5) + assert get_background_profile() == 5 + reset_background_profile(tok) + assert get_background_profile() is None + + +def test_nested_set_reset(): + t1 = set_background_profile(3) + t2 = set_background_profile(1) # e.g. an admin/system sub-run + assert get_background_profile() == 1 + reset_background_profile(t2) + assert get_background_profile() == 3 # back to the outer profile + reset_background_profile(t1) + assert get_background_profile() is None # fully cleared (no leak) diff --git a/tests/test_profile_password.py b/tests/test_profile_password.py new file mode 100644 index 00000000..798e6d91 --- /dev/null +++ b/tests/test_profile_password.py @@ -0,0 +1,67 @@ +"""Per-profile LOGIN password (opt-in username/password mode) — DB layer. + +Separate from the quick-switch PIN: a profile with no password set is NOT +loginable (you can't authenticate to an account with no credential), unlike the +PIN where 'no PIN = always valid'. +""" + +from __future__ import annotations + +import pytest + +from database.music_database import MusicDatabase + + +@pytest.fixture +def db(tmp_path): + return MusicDatabase(str(tmp_path / "music.db")) + + +def test_migration_adds_password_hash_column(db): + with db._get_connection() as conn: + cols = [r[1] for r in conn.execute("PRAGMA table_info(profiles)").fetchall()] + assert 'password_hash' in cols + + +def test_set_and_verify_password(db): + pid = db.create_profile(name='Brock') + assert db.profile_has_password(pid) is False # none yet + assert db.verify_profile_password(pid, 'hunter2') is False # no password → not loginable + + db.set_profile_password(pid, 'hunter2') + assert db.profile_has_password(pid) is True + assert db.verify_profile_password(pid, 'hunter2') is True + assert db.verify_profile_password(pid, 'wrong') is False + + +def test_no_password_is_never_loginable(db): + # Unlike the PIN (no PIN = always valid), a passwordless account can't log in. + pid = db.create_profile(name='NoPass') + assert db.verify_profile_password(pid, '') is False + assert db.verify_profile_password(pid, 'anything') is False + + +def test_clearing_password(db): + pid = db.create_profile(name='Temp') + db.set_profile_password(pid, 'pw') + assert db.profile_has_password(pid) is True + db.set_profile_password(pid, '') # clear + assert db.profile_has_password(pid) is False + assert db.verify_profile_password(pid, 'pw') is False + + +def test_get_profile_by_name_case_insensitive(db): + pid = db.create_profile(name='Daughter') + assert db.get_profile_by_name('daughter')['id'] == pid + assert db.get_profile_by_name('DAUGHTER')['id'] == pid + assert db.get_profile_by_name('nobody') is None + + +def test_password_is_independent_of_pin(db): + # Setting a password must not touch the PIN and vice-versa (separate creds). + from werkzeug.security import generate_password_hash + pid = db.create_profile(name='Both', pin_hash=generate_password_hash('1234', method='pbkdf2:sha256')) + db.set_profile_password(pid, 'longpassword') + assert db.verify_profile_pin(pid, '1234') is True # PIN still works + assert db.verify_profile_password(pid, 'longpassword') is True # password works + assert db.verify_profile_password(pid, '1234') is False # PIN is NOT the password diff --git a/tests/test_profile_recovery.py b/tests/test_profile_recovery.py new file mode 100644 index 00000000..f730b74f --- /dev/null +++ b/tests/test_profile_recovery.py @@ -0,0 +1,60 @@ +"""Login-password recovery via security question + answer — DB layer.""" + +from __future__ import annotations + +import pytest + +from database.music_database import MusicDatabase + + +@pytest.fixture +def db(tmp_path): + return MusicDatabase(str(tmp_path / "music.db")) + + +def test_migration_adds_recovery_columns(db): + with db._get_connection() as conn: + cols = [r[1] for r in conn.execute("PRAGMA table_info(profiles)").fetchall()] + assert 'recovery_question' in cols and 'recovery_answer_hash' in cols + + +def test_set_get_verify(db): + pid = db.create_profile(name='RecUser') + assert db.profile_has_recovery(pid) is False + assert db.get_profile_recovery_question(pid) is None + + db.set_profile_recovery(pid, 'First pet?', 'Rex') + assert db.profile_has_recovery(pid) is True + assert db.get_profile_recovery_question(pid) == 'First pet?' + assert db.verify_profile_recovery_answer(pid, 'Rex') is True + assert db.verify_profile_recovery_answer(pid, 'Fido') is False + + +def test_answer_match_is_forgiving(db): + pid = db.create_profile(name='Forgiving') + db.set_profile_recovery(pid, 'City?', ' New York ') + assert db.verify_profile_recovery_answer(pid, 'new york') is True # case + spacing + assert db.verify_profile_recovery_answer(pid, 'NEW YORK') is True + + +def test_no_recovery_never_verifies(db): + pid = db.create_profile(name='NoRec') + assert db.verify_profile_recovery_answer(pid, '') is False + assert db.verify_profile_recovery_answer(pid, 'anything') is False + + +def test_clearing_recovery(db): + pid = db.create_profile(name='ClearRec') + db.set_profile_recovery(pid, 'Q?', 'A') + assert db.profile_has_recovery(pid) is True + db.set_profile_recovery(pid, '', '') + assert db.profile_has_recovery(pid) is False + assert db.get_profile_recovery_question(pid) is None + + +def test_answer_is_hashed_not_plaintext(db): + pid = db.create_profile(name='Hashed') + db.set_profile_recovery(pid, 'Q?', 'secretanswer') + with db._get_connection() as conn: + stored = conn.execute("SELECT recovery_answer_hash FROM profiles WHERE id = ?", (pid,)).fetchone()[0] + assert 'secretanswer' not in stored and stored.startswith('pbkdf2:') diff --git a/tests/test_profile_spotify_resolution.py b/tests/test_profile_spotify_resolution.py new file mode 100644 index 00000000..825e3a72 --- /dev/null +++ b/tests/test_profile_spotify_resolution.py @@ -0,0 +1,36 @@ +"""Per-profile Spotify client resolution falls back safely (shared-app model). + +The builder must return the GLOBAL client for admin (profile 1) and for any +non-admin profile that hasn't connected its own Spotify (no token cache, no own +app creds) — so background workers and existing users are unaffected. A +per-profile client only appears once that profile has its own app creds OR its +own .spotify_cache_profile_. + +We monkeypatch get_spotify_client to a sentinel so "fell back to global" is an +exact, order-independent identity check (the real global client isn't a stable +singleton across the suite). +""" + +from __future__ import annotations + +import os + +from core.metadata import registry + + +def test_admin_and_none_use_global_client(monkeypatch): + sentinel = object() + monkeypatch.setattr(registry, "get_spotify_client", lambda *a, **k: sentinel) + registry.register_profile_spotify_credentials_provider(lambda pid: None) + assert registry.get_spotify_client_for_profile(1) is sentinel + assert registry.get_spotify_client_for_profile(None) is sentinel + + +def test_unconnected_profile_falls_back_to_global(monkeypatch): + sentinel = object() + monkeypatch.setattr(registry, "get_spotify_client", lambda *a, **k: sentinel) + # No own app creds for this profile, and no token cache → must use global. + registry.register_profile_spotify_credentials_provider(lambda pid: None) + pid = 987654 + assert not os.path.exists(f"config/.spotify_cache_profile_{pid}") + assert registry.get_spotify_client_for_profile(pid) is sentinel diff --git a/tests/test_reverse_proxy_mode.py b/tests/test_reverse_proxy_mode.py new file mode 100644 index 00000000..c69abe24 --- /dev/null +++ b/tests/test_reverse_proxy_mode.py @@ -0,0 +1,81 @@ +"""Opt-in reverse-proxy mode must be a STRICT no-op when off (default), so a +direct/LAN install is byte-for-byte unchanged, and only harden when enabled.""" + +from __future__ import annotations + +from flask import Flask +from werkzeug.middleware.proxy_fix import ProxyFix + +from core.security.reverse_proxy import apply_reverse_proxy_mode, CONFIG_KEY + + +def _cfg(value): + """A config_manager.get-style callable returning `value` for the proxy key.""" + return lambda key, default=None: value if key == CONFIG_KEY else default + + +def test_off_by_default_is_a_strict_noop(): + app = Flask(__name__) + + enabled = apply_reverse_proxy_mode(app, _cfg(False)) # default/off + + assert enabled is False + assert not isinstance(app.wsgi_app, ProxyFix) # NOT wrapped + # Flask defaults untouched — cookie not forced Secure, no SameSite override + assert app.config.get('SESSION_COOKIE_SECURE') in (None, False) + assert app.config.get('SESSION_COOKIE_SAMESITE') is None + + +def test_missing_key_is_also_a_noop(): + app = Flask(__name__) + assert apply_reverse_proxy_mode(app, lambda key, default=None: default) is False + assert not isinstance(app.wsgi_app, ProxyFix) + + +def test_on_wraps_proxyfix_and_secures_cookie(): + app = Flask(__name__) + + enabled = apply_reverse_proxy_mode(app, _cfg(True)) + + assert enabled is True + assert isinstance(app.wsgi_app, ProxyFix) # forwarded headers trusted + assert app.config['SESSION_COOKIE_SECURE'] is True # cookie HTTPS-only + assert app.config['SESSION_COOKIE_SAMESITE'] == 'Lax' + + +def test_failure_falls_back_to_noop(): + # A config_get that raises must not break startup — treated as off. + app = Flask(__name__) + def boom(key, default=None): + raise RuntimeError('config exploded') + assert apply_reverse_proxy_mode(app, boom) is False + assert not isinstance(app.wsgi_app, ProxyFix) + + +def test_off_adds_no_security_headers(): + app = Flask(__name__) + apply_reverse_proxy_mode(app, _cfg(False)) + + @app.route('/ping') + def _ping(): + return 'ok' + + resp = app.test_client().get('/ping') + # direct/LAN install: none of the proxy-mode headers are added + assert 'X-Content-Type-Options' not in resp.headers + assert 'X-Frame-Options' not in resp.headers + assert 'Strict-Transport-Security' not in resp.headers + + +def test_on_adds_security_headers(): + app = Flask(__name__) + apply_reverse_proxy_mode(app, _cfg(True)) + + @app.route('/ping') + def _ping(): + return 'ok' + + resp = app.test_client().get('/ping') + assert resp.headers.get('X-Content-Type-Options') == 'nosniff' + assert resp.headers.get('X-Frame-Options') == 'SAMEORIGIN' + assert 'max-age=' in resp.headers.get('Strict-Transport-Security', '') diff --git a/tests/test_script_split_integrity.py b/tests/test_script_split_integrity.py index c299d14f..54c5adc5 100644 --- a/tests/test_script_split_integrity.py +++ b/tests/test_script_split_integrity.py @@ -53,7 +53,7 @@ SPLIT_MODULES = [ # Other JS files that exist in static/ but are NOT part of the split NON_SPLIT_JS = {"setup-wizard.js", "docs.js", "helper.js", "particles.js", "worker-orbs.js", "enrichment-manager.js", "origin-history.js", "blocklist.js", - "watchlist-history.js"} + "watchlist-history.js", "service-switch.js", "my-accounts.js"} # Pre-existing duplicate helper functions that lived in the original monolith. # In a plain + + diff --git a/webui/static/downloads.js b/webui/static/downloads.js index aeca1bd1..3faa2cc6 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -3060,8 +3060,19 @@ function _candidatesFmtDur(ms) { // rows (different click binding scope). ``showSourceBadge`` adds a small // per-row source pill — used in hybrid "All sources" mode where the user // otherwise can't tell which source a row came from. +// Display label for a candidate's filename. Encoded ``id||title`` sources +// (youtube/tidal/qobuz/hifi) carry the title after ``||`` — a '/' in that title +// is part of the name, NOT a path separator, so it must not be basename-split +// (issue #835: "YouSeeBIGGIRL/T:T" was showing as just "T:T"). Real file paths +// (Soulseek) keep the rightmost-segment basename. +function _ssShortFileLabel(filename) { + if (!filename) return '-'; + if (filename.includes('||')) return filename.split('||').slice(1).join('||'); + return filename.split(/[/\\]/).pop(); +} + function _renderCandidateRow(c, index, rowClass, showSourceBadge) { - const shortFile = c.filename ? c.filename.split(/[/\\]/).pop() : '-'; + const shortFile = _ssShortFileLabel(c.filename); const qBadge = c.quality ? `${c.quality.toUpperCase()}` : ''; diff --git a/webui/static/helper.js b/webui/static/helper.js index 98c9fece..b583e031 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3413,197 +3413,20 @@ function closeHelperSearch() { // projects that span multiple commits before shipping. Strip the flag at // release time and add a real `date:` line at the top of the version block. const WHATS_NEW = { - '2.6.9': [ - { date: 'June 9, 2026 — 2.6.9 release' }, - { title: 'Security: the admin launch PIN is now actually enforced (#832)', desc: 'the "require PIN on launch" lock used to be a client-side overlay only — the server never checked it, so removing the overlay (Safari\'s "Hide Distracting Items", browser devtools, or any non-browser client like curl) gave full access to every API. If you expose SoulSync publicly through a reverse proxy, that was wide open. There is now a real server-side gate: while the launch PIN is on, every request from an unverified session is rejected until the PIN is entered — data, settings, profile management, even websockets. A side door was closed too (the internal API-key endpoints were "no auth required", so a key could be minted to bypass the lock). The public REST API still works for valid API-key holders, and a deep link / refresh while locked now lands on the lock screen instead of raw JSON. Worth saying plainly: this makes the launch PIN do its job — for public exposure, keep your reverse proxy\'s own auth in front of it too.', page: 'settings' }, - { title: 'Security: your saved secrets stop being sent to the browser', desc: 'the settings API returned the full decrypted config to the browser — every API key, OAuth secret, Plex/Jellyfin token, and service password in cleartext. They are encrypted at rest, but that does nothing once the API hands the plaintext to the client (devtools, HAR captures, an XSS, a screen share). Configured secrets are now masked before they leave the server (shown as dots in the settings fields); saving an untouched form keeps the real value, editing replaces it, and clearing a field still removes it. No secret reaches the browser anymore.', page: 'settings' }, - { title: 'Delete now works on tracks with curly apostrophes (#833, the-hang-man)', desc: 'a track like "I\'m Upset" could delete its database row but leave the file behind. The library stored the title with a curly apostrophe (what Spotify/Apple metadata uses) while the file was written to disk with a straight one, so the delete compared the wrong characters and missed. File resolution now folds typographic look-alikes (curly vs straight quotes, en/em dashes, ellipsis) when matching the on-disk file — it never renames, just finds the file that\'s actually there. Fixes existing mismatched files with no re-import, and covers sidecar cleanup and dead-file checks too, not just delete.', page: 'library' }, - { title: 'Watchlist live scan — a full revamp with a per-run History (#831)', desc: 'the watchlist scan display was rebuilt. A bespoke live deck shows the artist being scanned with a large portrait, a real progress bar, found/added counters, and a live feed of exactly which tracks a scan found and added to your wishlist — with zero layout shift as data arrives. A new History button opens a modal of every past scan and the tracks each one added (not downloaded — added to the wishlist by the watchlist), persisted per run. The Download Origins view also groups its entries by action, and the whole page got the house-style chip buttons and a reskinned Global Settings modal.', page: 'watchlist' }, - { title: 'Spotify (no auth): renamed, default for enrichment, and fuller search', desc: '"Spotify Free" is now "Spotify (no auth)" — a clearer name for the credential-free metadata source. A new always-visible toggle in the Spotify settings lets the background enrichment worker use it (on by default); if you have Spotify auth, enrichment works either way, and the toggle overrides. The no-auth source also gained album search (via the artist\'s discography, closing a gap where it could only do tracks), and the daily-budget → free bridge now actually diverts to the free source when your real-API budget is spent instead of stalling.', page: 'settings' }, - { title: 'Owned tracks stop coming back to the wishlist (#825, carlosjfcasero)', desc: 'two fixes so tracks you already own stop reappearing as wishlist items. Manually adding an album to the wishlist now checks ownership and skips tracks you have. And the track matcher no longer reads a bracketed subtitle (e.g. a "(Bonus Track)" or remix qualifier) as a different song, so a track you own isn\'t treated as missing — version markers (live / remix / acoustic) are still respected and not collapsed.', page: 'watchlist' }, - { title: 'Playlist sync append: no more duplicates, and it honors your sync mode (#823)', desc: 'append mode no longer re-adds every track on each run (which turned playlists into N copies of themselves) — it now dedupes against what\'s already there. Automated syncs also honor the per-playlist sync mode you configured instead of always running "replace".', page: 'sync' }, - { title: 'Download Discography: explains skips + credits collaborations correctly (#830, Vicky)', desc: 'downloading an artist\'s discography no longer silently skips tracks with a flat "No new tracks". It now shows why each was skipped — already owned, by a different artist, or filtered out. And collaboration tracks credited as one combined string (e.g. "A, B & C") are matched correctly instead of being dropped as an artist mismatch.', page: 'library' }, - { title: 'Album downloads reuse an existing folder (#829)', desc: 'when an album already has a folder on disk, a new batch download drops into it instead of creating a split second folder — so an album stays in one place.', page: 'downloads' }, - { title: 'Dead File Cleaner stops flagging your whole library (#828)', desc: 'the Dead File detector no longer marks an entire library as dead when the files are simply unreachable (an unmounted or temporarily missing path). When an implausibly large share of tracks come back unresolvable, it now treats that as an environment problem and aborts with zero findings instead of proposing to delete everything.', page: 'dashboard' }, - { title: 'Settings stop flooding the log (#827)', desc: 'sitting on the Logs tab no longer triggers settings auto-saves that spam app.log with save lines — auto-save is suppressed while you\'re viewing logs.', page: 'settings' }, - { title: 'Wishlist album bundles no longer jam the download pool (#740, Sokhi)', desc: 'per-album wishlist bundles are serialized so they stop flooding the shared search/download workers and blocking everything behind them.', page: 'downloads' }, - { title: 'Multi-artist tags apply on Search → Download Now (Netti93)', desc: 'a track downloaded straight from Search now carries its real metadata source through, so your multi-artist tagging settings actually take effect instead of being lost on the way to the downloader.', page: 'search' }, - { title: 'Full release dates written to your tags (#824)', desc: 'the tag writer stores and writes full yyyy-mm-dd release dates end to end instead of downgrading them to just the year.', page: 'library' }, - { title: 'Watchlist stops re-flagging decimal-volume albums as duplicates (Sokhi)', desc: 'different decimal-volume editions of an album (e.g. "Vol. 4" vs "Vol. 4.5") are no longer treated as the same album, so the watchlist stops collapsing or skipping one of them.', page: 'watchlist' }, - ], - '2.6.8': [ - { date: 'June 7, 2026 — 2.6.8 release' }, - { title: 'Blocklist — never download an artist, album, or track again', desc: 'a proper artist / album / track blocklist, reachable from the Blocklist button on the Watchlist page. Add an entry by pasting a Spotify / iTunes / Deezer / MusicBrainz link or ID; SoulSync resolves it and, in the background, matches it across your other sources so a ban added by Spotify ID also blocks the same thing arriving via Deezer or Tidal. Bans are enforced everywhere it matters: the wishlist never re-adds a blocked item, the download queue (playlist sync / album / discography) skips them, and starting a manual download of a blocked artist asks for a "download anyway?" confirm first. Profile-scoped, and your old Discovery blacklist is migrated in automatically.', page: 'watchlist' }, - { title: 'Download retry overhaul — exhaust every candidate before giving up (#801)', desc: 'when a download fails AcoustID or integrity verification, SoulSync no longer just quarantines and stops. It now retries the next-best candidate, works through every query for a source from cache first (no needless re-searching), and when a whole source is exhausted it switches to the next source instead of failing the track. New opt-in knobs (collapsible tiles on the Downloads settings): retry-next-candidate-on-mismatch, a per-source exhaustive retry budget, and a last-resort "accept a repeated version mismatch" fallback for stubborn tracks. A race guard also stops stale duplicate completion calls from re-quarantining a file after you have already moved on.', page: 'downloads' }, - { title: 'Download Origins — see (and delete) exactly what your syncs pulled in', desc: 'a new Download Origins view (button on the Watchlist page) records every track SoulSync downloaded on your behalf and why — which watchlist artist or which playlist sync triggered it. Browse the full provenance list and delete any of it (file + library entry) in one place. This also powers the new Expired Download Cleaner below.', page: 'watchlist' }, - { title: 'Expired Download Cleaner — retention-based cleanup of synced downloads', desc: 'a new Library Maintenance job that cleans up downloads brought in by the watchlist / playlist sync once they pass a retention window you set per origin (off, or 1–4 weeks / 2 / 3 / 6 months). It is deliberately careful: it never proposes deleting something still in a playlist you actively mirror or an artist you still watch, or anything you have played more than once. Dry run is ON by default — it lists what it would remove as findings for you to review and delete — and you can flip it to fully automatic. Default off; only ever touches downloads tracked from this version forward, never your existing or manual library.', page: 'dashboard' }, - { title: 'Lyrics Filler — fill in missing .lrc lyrics across your library (Sokhi)', desc: 'a new Library Maintenance job that finds tracks with no lyrics and — checking LRClib first — only flags the ones lyrics actually exist for, so instrumentals are never surfaced. Review and apply to write a synced .lrc and embed the lyrics. The Library Re-tag tool also gains a Lyrics option, so it can fetch/refresh lyrics alongside tags and cover art in the same pass.', page: 'dashboard' }, - { title: 'Spotify daily Docker de-auth — fixed (wolf39us)', desc: 'Spotify auth tokens now live in the database instead of a file the container could lose, so your Spotify connection survives daily Docker restarts instead of silently logging out and stalling enrichment. (Tokens are also never shipped to the browser.)', page: 'settings' }, - { title: 'Release-date gate — unreleased tracks stop burning cycles (#705)', desc: 'tracks with a future release date no longer get stuck cycling through the wishlist or Fresh Tape trying to download something that does not exist yet. They are held until their release date arrives, then processed normally.', page: 'sync' }, - { title: 'YouTube works out of the box', desc: 'the Docker image now ships a JavaScript runtime and a nightly yt-dlp, so YouTube streams and music videos work immediately without manual setup or hitting yt-dlp signature breakage.', page: 'settings' }, - { title: 'Navidrome playback for un-mounted libraries (#809)', desc: 'Navidrome tracks now play even when the music library is not mounted on the SoulSync container\'s disk — it streams them through the Navidrome server\'s own API instead of failing. The crossfade preload path streams the same way, so gapless/crossfade works for those tracks too.', page: 'dashboard' }, - { title: 'Dashboard + download/discovery modals — a living visual pass', desc: 'a big visual upgrade. The download and discovery modals were revamped with dashboard-grade motion and live per-track row states (pills that animate only while a track is actually working). The dashboard worker orbs and rate monitor got a depth + life pass — orbital depth, telemetry comet tails and impact ripples, a sleep/wake cycle when idle, a daily-budget ring around the Spotify avatar, rate-limit bans that visibly count down, and VU-style peak-hold ticks — all moved to GPU/compositor-only so it is smoother, not heavier.', page: 'dashboard' }, - { title: 'Reorganize picks the right album edition (#767)', desc: 'reorganize / track-number repair no longer mislabels a single as track 2 of a 10-track deluxe. When the edition it is about to use clearly does not fit your actual files (e.g. one file vs a 10-track deluxe), it finds the correct edition and pins it. Well-fitting albums are untouched.', page: 'library' }, - { title: 'Cover art at native resolution + correct editions (#806)', desc: 'MusicBrainz cover art is now fetched at native resolution (Cover Art Archive /front) instead of a downscaled thumbnail, and a numeric edition difference is treated as a different release — so "Vol.4" stops getting served "Vol.4.5"\'s cover. Includes an archive.org outage cooldown so a CAA outage does not stall imports.', page: 'library' }, - { title: 'Cover Art Filler: fix album OR artist art independently + clearer read-only errors', desc: 'the Cover Art Filler can now repair album art or artist art on their own instead of always doing both (Pache711). And when your music folder is genuinely read-only, it now says so accurately — read-only detection comes from an actual write attempt rather than mount flags (which union/NFS/mergerfs setups misreport), so a writable library is never falsely blocked, and the message names real causes (a :ro volume, a read-only host/NFS/SMB mount) instead of guessing.', page: 'dashboard' }, - { title: 'Import: stop wiping good metadata + clearer rejections (#804)', desc: 'fixed an import bug that could blank every tag on an already-tagged file and file it under "Unknown Artist" when metadata enhancement hit a transient error — clean/matched imports now keep their existing tags on failure. Files you import/sort yourself are also no longer false-quarantined for a small duration difference against a re-resolved release (that check is for catching broken downloads, not your own files). And the import window now shows each failed file\'s reason inline instead of just "Failed".', page: 'dashboard' }, - { title: 'Artist pages show your full discography again', desc: 'fixed artist-detail pages showing only a handful of albums for a heavily-watched artist — background watchlist probes were poisoning the album-list cache. Also fixed missing tracks on an artist page where album-context qualifiers were blocking library-presence matching (#808).', page: 'library' }, - { title: 'Watchlist iTunes IDs: backfill that actually works + corruption repair', desc: 'the watchlist iTunes-ID backfill never ran in the normal wiring; it now uses the registry client and works. A one-time repair also fixes artists whose stored "iTunes ID" was actually a Deezer ID. Plus the Deezer-search → Tidal-download multi-artist flow is pinned end to end (Netti93).', page: 'watchlist' }, - { title: 'Torrents: stalled-download handling (noldevin)', desc: 'a dead magnet no longer holds a download worker hostage for six hours — stalled torrents are abandoned, and the stall thresholds are now exposed as UI settings you can tune.', page: 'settings' }, - { title: 'Manual match by pasted MusicBrainz link/ID (Ashh)', desc: 'the manual-match tool now accepts a pasted MusicBrainz ID or URL to match a track directly to that exact entity.', page: 'library' }, - { title: 'Smaller fixes', desc: 'fixed the Stream button never learning its stream was ready (a 2.6.5 regression); Library Re-tag cover-mode scans no longer produce unappliable "(0 track(s))" findings, and the re-tag findings now show each track\'s real filename; mirrored "Liked Songs" stops 400-ing on every auto-refresh (wolf39us); downloads now pause all enrichment workers (and discovery pauses the heavy five) so imports stay fast; Genius rate-limits fail fast instead of napping the import pipeline; and an on-demand memory-growth diagnostic was added for chasing leaks (#802).' }, - ], - '2.6.7': [ - { date: 'June 5, 2026 — 2.6.7 release' }, - { title: 'Spotify Free — Spotify metadata with no credentials, and a rate-limit bridge (#798)', desc: 'a new "Spotify Free (no credentials)" metadata source. Pick it in Settings → Metadata to get Spotify search + enrichment without connecting a Spotify account (it uses the public web-player data via the optional spotapi package). It also works as a rate-limit bridge for connected users: if your real Spotify auth gets rate-limited, enrichment automatically falls through to the free source instead of stalling, then returns to your real auth once the ban lifts. The worker can be resumed mid-ban, the dashboard now shows "Running (Spotify Free)" instead of looking stuck, and the daily-API budget never pauses a Spotify-Free user — free work isn\'t counted against it, and once your real-API budget for the day is spent the worker switches to free (uncapped) for the rest of the day instead of pausing, reverting to your real auth on the daily reset. Opt-in — nothing changes unless you select it.', page: 'settings' }, - { title: 'Import IDs from File Tags — recover the provider IDs already in your files', desc: 'a new "Import IDs from File Tags" tool (Tools → Database & Scanning) reads the Spotify / iTunes / MusicBrainz / Deezer / Tidal / AudioDB / Genius / Last.fm IDs that SoulSync (or MusicBrainz Picard) already embedded in your files and fills them into the database. The media-server API never exposes these IDs, so the only way to get them is from the files themselves — and once they\'re in the DB, the enrichment workers skip every entity that already has an ID, saving a large amount of API traffic on an already-tagged library. Gap-fill only: it never overwrites an existing match, and it\'s atomically guarded so it can\'t clobber a match a worker makes at the same moment. New tracks also get this automatically as the final phase of every library scan, so it stays current without re-running the tool.', page: 'dashboard' }, - { title: 'Library Re-tag — rewrite your library\'s tags from the source, safely', desc: 'a proper Library Re-tag job replaces the old Retag tool. It matches each file to its source tracklist and rewrites the tags — title, artist, album, track / disc numbers, cover art, and the embedded source IDs — and shows you a per-track old→new diff before anything is written. Standard dry-run pattern (you see exactly what would change and opt in to apply), Light / Full depth settings, and it pulls cover art + metadata from your configured source order.', page: 'dashboard' }, - { title: 'Paste a metadata link to open an artist, album, or track (#775)', desc: 'the Search page now accepts a pasted metadata link. Paste a Spotify / iTunes / Deezer / etc. artist, album, or track URL and SoulSync resolves it and opens that exact item instead of running a name search — handy when a name search is ambiguous or you already have the link. Bare IDs are rejected as ambiguous (a link carries the entity type), and a clear not-found hint shows when nothing resolves.', page: 'search' }, - { title: 'Mobile: a full responsive pass across the app (#793, #795)', desc: 'a comprehensive small-screen pass. The artist-detail page, enhanced track table, music player and Now Playing modal, sync buttons, discover carousels, the downloads page, the notification panel, and the mini-player all lay out cleanly on phones now, plus scroll-render and password-manager-extension compatibility improvements.' }, - { title: 'Playlist sync: new "Reconcile" mode that edits in place (#792)', desc: 'playlist sync gains a "Reconcile" mode alongside Replace and Append. Replace deletes and recreates the server playlist every sync (which wipes its custom image / description); Reconcile updates the same playlist in place — adds new tracks, removes ones no longer in the source — so your custom image and description survive. Choose it per playlist in the sync-mode setting.', page: 'sync' }, - { title: 'Manual album match now locks the edition it\'s pinned to (#758)', desc: 'manually matching an album now pins AND locks that exact edition. Previously the auto canonical resolver could drag a manually-matched regular edition back to the deluxe on the next cycle — reporting missing songs or renumbering tracks. The manual choice is now the authority every downstream tool reads (track-number repair, reorganize, missing-tracks), and the auto resolver won\'t override it. A new manual match still wins if you change your mind.', page: 'library' }, - { title: 'Write Tags won\'t overwrite a correct file with placeholder data (#800)', desc: 'Write Tags will no longer stamp a correctly-tagged file with placeholder database values like "Various Artists" or "[Unknown Album]". If your file already holds a real value and the database only has a placeholder, the file\'s value is preserved instead of being destroyed. A legitimate value (including a genuine compilation\'s "Various Artists") still writes normally.', page: 'library' }, - { title: 'AcoustID stops quarantining correct downloads of non-English artists (#797)', desc: 'AcoustID verification no longer false-quarantines correct downloads from non-English artists. When the fingerprint database returns the artist/title in its original script (e.g. Japanese kanji for Joe Hisaishi) and your metadata is romanized, the title can\'t match across scripts — so a correct file used to get quarantined. When the artist is confirmed across scripts via MusicBrainz aliases, the file is now kept instead of quarantined. A per-request "Skip AcoustID verification" toggle was also added to the download flow.', page: 'downloads' }, - { title: 'Fix: wrong artist on the artist-detail page when a source ID was duplicated', desc: 'fixed a class of bugs where the artist-detail page could show the wrong artist, and where one enrichment worker could stamp a single source ID onto several different artists. Artist matching is tightened (a 0.85 confidence gate + a shared uniqueness guard), a one-time startup repair de-duplicates any source IDs shared across multiple artists, and the library view is kept when a source ID is ambiguous instead of jumping to the wrong artist.', page: 'library' }, - { title: 'Fix: manual playlist fixes reverted on the next mirrored-sync run (#799)', desc: 'a manual fix on a mirrored playlist no longer reverts to "Wing It" on the next discovery / sync run — the manual match is checked first and its flag is cleared correctly. Also stopped the wide "Server Playlists" sync tab from stretching full-width.', page: 'sync' }, - { title: 'Find & Add: manual matches now survive a library rescan (#787)', desc: 'a match made via Find & Add (and via the manual-match tool) now records a durable manual match plus the file path, so it survives a library rescan instead of being lost and re-flagged.' }, - { title: 'Fix: streamed tracks played with no sound', desc: 'fixed streamed tracks occasionally playing silently — the browser\'s Web Audio context could be left suspended. It\'s now resumed on the play event across every play path.', page: 'dashboard' }, - { title: 'Navidrome: respect the selected music library + survive renames (#789)', desc: 'Navidrome now respects the music library you selected (it previously ignored the selection and imported all libraries), and pins that selection by id rather than name so it survives a library rename.', page: 'settings' }, - { title: 'Fix: file / CSV playlists failed to match raw "Artist - Title" titles (#785)', desc: 'file and CSV playlists whose rows are raw "Artist - Title" strings now match correctly — the discovery worker also searches the canonical title form.', page: 'sync' }, - { title: 'Fix: torrent client URL without http:// failed to connect (#790)', desc: 'a torrent client URL entered without an http:// or https:// scheme now connects instead of failing the connection probe.', page: 'settings' }, - { title: 'Fix: Soulseek album bundle left completed files in the slskd folder (#796)', desc: 'Soulseek album-bundle downloads no longer leave their completed copies behind in the slskd download folder after the bundle finishes.', page: 'downloads' }, - { title: 'Cover Art Filler + Library Re-tag honor your configured cover-art sources', desc: 'both the Cover Art Filler and the new Library Re-tag job now pull artwork from your configured cover-art source order instead of a fixed path, so the cover you get matches your source preferences.', page: 'dashboard' }, - ], - '2.6.6': [ - { date: 'June 3, 2026 — 2.6.6 release' }, - { title: 'Fix: qBittorrent 5.2.0+ would not connect (HTTP 204 login)', desc: 'qBittorrent 5.2.0 changed its /api/v2/auth/login endpoint to answer a successful login with HTTP 204 (No Content) instead of the old HTTP 200 + "Ok." body. SoulSync required the literal "Ok." response, so on 5.2.0+ every login failed with "HTTP 204 body=" — the connection probe and all torrent actions were dead even though qBittorrent itself logged a successful login. Login is now accepted on the SID auth cookie and/or a success response (the old "Ok." or the new empty 204), while bad credentials (which qBittorrent reports as HTTP 200 + "Fails.") are still rejected. Covers 5.2.0 / 5.2.1; no more whitelist-bypass workaround needed.', page: 'settings' }, - { title: 'Cover Art Filler: finds albums with no art on disk + writes it into the files', desc: 'two fixes. (1) The filler only ever checked whether the database had a thumbnail URL — so an album whose files had no embedded art and no cover.jpg, but whose DB row happened to carry a URL, was never flagged. It now also checks the files themselves (a cheap cover.jpg/folder.jpg sidecar check first, then the representative track for embedded art). (2) Applying found art used to only update the database thumbnail; it now actually embeds the artwork into the album\'s audio files and writes a cover.jpg, reusing the same path the import flow uses (so your preferred cover-art source order is honored). It is purely additive — it never rewrites your existing tags and skips any file that already has art. (3) The new cover-art sources no longer attach the wrong cover: a fuzzy title-search result is only used when its title AND artist actually match the album.', page: 'dashboard' }, - { title: 'Recommended artists: now explains WHY, and has its own spot on Discover', desc: 'the similar-artists recommendations now show "Because you have X & Y" — the actual artists in your library that point to each suggestion — instead of a bare count, and the feature is promoted out of a buried hero button into a first-class "Recommended For You" section on the Discover page. Powered by the library-wide similar-artists graph, so recommendations span your whole library, not just your watchlist.', page: 'discover' }, - { title: 'Organize by playlist: library, wishlist, and Download Missing fixes (#780)', desc: 'syncing with "Organize by playlist" now registers organize downloads in the SoulSync library, reliably adds failed organize tracks to the wishlist, sees files already sitting in the playlist folder during Download Missing (so re-runs stop re-downloading them), and the Spotify UI refreshes its track cache so newly added tracks actually appear. The per-playlist folder preference is persisted and honored across auto-sync.', page: 'sync' }, - { title: 'Faster, smoother WebUI navigation + scrolling (#783)', desc: 'sidebar navigation responds on press instead of release, the particle/dashboard canvases pause their redraw while you are actively scrolling, long lists skip off-screen layout work, and the dashboard\'s initial data loads fire in parallel instead of one-after-another. Opening Settings no longer triggers a spurious full save (which re-initialized every backend service client) on every visit. A new "Reduce Visual Effects" performance mode is available for low-power devices.', page: 'dashboard' }, - { title: 'Dashboard polish + mobile responsiveness', desc: 'the enrichment-services cluster and the API rate-monitor on the dashboard now lay out cleanly on small screens, and the "Manage Workers" hub got a subtle living-nucleus treatment that reflects real worker activity at a glance.', page: 'dashboard' }, - ], - '2.6.5': [ - { date: 'June 1, 2026 — 2.6.5 release' }, - { title: 'Basic search: visual overhaul + per-source picker in hybrid mode', desc: 'the basic search tab on the Search page was carrying its original visual treatment from before the rest of the app moved toward the glassy / accent-radial aesthetic, and it always targeted the first source in the hybrid chain with no way to pick a different one. Two things in this commit: (1) full visual redesign — glass search-bar card with accent radial wash + focus ring, pill primary search button, always-visible compact filter pill row (Type / Format / Sort), accent-tinted status pill, album result cards with accent left-edge stripe + cover icon + chevron expand + pill action buttons, track result cards as slim glass rows, multi-disc separators in album track lists, responsive button-stack on narrow viewports. Self-contained sheet at ``webui/static/basic-search-v2.css`` so revert is just dropping the link tag. (2) source picker — small chip row above the search bar lists every active source in the hybrid chain. Click a chip to target that specific source for the next search. In single-source mode the chip is rendered as a dashed-border label so the user always knows what they\'re searching. New ``GET /api/search/sources`` endpoint returns the active source list; ``/api/search`` accepts an optional ``source`` body param to target that specific source via its client. Backwards compatible — omitting ``source`` uses the orchestrator default exactly as before. Pinned with 5 new unit tests (source routing, fallback on unknown source, no-source default, alias preservation, album serialisation through the source-targeted path) on top of the existing 6 tests.', page: 'search' }, - { title: 'Fix: duplicate tracks in albums with Japanese / CJK titles (#722)', desc: 'Japanese OST downloads via Apple Music + Tidal produced duplicate library entries — the same audio file landed under multiple track positions in the album. Root cause: ``MusicMatchingEngine.normalize_string`` correctly skipped unidecode for CJK text (kanji→pinyin would have been gibberish) but then ran ``re.sub(r"[^a-z0-9\\s$]", "", text)`` which stripped EVERY CJK character. Every Japanese title normalised to ``""`` and ``similarity_score`` short-circuited to 0.000 on the empty-string guard. The matcher fell back to duration+artist alone, so multiple iTunes tracks (different songs with same artist + similar duration) mapped to the same Tidal candidate. User got the same audio downloaded N times under different track positions. Fix: when CJK is detected, the alphanumeric-strip step preserves CJK Unified Ideographs, Hiragana, Katakana, Hangul, and Halfwidth/Fullwidth ranges, so CJK titles produce a comparable normalised form. Two different Japanese tracks now score appropriately low; two identical Japanese tracks now score 1.0. Latin-only normalisation is completely unchanged. 16 new unit tests pin every CJK family + every regression-prone Latin baseline. Closes #722.', page: 'downloads' }, - ], - '2.6.4': [ - { date: 'May 28, 2026 — 2.6.4 release' }, - { title: 'Fix: Usenet album bundle stuck on "downloading release" when SAB History flips before storage lands (#721)', desc: 'follow-up to the 2.6.3 queue→history handoff fix. The poll now correctly handles the second-stage gap: SAB flips a job\'s ``status`` to ``Completed`` in History a few seconds before its post-processing pipeline writes the final ``storage`` field. Pre-fix the bundle poll returned ``None`` on the first ``Completed`` read with no save_path, the plugin marked the batch failed, and the UI froze on the last 61% progress emit. ``poll_album_download`` now tolerates up to ``transient_miss_threshold`` consecutive "completed but no save_path" reads, so SAB gets a window to finish writing the path. When it lands, the poll resolves normally. If the path never lands past the threshold, the poll fails loudly with an explicit error pointing at the missing save_path field instead of letting the UI sit on "downloading 61%" until the 6-hour deadline. Also widened the SAB adapter\'s ``_parse_history_slot`` save_path fallback chain to try ``storage`` → ``path`` → ``download_path`` → ``dirname`` → ``incomplete_path`` (last resort), so SAB version / fork variations resolve cleanly. Whitespace-only values are skipped. 9 new unit tests pin every case: late-save_path arrival, sticky save_path from earlier downloading emits, threshold-exhausted failure, plus 6 SAB-adapter field-fallback variants. Closes #721.', page: 'downloads' }, - ], - '2.6.3': [ - { date: 'May 27, 2026 — 2.6.3 release' }, - { title: 'Album-bundle staging: clean up Soulseek copies + startup sweep for orphan dirs', desc: 'two related cleanup issues with the album-bundle staging dir (``storage/album_bundle_staging//``). (1) Pre-fix the per-batch cleanup at the end of a download only ran for torrent / usenet bundles — Soulseek bundles were excluded with a comment about slskd "keeping its own completed folders", but Soulseek\'s bundle path ALSO copies completed files into the private staging dir for the per-track workers to claim. Those copies leaked forever; users with active Soulseek album downloads accumulated stale GB under the staging root over time. Extended the cleanup gate to include ``soulseek`` so the per-batch dir is removed once the bundle completes — same code path that already worked for torrent / usenet. (2) Added a startup sweep that removes any orphan ```` subdirs left behind by previous-session crashes, errored batches, or pre-fix Soulseek bundles. Sweep runs once at server boot before any batch can register a staging dir, so it can\'t race a starting batch — safe by construction. Defensive guards: name-shape check rejects non-batch-id dirs (so a hand-placed ``.git`` or ``README.txt`` is left alone), only acts inside the configured staging root, ``shutil.rmtree`` errors log + continue instead of crashing startup. For users who already have a "clean up old files" automation pointed at this dir: stop pointing it there if you want, the auto cleanup + startup sweep cover it now; or leave it as belt-and-suspenders with a relaxed 1h+ mtime threshold. 11 new unit tests pin every edge case.', page: 'downloads' }, - { title: 'Sync page: collapse tabs to brand-logo chips, active swells into a label pill', desc: 'with 14 sync sources now (Spotify, Spotify Link, iTunes Link, Tidal, Qobuz, Deezer, Deezer Link, YouTube, Beatport, ListenBrainz, Last.fm, SoulSync Discovery, Import, Mirrored, plus Server Playlists), the old "row of equal-width labeled pills" tab strip ran out of horizontal room — labels wrapped to 2 lines, the active pill ate disproportionate space, the whole row felt cramped. Restyled the tab strip as a row of circular brand-logo chips (40px on desktop, smaller on tablet/mobile); the currently-active tab swells into a pill with its label inline. Hover tooltip surfaces the source name via the title attribute. Each chip carries its source\'s brand color as a hover ring + active fill (Spotify green, Tidal orange, Qobuz blue, Deezer purple, iTunes coral, YouTube red, Beatport green, LB orange, Last.fm red, SSD teal). To disambiguate the three duplicate-logo sources (Spotify Link / Deezer Link / iTunes Link share a logo with their native-source siblings), each "Link" variant carries a small chain-link badge bottom-right. CSS-only swap — zero JS / behavior changes, same tab click handler, same data-tab routing. The active tab\'s label still shows so context is never lost; non-active tabs trade their label for ~70% horizontal savings.', page: 'sync' }, - { title: 'Fix: Soulseek album downloads stuck on "failed" after slskd finished the release (#715)', desc: 'when an album was downloaded via the Soulseek album-bundle path (the release-first flow added in 2.6), the batch would frequently mark itself as failed even though slskd had successfully downloaded every track of the album. Worst on users running slskd with the common ``directories.downloads.username = true`` config — files landed at ``//`` instead of the three hard-coded candidate paths the bundle resolver probed, so every file looked locally missing and the poll spun until the ~30 minute deadline expired. Per-track Soulseek downloads were unaffected because they already routed through a recursive walk-by-basename helper. Lifted that helper into ``core/downloads/file_finder.py`` and rewired the bundle path to use it — same logic the per-track flow has used since 2.5.9. Also: when the bundle poll detects that slskd reports every transfer Completed but no local file resolves within a 45-second grace window, it now exits with a clear log line pointing at the likely ``soulseek.download_path`` mismatch instead of silently spinning until the master deadline. Misleading "(0 tracks, quality=)" log on the preflight-reuse path also fixed — was reading attrs off a None object. 17 new unit tests pin every slskd layout shape (flat, username-prefixed, full-tree-preserved, deep nested, dedup-suffix, quarantine-skip, YouTube/Tidal encoded names, transfer-dir fallback, fuzzy punctuation variants). Closes #715.', page: 'downloads' }, - { title: 'Auto-Sync manager: full visual overhaul to match the dashboard vibe', desc: 'the Auto-Sync manager modal had been carrying its original visual treatment forward unchanged while the rest of the app moved toward the glassy / accent-radial / gradient-border aesthetic the dashboard now sets. Restyled every surface inside the modal to match — selector-based override layer at the end of style.css so functionality is untouched (zero JS or HTML changes). Touched surfaces: modal shell (glass shell with thin accent border + corner radial wash + inner top-edge highlight), header (gradient text title + accent-tinted hairline separator + spinning-X close), KPI summary tiles (dashboard-style gradient tiles with accent top-edge glow on hover + gradient stat numbers), live monitor strip (accent-tinted glass card with status-colored borders), refresh / intro buttons (pill primary with accent fill + glow on hover), tabs (underline-style with accent fill + soft radial glow on the active tab), sidebar (glass panel with accent-tinted source-group cards, accent border on scheduled playlists, accent ring on the filter input focus), board area (subtle accent radial spotlight + column cards with gradient headers + drag-over accent glow), drop zones (animated dashed pull with accent radial wash + accent-tinted text on drag-over), scheduled cards (accent left-edge stripe, gradient pill timing badges, pill "Run now" primary + rotating ghost X), run history rows (dashboard recent-activity aesthetic with accent hover lift + pill status badges), bulk-schedule popover, weekly editor (full glass modal matching the version-modal vibe, day-toggle pills, accent-pill save button), and empty / monitor-empty states. Soft accent-tinted scrollbars throughout. Delete the v2 block at the bottom of style.css to revert.', page: 'automations' }, - { title: 'Auto-Sync: weekly board cards now match the hourly board', desc: 'when the Weekly Board shipped, its scheduled-card visual diverged from the hourly board — weekly cards only showed the playlist name + weekly label + timezone, while hourly cards carry the full action row (Run now button, unschedule X, next-run countdown, health badge). Standardised the weekly card on the hourly shape so dropping a playlist onto a day column produces a card with the exact same affordances as dropping it onto an hourly bucket: health indicator dot when recent runs failed, source + track count meta line, weekly label + timezone + "next in Nh" pills, Run-now button (disabled while pipeline is running, same gating as hourly), and an unschedule X. Click anywhere outside the buttons still opens the weekly editor for changing days / time / tz. Weekly cards also became draggable between day columns now — drop on a new column appends that day to the schedule (matches the existing multi-day editor flow).', page: 'automations' }, - { title: 'Dashboard: enrichment equalizer bars get next-level polish', desc: 'follow-up to the equalizer redesign. four upgrades that take the bars from "nice" to "vibey": (1) every bar now carries a circular avatar disc above the track loading the service\'s real brand logo (the same images the header-action worker orbs use — Spotify, Apple Music, Deezer, Last.fm, Genius, MusicBrainz, AudioDB, Tidal, Qobuz, Discogs, Amazon Music). Disc has a dark glass backdrop, accent-tinted ring, and slow halo pulse when the worker is running; the brand color shows through as a drop-shadow on the logo + ring tint. Initial-letter fallback kicks in automatically if any CDN URL fails; (2) peak-flash detector — when the worker\'s calls/min actually steps upward between updates, the peak tip briefly flares white-hot and the fill flashes brighter for ~650ms, like a hardware VU meter\'s peak-detect LED catching a beat. only fires on real increases above a noise threshold so jitter doesn\'t pulse the bar constantly; (3) rolling number counter — the live count digit-animates from old→new value with easeOutCubic instead of snapping, the same premium-counter feel you\'d see on a fancy dashboard; (4) glass-surface reflection puddle under each bar — soft accent-colored glow whose opacity tracks the real (unclamped) rate, so the row looks like the bars are standing on a polished pane rather than floating in space. puddle ripples on peak-flash too. all four tied together by the same --eq-accent / --eq-glow CSS variables so future tweaks stay coherent.', page: 'dashboard' }, - { title: 'Dashboard: equalizer-bar redesign for the Enrichment Services panel', desc: 'the enrichment rate monitor on the dashboard used a 10-column grid of circular speedometers, which (a) lost symmetry the moment a new service was added — 11 services meant 10 in one row and a lonely orphan in the next, (b) wasted ~80% of pixels on empty gauge arcs showing "0" most of the time. Replaced with a VU-meter / equalizer-bar row: each service is a vertical capsule with the service\'s brand-color fill height tracking calls/min, a continuous shimmer scan + glowing peak tip when active, a slow breathing animation when idle, and a red pulsing variant when rate-limited. Symmetric by design at any service count — the row uses flex with even gaps so new services just slot in. Bar tip + value text adapt color to the service\'s accent. Status pill below each bar (Running / Idle / Paused / Stopped) with its own pulsing dot. Click any bar to open the same detail modal the speedometer used; nothing changed about the underlying data flow.', page: 'dashboard' }, - { title: 'Auto-Sync: fix ListenBrainz pipelines stuck on "Refreshing:" for 5+ minutes', desc: 'Auto-Sync pipeline runs against a ListenBrainz playlist (Weekly Jams / Weekly Exploration / Top Discoveries / etc.) would sit on "Refreshing: \'\'" for minutes with zero UI updates before anything else happened. Two real bugs ganging up: (1) the refresh path ran the LB matching engine on every track to produce matched_data — 5+ minutes blocking with no progress emission — and Phase 2 of the pipeline then ran the SAME matching engine on the same tracks via the discovery worker. So LB tracks discovered twice, the first time silently. (2) ListenBrainz manager only exposed an `update_all_playlists` entry-point — refreshing one playlist re-pulled all 12+ cached LB playlists\' details from the API even though we only cared about one. Pipeline now skips the refresh-side discovery (Phase 2 handles it with progress emits every 3s — same pattern that already works for Spotify); refresh uses a new targeted `LBManager.refresh_playlist(mbid)` that hits just the requested playlist. Also killed a silent `except Exception: pass` in the LB adapter that was masking real API failures as stale-cache hits — refresh errors now log with traceback and surface to the run-history error tally. Pinned with 12 new unit tests.', page: 'automations' }, - { title: 'Wishlist: harden Spotify backfill so a poisoned track_number can\'t mask a lean album', desc: 'follow-up to the earlier wishlist import-path work. residual per-track wishlist downloads (single tracks from different albums, falling below the album-bundle threshold) were producing folders without a year subfolder whenever the underlying wishlist row had a track_number=1 from an older default — the candidate dispatcher\'s "fall back to Spotify API" branch was gated entirely on track_number being missing, but the same API call was also the only thing that hydrated the lean album_context (release_date / total_tracks / cover art) when the original discovery match came from Deezer\'s search endpoint which doesn\'t carry those fields. so any row whose track_number happened to look "filled" (1 from a default) would short-circuit the API call and the year disappeared from the folder path even though the API knew it. split the two concerns: track_number resolution keeps its track_info → track object → API precedence, but album hydration now runs whenever release_date or total_tracks are missing regardless of where track_number came from. one network round-trip still serves both. lifted into core/downloads/track_metadata_backfill.py with 24 unit tests pinning every branch — including the regression: poisoned default-1 track_number does NOT block album backfill anymore.' }, - { title: 'Wishlist: fix imports landing as track 01 with no year in folder name', desc: 'follow-up to the earlier wishlist album-bundle work. tracks with rich Spotify metadata were still importing as `01 - ` with no year in the folder path because three regressions stacked: the Track→dict conversion in payload helpers dropped everything except `album.name` (silently throwing away release_date / images / album_type / total_tracks), Deezer-sourced discovery matches saved their payloads without `track_number` / `disc_number` keys at all (Deezer uses `track_position` while the cache lookup read `track_number` literally), and the import pipeline only consulted `album_info.track_number` before falling to the filename — which fails for VA-collection source files like `417 Fountains of Wayne - Stacys Mom.flac` where the leading number is a playlist position not the album track. all three patched, with the track_number resolution chain lifted into `core/imports/track_number.py` as a pure function with 18 unit tests pinning every branch.' }, - { title: 'Wishlist: distinguish Queued from Analyzing batches in the UI', desc: 'wishlist runs with more than 3 sub-batches used to render every batch as "Analyzing..." simultaneously — even though the download worker pool only runs 3 at a time. so a 26-album wishlist scan looked like all 26 were working in parallel when really 23 were just sitting in the executor queue waiting their turn. now batches show a distinct "Queued ⏳" state while parked in the executor queue; they flip to "Analyzing..." when a worker actually picks them up. zero behavior change — just less misleading UI.' }, - { title: 'Wishlist: only engage album-bundle when several tracks from the same album are missing', desc: 'auto-wishlist + manual-wishlist runs were promoting every single-track wishlist item to a per-album bundle search — so a wishlist of "26 single tracks from 26 different albums" downloaded full albums (5-42 files each, ~85% wasted bandwidth) and hammered slskd with concurrent searches. now the bundle path only engages when an album has 2 or more missing tracks in the wishlist; single-track items take the cheaper per-track path that already works fine. fewer slskd searches per cycle, less wasted bandwidth, no more downloading the same album three times in a row when staging-match misses a single track. configurable via `wishlist.album_bundle_min_tracks` if you want the old behaviour (set to 1) or stricter (set to 3+).' }, - { title: 'Auto-Sync: schedule playlists by weekday + time, not just by hour interval', desc: 'new Weekly Board tab on the Auto-Sync manager. drag a playlist onto a day column (Mon-Sun) to schedule it for that weekday at the default time, then click the card to open an editor for multi-day picks, custom time, and timezone. existing hourly schedules stay on the Hourly Board tab — one playlist gets exactly one schedule so swapping between weekly and hourly auto-replaces the old one. timezone defaults to whatever your browser reports (e.g. America/Los_Angeles); the editor accepts any IANA tz string. backend already had the plumbing from earlier 2.6.3 commits — this lights up the UI.', page: 'automations' }, - { title: 'Fix: usenet album downloads stuck on "downloading release" even after SAB finished', desc: 'when SAB moves a job from its queue into history (post-processing handoff — verify + unrar window), there\'s a brief gap where the job is in neither. SoulSync\'s poll treated one missing read as terminal failure and gave up silently, leaving the download modal frozen on "downloading release" forever. SAB queue states like `Pp` (post-processing) also weren\'t in the state map, hit the default-error fallback, and hung the poll the same way. now the poll tolerates ~10 seconds of transient misses, treats unmapped queue states as transient instead of silently looping, fires a terminal failed event on every failure path so the modal exits the downloading state, and queries SAB by nzo_id directly instead of paging the last-50-entries history (avoids losing the job on busy servers). same tolerance applies to torrent downloads — covers network blips that used to kill an otherwise healthy download.' }, - { title: 'Discogs: strip artist disambiguation suffixes everywhere', desc: 'Discogs marks duplicate-named artists with either `(N)` numeric suffixes ("Bullet (2)") or trailing asterisks ("John Smith*"). Only the `(N)` form was stripped, and only on the collection-import path — so artist + album search results in the UI surfaced raw "Foo*" or "Bar (2)" rows, and worse, import path inherited those characters into folder names on disk. centralized the cleanup into a single helper applied at every site a Discogs payload becomes a name string (artist search, album search, track lookups, get_artist_albums feature filtering). closes #634.' }, - { title: 'Library: Enhanced / Standard view toggle now sticks per browser', desc: 'the artist detail page used to revert to Standard view every time you clicked into a new artist — flipping to Enhanced was a per-click thing with no memory. now the choice is persisted in localStorage and reapplied automatically on every artist open. survives page reloads too. non-admins (who can\'t toggle Enhanced anyway) are unaffected; admins who never touched the Enhanced view stay on Standard since the saved value defaults to it.' }, - { title: 'Fix popup: manual matches survive Playlist Pipeline runs', desc: 'manually mapping a mirrored-playlist track via the Fix popup (search or MBID) saved correctly and the download went through, but the next Playlist Pipeline run kept reverting the match — the track flipped back to "Provider Changed" and you had to map it again. three things were ganging up on it: the manual-fix save always stamped `provider: \'spotify\'` even when the match came from MusicBrainz / iTunes / Deezer, so prepare-discovery saw a provider mismatch on the next run; the discovery worker also re-queued any matched_data that lacked `track_number` / `album.id` / `release_date` for re-discovery, and Fix-popup saves never carry those fields by design (search results don\'t include track_number, MBID lookups don\'t include album.id) — so manual fixes always looked "incomplete" and got overwritten; and the prepare-discovery provider check didn\'t honour the `manual_match` flag at all. all three patched — manual matches now stamp the actual source, the worker short-circuits on `manual_match` before the incomplete-data branch, and prepare-discovery treats manual fixes as cached regardless of provider drift.' }, - { title: 'Fix popup: artist + track fields no longer surface unrelated covers', desc: 'separate artist / track inputs in the Fix popup used to dump both into a bare MusicBrainz keyword query — and MB\'s scorer heavily favours title matches, so searching "Coffee Break" + "Zeds Dead" surfaced random "Coffee Break" tracks by other artists ahead of the canonical Zeds Dead one. fields-mode now uses a field-scoped Lucene query that actually anchors the artist, with the old bare query kept as a fallback for diacritic mismatches like "Bjork" vs "Björk" where strict phrase match misses. results also stable-prefer entries with known track length so the canonical 3:04 sibling sits above the 0:00 duplicate.' }, - { title: 'Groundwork: unified playlist source layer', desc: 'first slice of a refactor that\'ll let ListenBrainz, Last.fm radio, and SoulSync Discovery playlists live as Sync-page tabs alongside Spotify / Tidal / Qobuz / YouTube — so they can be mirrored + scheduled like the rest. this commit adds the shared adapter layer all those sources will plug into; no UI changes yet. nothing to do on your end.' }, - { title: 'Auto-Sync refresh now routes through the unified source layer', desc: 'follow-up to the groundwork above. the mirrored-playlist auto-refresh handler used to have a ~190-line if/elif chain branching per source (one branch each for Spotify, Spotify public, Deezer, Tidal, YouTube). now it asks the source registry for the right adapter and calls one refresh method. behavior identical — same matched_data, same Tidal-skip-on-no-auth log, same Spotify-public-prefers-authed-API fallback. unlocks ListenBrainz / Last.fm / SoulSync Discovery as future Sync-page mirror sources without a fresh elif branch each time.' }, - { title: 'Discovery folded into the unified source contract', desc: 'next slice of the groundwork. each playlist source can now answer one extra question — "match these raw tracks against Spotify / iTunes" — through the same adapter interface. Spotify / Tidal / Qobuz / YouTube / Deezer / Spotify-public / iTunes-link / SoulSync-Discovery all answer trivially (their tracks already have provider IDs); ListenBrainz + Last.fm run the matching engine. mirror-refresh now calls this automatically when a source returns MB-metadata-only tracks, so when ListenBrainz becomes a Sync-page tab next commit, its mirrors land already discovered + ready to sync — no separate Discover-page round-trip needed.' }, - { title: 'ListenBrainz Sync tab', desc: 'new ListenBrainz tab on the Sync page, between Beatport and Import. lists your "For You" / "My Playlists" / "Collaborative" LB playlists in one place. clicking a card kicks off the same discovery → sync → mirror flow you already get from the Discover page (no duplicate UI behind the scenes, just a new entry point). once mirrored, LB playlists participate in Auto-Sync schedules + pipeline automations like any other source. needs ListenBrainz connected in Settings → Connections.', page: 'sync' }, - { title: 'Last.fm Radio Sync tab', desc: 'sibling to the ListenBrainz tab — lists your generated Last.fm Radio playlists alongside the rest of the Sync sources. same discovery → mirror flow under the hood, just a different entry point. new Last.fm radios are still generated from the Discover page by picking a seed track; this tab is for syncing existing ones. mirrors auto-trim when Last.fm Radio cache rotates so old radios don\'t pile up.', page: 'sync' }, - { title: 'SoulSync Discovery Sync tab', desc: 'last of the unified-tab trio. surfaces your personalized SoulSync Discovery playlists (decade mixes, hidden gems, popular picks, daily mixes, discovery shuffle, etc.) on the Sync page. clicking a card regenerates the playlist + mirrors it under a stable synthetic id, so the same mirror updates in place every Auto-Sync refresh. tracks come out already matched against Spotify / iTunes / Deezer so there\'s no discovery hop — straight to download / sync.', page: 'sync' }, - { title: 'Fix: album-bundle downloads all landing as track 1', desc: 'soulseek album-bundle downloads (and any other untagged release-staging path) were importing every track with track_number=1. the staging-file reader was using the auto-import\'s filename extractor that defaults to 1 when no NN- prefix is present in the filename — so for albums like Ryoto\'s "Cha-La Head-Cha-La" where slskd hands you bare titles, every file got "track 1" stamped on it. now the staging path uses a strict extractor that returns 0 when it can\'t see an explicit prefix, so the downstream resolver correctly falls through to the authoritative Spotify metadata and the right track numbers land in the library.' }, - { title: 'Wishlist albums-cycle: one album-bundle search per album', desc: 'auto-wishlist runs in two cycles — albums + singles. previously the albums cycle dumped every missing track from every album into one big batch and ran a separate per-track Soulseek / Prowlarr search for each (~50 searches for a typical scan). now the albums cycle splits the wishlist into per-album sub-batches at submission time, and each one engages the existing slskd / torrent / usenet album-bundle release-first flow with that album\'s context. tracks without resolvable album metadata stay on the classic per-track residual batch so nothing falls off. singles cycle is unchanged.' }, - { title: 'Wishlist cycle toggles once per run, not per sub-batch', desc: 'follow-up to the per-album split above. with N sub-batches per wishlist run, the cycle toggle (albums → singles or vice versa) was firing once per sub-batch as each one completed — so the cycle could flip mid-run and the automation completion event got emitted multiple times. each wishlist invocation now stamps every sub-batch with a shared run id; the completion handler waits until the LAST sibling finishes before toggling the cycle + emitting the run-complete event. cleaner state machine, future-proofs frontend grouping of sub-batches under one logical run.' }, - { title: 'Wishlist download modal: keeps tracking across every album', desc: 'with the per-album sub-batch split, the modal\'s polling kept hitting the original batch id only and went stale as soon as the first sub-batch finished — subsequent albums downloaded fine but the modal\'s rows stopped updating. backend now merges every sibling sub-batch\'s tasks + analysis results into the response under the original batch id (re-indexes track positions globally so the table doesn\'t collide on row keys, picks whichever bundle is currently active, aggregates phase across siblings so the moment ANY sibling reaches the task stage the modal renders task rows — earlier rule waited for the slowest sibling and left the modal looking frozen while both albums were already downloading on slskd). frontend untouched — the modal sees one unified view of the whole run.' }, - ], - '2.6.2': [ - { date: 'May 24, 2026 — 2.6.2 release' }, - { title: 'Fix: songs stuck in quarantine loop when picking a different candidate', desc: 'when a track failed AcoustID verification and got quarantined, opening the candidates modal and manually picking a different file would just re-quarantine it — the manual pick path ran full AcoustID verification with no bypass, so if the alternate file disagreed with AcoustID\'s stored metadata too (common for live versions, remasters, regional title differences) the file landed right back in quarantine. user got stuck in the loop. manual picks via the candidates modal now skip AcoustID for that one post-process pass (matching what the Approve button already does for restored quarantine files). integrity and bit-depth gates still run because those check the new file\'s actual condition, not its identity. closes #701.' }, - { title: 'Fix: whole-album downloads ending up "failed" with empty queues', desc: 'the Soulseek album-bundle path routes whole-album downloads through a private staging dir, then per-track workers claim the staged files. when slskd files arrived without ID3 tags, the staging cache fell back to filename stems like "Artist - Album - 03 - Title" — too noisy to clear the title-similarity threshold against the clean Spotify title, so every track went not_found and the batch ended failed even with all files on disk. staging match now pulls the trailing-title segment when a bare track-number is present between " - " delimiters, so slskd filename patterns match cleanly. closes #700.' }, - { title: 'Fix: album tracks getting requeued as singles by the wishlist', desc: 'downstream of the album-bundle fix above. failed album-batch tracks were going to the wishlist with `source_type=\'playlist\'` hardcoded, and a couple of fallback paths were stamping `album_type=\'single\'` on the stored album dict. on requeue the path builder saw single → routed to the Singles tree even though the track belonged to an album (running Reorganize would patch it because the DB still knew). album batches now carry `source_type=\'album\'`, source-context preserves `album_context` / `artist_context`, and the non-dict-album + slskd-reconstruction fallbacks default to `album` instead of lying with `single`. closes #698.' }, - { title: 'Fix: Redownload Album button on the enhanced artist view was dead', desc: 'the Redownload button on the enhanced artist-page album row was throwing a silent ReferenceError on click — no popup, no toast, no log line, button just did nothing. the underlying function was lost when script.js got split into 17 domain modules and nothing caught it for a while. restored. closes #699.' }, - { title: 'iTunes / Apple Music link import', desc: 'new iTunes Link tab on the Sync page, between Deezer Link and YouTube. paste an Apple Music album, track, or playlist URL and SoulSync pulls the tracklist, runs it through the same discovery → sync → download flow as the other link tabs. handles the new Apple Music SPA token shape — token gets scraped from the JS bundle on first use and cached for 6 hours, with a 401 retry that refetches if Apple rotates mid-session.' }, - { title: 'Auto-Sync: bulk schedule by source + custom interval columns', desc: 'each source group in the sidebar gets a Bulk button — opens a popover that lets you schedule every playlist in that group at a chosen interval in one click (1h / 2h / 4h / 8h / 12h / 16h / 24h / 48h / 72h / weekly) or "Custom interval…" for an arbitrary number of hours. also includes "Unschedule all" to clear a source\'s schedules. on the board itself, a schedule with a non-standard interval (e.g. 6h or 36h, created via Automations page or the custom prompt) now renders as its own dashed-border column instead of disappearing because it didn\'t match a hardcoded bucket.' }, - { title: 'Auto-Sync manager: filter, failure indicators, history filters + load more', desc: 'a handful of UX additions on the Playlist Auto-Sync modal. sidebar gets a "Filter playlists…" search input so you can narrow down a long mirrored-playlist list. scheduled cards on the board now show a red ! badge when the last three pipeline runs all failed (yellow ⚠ if at least one of the last few failed) so chronically broken schedules surface visually instead of getting buried in the run-history tab. run history tab title shows a red error count when there are failed runs in the loaded window. the history tab itself gains All / Errors / Completed filter pills, a "Load more" footer that pulls another 50 entries, and a "Run pipeline again" button inside the expanded detail panel so you can re-trigger a specific playlist without leaving the modal. also dropped the "Discovered: completed" pill — `tracks_discovered` was a status string, not a count, and the same data is already in the before/after stats grid above.' }, - { title: 'Auto-Sync manager redesigned to match the rest of the app', desc: 'the Auto-Sync modal got a top-to-bottom restyle so it stops feeling like it lives in a different app. modal shell now uses the standard SoulSync gradient + accent-tinted border + 20px radius, the KPI summary became inset stat tiles, the live pipeline monitor is auto-fill instead of a fixed 4-col grid, tabs are now underline-style instead of pill buttons, and the schedule board sidebar/columns use the dense dark-card pattern that Automations + Library pages already use. modal also fills more of the screen since there was a lot of unused real estate. run history cards got the same treatment — slim horizontal row matching `.automation-card` (status dot + name + flow chips + meta row), and the expanded panel uses the same stats-grid / log-section structure as the Automations run-history modal.' }, - { title: 'Fix: Auto-Sync manager hung forever on "Loading schedule..."', desc: 'a regression while wiring up the new "in library" count: the join used `COLLATE NOCASE` on `tracks.title` and `artists.name`, which can\'t use the indexes those columns have. on a 300k-track library each playlist took ~18s. with 30 mirrored playlists the modal\'s `/api/mirrored-playlists` call would never come back. switched to case-sensitive equality so SQLite uses `idx_artists_name` + `idx_tracks_title` (6ms per playlist). misses pure-case-different matches but Spotify names are canonical against library imports, so it\'s a rounding-error tradeoff for the 3000× speedup.' }, - { title: 'Fix: Auto-Sync run history `in library` always showed 0', desc: 'the SQL query was referencing `tracks.artist` — a column that hasn\'t existed since the schema went relational. `tracks` has `artist_id` (FK to `artists`), so the query threw "no such column", got swallowed by the surrounding try/except, and the count silently defaulted to 0 on every playlist. rewired the join through `artists` so the count actually reflects how many mirrored tracks already live in your library.' }, - { title: 'Auto-Sync modal opens faster (1.5s → ~280ms)', desc: 'opening the Auto-Sync manager used to spend ~1.5s in `/api/mirrored-playlists` because each mirrored playlist row triggered its own `get_mirrored_playlist_status_counts` call, and each of those opened a fresh SQLite connection with PRAGMA setup. new `get_all_mirrored_playlist_status_counts(profile_id)` does the totals / discovered / wishlisted / in-library counts across every playlist for the active profile in 4 batched `GROUP BY` queries over one connection. about 5× faster on a 30-playlist account.' }, - { title: 'Playlist sync is way faster on partial-match playlists', desc: 'syncing a playlist where most tracks weren\'t in the library used to take forever — a 30-track playlist with only 9 matches was burning 4+ minutes because every unmatched track ran the full title-variation × artist-variation SQL grid against the tracks table (~30 SQL queries per missed track). cache only covered matches, so misses re-paid the cost every sync. sync now uses a per-artist track pool that fills in lazily — only tracks that miss the sync match cache trigger a one-time fetch of their artist\'s library tracks, and later misses for the same artist reuse the in-memory list. playlists where every track is already cached pay zero pool cost (no upfront delay). benefits every sync entry point — manual, auto-sync, the playlist_pipeline automation action, and the Sync All button.' }, - { title: 'Fix: Auto-Sync "next in 8h" timezone bug', desc: 'scheduled Auto-Sync playlists were all showing "next in 8h" regardless of the interval — Every 1 hour, Every 1 day, anything. backend stores next-run as a naive UTC string and the frontend was parsing it as local time, which on Pacific time offset the displayed countdown by ~8 hours. Auto-Sync now uses the existing UTC-aware parser the rest of the Automations page already uses. as a separate correctness fix, the automation update endpoint also now blanks the stored next-run whenever the trigger type or trigger config changes, so the engine recomputes from scratch instead of preserving a leftover timestamp from the previous schedule.' }, - { title: 'Auto-Sync modal restyled to your accent color', desc: 'the Playlist Auto-Sync manager now picks up your chosen accent (the same one used everywhere else in the app) instead of the hardcoded sky-blue palette it shipped with. tabs, drop zones, scheduled-playlist chips, scrollbars, and the modal glow all follow the accent theme. drop targets also light up clearly while dragging.' }, - ], - '2.6.1': [ - { date: 'May 24, 2026 — 2.6.1 release' }, - { title: 'React Import page polish', desc: 'Import now runs through the React route stack with album, singles, and auto-import tabs plus the state fixes needed for reliable Vite builds.' }, - { title: 'Fix: album-bundle track numbering', desc: 'Soulseek-first hybrid and album-level source flows now preserve each track number during staging instead of importing every file as track 01.' }, - { title: 'Fix: completed downloads stay visible', desc: 'The Downloads view now keeps a bounded completed-history tail so finished downloads remain visible after the live task disappears.' }, - { title: 'MusicBrainz release variants', desc: 'Manual album search now surfaces concrete MusicBrainz releases with richer variant metadata instead of collapsing everything to the release group.' }, - { title: 'Artist and radio UI refinements', desc: 'Artist detail actions and the Now Playing radio-mode controls were restyled and aligned for the refreshed artist workflow.' }, - ], - '2.6.0': [ - { date: 'May 24, 2026 — 2.6.0 release' }, - { title: 'Qobuz playlist sync', desc: 'new Qobuz tab on the Sync page. Connect Qobuz in Settings → Connections, hit Refresh on the tab, and your Qobuz playlists + Favorite Tracks show up alongside Tidal and Deezer. clicks run the same discovery → sync → download flow as the other sources.', page: 'sync' }, - { title: 'Import search: show when results came from the fallback source', desc: 'if you picked MusicBrainz (or Discogs / iTunes / etc.) as your primary metadata source but the Import album search ended up serving Deezer cards, you had no idea — the chain silently fell through when the primary returned nothing. now each card shows a small "via Deezer" label when the source differs from your primary, and a banner above the grid spells it out when all results came from the fallback. backend behavior unchanged.' }, - ], - '2.5.9': [ - { date: 'May 21, 2026 — 2.5.9 release' }, - { title: 'Now-playing modal: lyrics panel', desc: 'new lyrics panel below the player controls in the expanded now-playing modal. fetches from LRClib via /api/lyrics/fetch, but prefers the local .lrc / .txt sidecar files SoulSync drops next to your audio during post-processing so downloaded tracks show lyrics instantly with zero network. synced LRC (timestamped) highlights the active line and auto-scrolls it into the middle of the viewport on every audio timeupdate; plain text renders without highlighting. status chip shows whether the result came back Synced or Plain. panel is collapsed by default — click the Lyrics header to expand. cached per track so revisiting a track doesn\'t refetch.' }, - { title: 'Now-playing modal: View Artist closes the modal first', desc: 'tapping View Artist on the expanded media player now closes the now-playing modal before navigating, so the artist page is actually visible instead of sitting under a modal you\'d have to manually dismiss. click is a no-op when no artist_id is attached to the current track.' }, - { title: 'Torrent and Usenet release downloads', desc: 'torrent and usenet are now real download sources backed by Prowlarr plus your configured downloader client. album downloads use a release-first staging flow so SoulSync downloads one release, watches live progress, then imports the matching tracks from the staged files. hybrid mode keeps torrent / usenet out of album batches, but still lets them participate for playlist singles and wishlist tracks.' }, - { title: 'Fix: HiFi public instance compatibility', desc: 'HiFi instance probing now understands both supported manifest shapes: the newer /trackManifests-style flow and the public hifi-api /track/ legacy flow. instances that can search and return a playable HLS manifest are no longer mislabeled as Search only. browser-openable pages can still be offline from the API side, so HTTP 502 / Offline labels are still shown honestly.' }, - { title: 'Fix: Jellyfin full refresh imports tracks on older databases', desc: 'full refresh could import artists and albums but fail every Jellyfin track on upgraded databases that were missing newer media columns. startup repair now adds the missing tracks.file_size and albums.api_track_count columns before refresh work runs, so old databases can accept new Jellyfin track rows again.' }, - { title: 'Fix: transient SQLite disk I/O errors retry cleanly', desc: 'cache maintenance and the Tools page full-refresh job now retry short-lived SQLite disk I/O failures around destructive maintenance writes. this targets the reported pattern where the first run failed with disk I/O error and an immediate second run succeeded.' }, - { title: 'Fix: Album Completeness blocks wrong-artist fills', desc: 'artist matching now wins before album/single title matching. if Album Completeness is filling a Jamiroquai track, a same-title release by a different artist is rejected instead of being allowed through by a loose title match.' }, - { title: 'Fix: Album Completeness no longer cross-contaminates artists', desc: 'reported case: filling a Jamiroquai "Light Years" single brought in tracks from Gut\'s "Light Years" album — completely different artist, completely different genre. Root cause: the auto-fill artist gate was a loose 0.50 SequenceMatcher threshold that could let unrelated candidates through. Two new defenses now block this entirely. <strong>Stage 1</strong> skips the whole track-fill operation up front if the missing track\'s source artist doesn\'t match the target album artist — compilation albums (various artists / soundtrack) still pass through. <strong>Stage 2</strong> replaces the per-candidate 0.50 SequenceMatcher with an alias-aware 0.82 strict matcher that handles diacritics (Beyoncé/Beyonce) and known artist aliases. Both stages logged with structured warnings so future mismatches are diagnosable from the logs.' }, - { title: 'Code review refactor pass on the torrent / usenet flow', desc: 'cleanup commits before review. Lifted the shared album-pick + staging-collision helpers out of torrent.py into a new core/download_plugins/album_bundle.py module so usenet.py no longer reaches into a sibling plugin\'s private surface. Lifted the ~90-line inline album-bundle gate out of run_full_missing_tracks_process into core/downloads/album_bundle_dispatch.py with a clean pure-predicate / inject-deps design so the gate is unit-testable in isolation. Replaced the staging matcher\'s direct download_batches import with an injected get_batch_field accessor on StagingDeps. Made the 6h poll timeout configurable via download_source.album_bundle_timeout_seconds. Added atomic .tmp + rename copy so the Auto-Import worker can never observe a partial audio file during the album-bundle copy loop. 49 new tests across album_bundle, album_bundle_dispatch, and staging-provenance modules pin the contracts.' }, - { title: 'Hybrid mode skips torrent / usenet on album batches', desc: 'follow-up to the album-bundle flow. When download mode is set to Hybrid AND the batch is an album, the per-track search loop now silently strips torrent and usenet from the source chain — they\'re release-level sources that can\'t match per-track meaningfully, and the album-bundle handling only fires in single-source mode. Without the skip, hybrid + torrent-first would fire N redundant Prowlarr searches per album and rely on Auto-Import sweeping Staging to recover. Cleaner now: hybrid falls straight through to per-track-compatible sources (Soulseek / streaming) for albums, and torrent / usenet still get a shot for single-track / wishlist / basic-search use cases where per-track makes sense.' }, - { title: 'Album-bundle flow for torrent / usenet downloads', desc: 'fixes the core architectural problem with indexer-based sources. Prowlarr returns release-level torrents — searching per-track for "Luther (with SZA)" against the GNX album torrent scores near-zero and the orchestrator rejects every candidate. New gated flow: when downloading an album AND torrent or usenet is the single active source (not hybrid), SoulSync now does ONE Prowlarr search for the whole release, picks the best torrent (prefers FLAC, high seeders, reasonable size — drops single-track torrents that snuck in), hands it to your torrent / usenet client, walks the resulting audio files (extracting .zip/.rar/.tar if needed), and drops them all into the staging folder. The existing per-track staging matcher then imports each one to the library by fuzzy title match — same path as the Auto-Import flow. Gate is strictly opt-in: per-track flow is completely untouched for hybrid mode, non-album downloads, and every other source. 5 new tests cover the album picker (seeded-FLAC preference, size floor for single-track torrents, fallback when all candidates are small) and the staging path collision handler.' }, - { title: 'Filesystem-access heads-up for torrent / usenet sources', desc: 'new advisory card on the Indexers & Downloaders tab explaining the cleanest setup: point ALL your downloaders (Soulseek, qBittorrent, SABnzbd / NZBGet) at the same download folder. One folder, one mount, everything just works. Bare-metal needs no change; Docker users can reuse the existing ./downloads mount and just configure each client to write there. docker-compose.yml updated to call this out as the easiest path, with optional commented placeholders for users who prefer separate folders per protocol.' }, - { title: 'Torrent and Usenet downloads', desc: 'two new download sources live in the Download Source dropdown: <strong>Torrent Only (via Prowlarr)</strong> and <strong>Usenet Only (via Prowlarr)</strong>. they reuse the Prowlarr + torrent client + usenet client you set up on the Indexers & Downloaders tab. searches go through Prowlarr filtered by protocol, picked releases get handed to your torrent client or usenet client, and the resulting files get walked through archive_pipeline (extracts .zip / .rar / .tar when the client didn\'t already do it) and handed to the matching pipeline. both sources are also available in hybrid mode alongside soulseek / youtube / tidal / etc. one caveat: SoulSync needs read access to the torrent / usenet client\'s save_path — works out of the box for everything-on-one-box setups, but remote downloader hosts will need a future sync step.' }, - { title: 'Archive pipeline module (groundwork for torrent / usenet downloads)', desc: 'new core/archive_pipeline.py — walks a directory for audio files (recursive, case-insensitive extensions), extracts zip / tar / tar.gz / rar / 7z archives in-place (rar and 7z are optional deps that warn but don\'t crash if absent), and rejects any archive member trying to escape the destination via path traversal. shared helper the upcoming torrent and usenet download plugins both consume — usenet downloaders usually auto-extract, but the occasional torrent ships an album in a .rar and SoulSync handles it now. 21 unit tests cover the walker + zip + tar extraction + path-traversal protection.' }, - { title: 'Indexers & Downloaders tab restyled with collapsible sections', desc: 'tab now opens with an intro hero card explaining the flow (indexers find releases → downloader fetches them → SoulSync imports) and folds the rest into three collapsible sections: Indexers, Torrent Client, Usenet Client. each section gets a per-service color accent (Prowlarr orange, torrent sky-blue, usenet violet), a status dot in the header (green when Test Connection succeeds, red on failure, grey before testing), and Lidarr-style indexer cards with protocol badges instead of the inline emoji list. Indexers section is open by default; the downloader sections start collapsed since not everyone uses both protocols.' }, - { title: 'Regression tests for the new indexer + downloader plumbing', desc: 'mocked unit tests for Prowlarr + all five downloader adapters (qBittorrent, Transmission, Deluge, SABnzbd, NZBGet). 54 tests pin the state-mapping tables, the parse logic, and the protocol quirks each client needs handled (qBit Referer header, Transmission session-id renegotiation, Deluge magnet-vs-URL method split, SAB queue+history merge, NZBGet 64-bit size fields). next time anyone touches one of these adapters, CI catches breakage before it hits a real downloader.' }, - { title: 'Usenet client adapters (SABnzbd, NZBGet)', desc: 'third commit in the torrent + usenet rollout. SoulSync now talks to SABnzbd and NZBGet through a sibling adapter contract that mirrors the torrent adapter set — pick one downloader in Settings → Indexers & Downloaders, fill in its API key (SABnzbd) or username + password (NZBGet), and Test Connection confirms the link. all three layers are now stood up: Prowlarr finds releases, the torrent adapter and the usenet adapter each know how to ship work to the underlying client. next commit wires Prowlarr search → adapter dispatch → archive extraction so the new sources actually download. job state mapping covers SABnzbd queue + history and NZBGet groups + history, including the verify/repair/unpack phases that are unique to usenet.' }, - { title: 'Torrent client adapters (qBittorrent, Transmission, Deluge)', desc: 'second commit in the torrent + usenet rollout. SoulSync can now talk to any of the three big torrent clients through a single adapter contract — pick which one you use in Settings → Indexers & Downloaders, paste your WebUI URL and credentials, and Test Connection confirms the link. each adapter handles its own auth quirk (qBit cookies, Transmission session-id, Deluge JSON-RPC) and maps native state strings onto a uniform set so the rest of the app stays client-agnostic. no download wiring yet — that gets layered on once the usenet client adapters land in the next commit.' }, - { title: 'Prowlarr integration', desc: 'new Indexers & Downloaders tab in Settings. point SoulSync at your Prowlarr instance with a URL and API key, and you can browse the indexers Prowlarr exposes from inside the app. this is the search half of the upcoming torrent and usenet download sources — wires up the indexer list now so later commits can plug the download flow on top. Lidarr already pulls from its own indexers; Prowlarr unlocks the same search surface to the rest of the download pipeline.' }, - ], - '2.5.8': [ - { date: 'May 20, 2026 — 2.5.8 release' }, - { title: 'Fix: blank artist pages on Python / git-pull installs', desc: 'PR #644 moved the artist detail page behind a TanStack React route. installs that pull from git but never run `npm install && npm run build` ship without the Vite bundle, so the legacy shell saw `/artist-detail/<source>/<id>` URLs and bailed — every click left a blank pane. the legacy startup path now parses the URL itself and hands off to the existing artist detail loader, so Python users get artist pages back without needing to build the webui. Docker / built installs still take the React route as before.' }, - { title: 'Fix: downloads marked complete before post-processing finished', desc: 'monitored Soulseek transfers were getting flipped to "successful" the moment slskd reported the file done — before SoulSync had actually run the post-processing worker (find on disk, fingerprint-verify, import to library). a failed import after that point left a phantom "completed" row that never made it into the library. completion now waits for the real post-processing result; if the worker can\'t even be scheduled, the task is marked failed and the batch slot is released so the queue keeps moving.' }, - { title: 'Disk-backed artwork cache', desc: 'image fetches now route through a disk + SQLite cache with hashed URLs, size / mime validation, stale fallback when the upstream is down, and per-image fetch locking so 12 simultaneous requests for the same album cover share one network round-trip. cuts repeat-load latency and survives metadata source rate limits. served from new `/api/image-cache`; `/api/image-proxy` stays as a compatibility shim.' }, - { title: 'Strict-source downloads check duration before pulling', desc: 'Tidal / Qobuz / HiFi / Deezer-DL / Amazon candidates now get a duration-tolerance check before the download starts, using the same tolerance logic post-processing would apply after. tracks whose duration is far enough off the metadata reference to fail the integrity check are skipped at pick time instead of after wasting the download. Soulseek and YouTube unchanged (they don\'t expose reliable pre-download duration).' }, - ], - '2.5.7': [ - { date: 'May 19, 2026 — 2.5.7 release' }, - { title: 'Fix: MusicBrainz manual search missing results', desc: 'the Fix popup and manual library service search were using strict Lucene phrase-match queries against the `recording` / `release` / `artist` fields — diacritics ("Bjork" vs canonical "Björk"), bracketed suffixes like "(Live)", and any AND-clause mismatch all killed recall. switched user-facing manual lookups to bare queries that hit MB\'s alias / sortname indexes with diacritic folding. enrichment workers stay strict for precision.' }, - { title: 'Fix: MusicBrainz album clicks 404ing in enhanced search', desc: 'every click on a MusicBrainz album result was silently 404-ing — the /release fetch was passing `cover-art-archive` as an `inc` param, which MB rejects with 400 (that field is returned on every release response by default, no include needed). dropped the bad include; album detail now loads correctly.' }, - { title: 'Fix popup: paste a MusicBrainz URL or MBID to match directly', desc: 'new escape hatch on the Fix Track Match modal (the 🔧 Fix button on mirrored / YouTube / Tidal / Deezer / Beatport / ListenBrainz / Spotify-public discovery rows). when fuzzy search keeps ranking the wrong recording among many same-title versions, paste the MusicBrainz recording URL like `https://musicbrainz.org/recording/<uuid>` or the bare UUID into the new field and hit "Look up". skips all fuzzy logic, resolves straight to that record, and runs it through the same confirm + match pipeline.' }, - { title: 'Fix popup: MusicBrainz added to the auto-search cascade', desc: 'the Fix Track Match modal used to query only Spotify → Deezer → iTunes for the auto-search, leaving MusicBrainz out of the loop entirely — even for users with MusicBrainz set as their primary metadata source. now MB is part of the cascade. when MB is your primary, it gets queried first; otherwise it sits as the last fallback. catches niche / non-mainstream / canonical-with-diacritics recordings that the commercial sources miss. Discogs is intentionally absent — Discogs has no track-level search API.' }, - { title: 'Fix: Docker basic-search streaming silently failed under rootless Docker', desc: 'the streaming "Play" flow on the basic search page tried to create `/app/Stream` lazily at runtime, which fails silently when the container runs under rootless Docker / Podman (in-container root can\'t write to `/app`). pre-baked the directory at image build time, matching the same pattern that fixed `/app/Staging` earlier in the cycle. non-persistent — no volume needed.' }, - { title: 'Fix: slskd-unreachable log spam during non-Soulseek downloads', desc: 'when slskd was configured but not actually running (or unreachable on its configured port), the `/api/downloads/status` polling loop fanned out to every download plugin including Soulseek, producing one `ERROR - Cannot connect to host ... [Name or service not known]` log line per poll for the entire duration of any download — visible spam even when the user wasn\'t using Soulseek at all. Connection failures now emit one WARNING with actionable context (start slskd, or clear the slskd_url if you don\'t use Soulseek) and demote subsequent failures to debug. The flag resets on the next successful slskd response so a later outage warns again.' }, - { title: 'Fix: MusicBrainz "Other" release-groups now visible in discography', desc: 'MB tags music videos, one-off web releases, and broadcast singles with `primary-type=Other` — common pattern for Vocaloid producers, JP indie artists, and some Western indie acts. The release-group browse filter only requested `album|ep|single`, dropping every Other-typed group at the API layer. Combined with the inline type mapper defaulting unknown primary types to "album", this hid legitimate tracks from the artist detail page and left downloaded tracks orphaned (visible in track counts but not bound to any visible album / single card). Centralised the type-mapper into one shared helper (`core/metadata/release_type.py`) used by every provider\'s raw→Album projection, added `Other` and `Broadcast` handling that routes those release-groups into the Singles section, and added `other` to the MB API filter. For inabakumori, this surfaces 5 previously-invisible releases. 23 unit tests pin the mapper contract; existing 65 search-adapter tests still green.' }, - { title: 'Fix: quarantined files no longer re-downloaded on auto-wishlist cycles', desc: 'when a file failed AcoustID verification and got quarantined, the next auto-wishlist run would search for the same track again, and the candidate picker\'s deterministic quality ranking kept selecting the same `(uploader, filename)` source — re-downloading and re-quarantining the exact same file every cycle. Users woke up to hundreds of duplicate `.quarantined` entries from a single bad upload. The Soulseek candidate filter now reads quarantine sidecars and drops any candidate whose `(username, filename)` matches a previously-quarantined source before the quality picker ranks it. Filesystem read on every search (sub-ms in practice). Approving or deleting a quarantine entry removes its source from the dedup set automatically — the user can give the source a second chance by explicitly approving / deleting the quarantine record.' }, - { title: 'Fix: Unknown Artist Fixer tool crashed on every run', desc: 'the "Fix Unknown Artists" repair job crashed instantly with `ImportError: cannot import name \'_build_path_from_template\'` — totally unrunnable. Commit ca5c9316 ("Rewrite Library Reorganize job to delegate to per-album planner") moved the private path-builder + quality-string helpers out of `core.repair_jobs.library_reorganize` and into the import pipeline (`core.imports.paths` / `core.imports.file_ops`), but the Unknown Artist Fixer\'s deferred-import path was left pointing at the old module. Re-wired to the new locations. Added two regression tests that exercise the deferred imports directly — the next refactor that moves these helpers fails CI rather than reaching the user.' }, - ], - '2.5.6': [ - { date: 'May 18, 2026 — 2.5.6 release' }, - { title: 'MusicBrainz as Primary Metadata Source', desc: 'MusicBrainz is now a full primary metadata source on equal footing with Deezer, iTunes, Spotify, and Discogs. switch to it in Settings → Metadata Source — always available, no account or API key needed, rate-limited to 1 req/sec. covers all primary source flows: search, album/track/artist lookup, watchlist scans, discover hero, similar artist backfill, artist map.', page: 'settings' }, - { title: 'Fix: MusicBrainz artist detail showing MBID as name', desc: 'clicking a MusicBrainz artist from search results was showing the raw MBID as the artist name on the detail page. URL-driven routing (PR #644) no longer passes the display name to the backend, so the source detail endpoint now looks it up directly from MusicBrainz by MBID.' }, - { title: 'Fix: artist detail back button always showing "← Back"', desc: 'PR #644 removed the back-button label logic along with the origin stack. restored: a label stack (separate from browser history, which handles actual navigation) tracks where you came from across the full similar-artist chain — "← Back to Search", "← Back to Artist A", "← Back to Artist B", etc. API response backfills the current artist name so the stack has real names when clicking similar artists.' }, - { title: 'Fix: Amazon search albums/artists missing, album downloads all track 01', desc: 't2tunes proxies Amazon Music and uses 400 to signal transient failures — first API call in a session hit this consistently, so album/artist searches always failed while track search (called 0.5s later) scraped through. added up to 3 retries with backoff on t2tunes-specific 400s. also: all search methods were using types=track,album but t2tunes album-type queries are broken — switched everything to types=track and derive albums/artists from track metadata instead. track numbers from album downloads were also always 1 — added index-based fallback when t2tunes tags omit trackNumber.' }, - ], - '2.5.5': [ - { date: 'May 17, 2026 — 2.5.5 release' }, - { title: 'Manual Library Match', desc: 'stop SoulSync from re-downloading tracks it already has. new centralized tool (Tools page → Manual Library Match, or Sync page → Library Match button) lets you search your wishlist / sync history on the left and your library on the right, then link them. once matched, that source track is permanently skipped in wishlist cleanup and the download analysis loop — even when force download is on. manage all your matches in one place with remove support.', page: 'tools' }, + // Convention: keep only the CURRENT release here, plus a single brief + // "Earlier versions" summary entry. Don't accumulate old per-version blocks. + '2.7.0': [ + { date: 'June 2026 — 2.7.0 release' }, + { title: 'Connect your own streaming accounts — My Accounts', desc: 'multi-user gets real. the new My Accounts panel (the music button by your profile) lets anyone connect their own spotify / tidal / listenbrainz, and from then on your playlist browsing and pulling uses your account, not the admin\'s. metadata + downloads stay on the admin\'s global accounts so background work is unchanged — only playlist reads are personal, and two people can browse their own playlists at the same time. admin and anyone who hasn\'t connected fall back to the global accounts exactly as before.', page: 'settings' }, + { title: 'Auto-sync runs as you', desc: 'background automations now run as their owner profile — a non-admin\'s scheduled auto-sync pulls their playlist from their account instead of falling back to the admin\'s. admin pipelines are untouched.', page: 'sync' }, + { title: 'Quick-switch active sources (admin)', desc: 'the sidebar Service Status panel is now clickable (admin only) — switch the active metadata source / media server / download source from one modal, with brand logos and real hybrid drag. the Manage Profiles modal got a visual revamp too.', page: 'dashboard' }, + { title: 'User accounts & login (opt-in)', desc: 'turn on "require login" in Settings → Security and each profile becomes a real account: a username + password sign-in screen replaces the picker + PIN. passwords are hashed and never sent to the browser, brute-force limited, with anti-lockout (you can\'t enable it until the admin has a password). off by default — your existing PIN / LAN setup is untouched.', page: 'settings' }, + { title: 'Forgot-password recovery', desc: 'set a security question + answer; if you forget your login password you answer it on the sign-in screen to reset. the answer is hashed and matched forgivingly.', page: 'settings' }, + { title: 'Run securely behind a reverse proxy', desc: 'opt-in reverse-proxy mode trusts X-Forwarded-* (correct client IP + https), marks the session cookie secure, and adds security headers — for running behind nginx / caddy / traefik. off by default so direct http:// LAN access is untouched. a full setup guide ships in Support/REVERSE-PROXY.md.', page: 'settings' }, + { title: 'Auth-proxy + PIN hardening', desc: 'optionally let authelia / authentik / oauth2-proxy be the gatekeeper (trust a Remote-User header), and the launch PIN now backs off after repeated wrong tries. the whole Security settings page was reorganized into clear PIN / user-accounts / reverse-proxy groups with step-by-step setup.', page: 'settings' }, + { title: 'Fixes', desc: 'a "/" in a song title no longer truncates the search and quarantines youtube/tidal downloads (#835); a rejected slskd download no longer hangs forever (#836); manual find & add appends to a jellyfin/emby playlist instead of recreating it (#837); auto-sync no longer caps public spotify playlists at 100 tracks (#838); "discovery state not found" after a restart is fixed (#843); and find & add search now puts exact title matches first instead of burying them.', page: 'downloads' }, + { title: 'Artist Sync is now a mini deep scan', desc: 'the sync button on an artist page reuses the same server-diff stale-removal as the whole-library deep scan, scoped to one artist — picking up new/changed tracks and removing ones the server no longer has, with the same safety guards (no more disk-check mass-deletes on an unreachable mount).', page: 'library' }, + { title: 'Earlier versions', desc: 'before 2.7.0, the 2.6.x cycle brought the artist/track/album blocklist, the download-retry overhaul, Download Origins + retention cleanup, server-side launch-PIN enforcement + secret masking, Spotify-no-auth metadata, Import IDs from File Tags, Library Re-tag, and a large pile of import / library / watchlist fixes.' }, ], }; @@ -3634,492 +3457,56 @@ const WHATS_NEW = { // usage_note?: 'optional hint shown at the bottom' } const VERSION_MODAL_SECTIONS = [ { - title: "Spotify Free — metadata with no credentials (#798)", - description: "a new \"Spotify Free\" metadata source that uses Spotify's public web-player data, so you can get Spotify search + enrichment without connecting an account. For connected users it also bridges rate-limit bans automatically — when your real auth is rate-limited, enrichment keeps running through the free source instead of stalling, then returns to your real auth once the ban lifts.", + title: "Per-profile streaming accounts", + description: "multi-user gets real. each profile can connect its own spotify / tidal / listenbrainz from the new My Accounts panel (the music button by your profile), and from then on your playlist browsing and pulling uses your account instead of the admin\'s.", features: [ - "pick \"Spotify Free (no credentials)\" in Settings → Metadata to use it without a Spotify account", - "automatic rate-limit bridge for connected users — no more enrichment stalling out during a ban", - "the worker resumes mid-ban, the dashboard shows \"Running (Spotify Free)\" instead of looking stuck", - "the daily-API budget never pauses a Spotify-Free user — when the real-API budget is spent it switches to free (uncapped) for the rest of the day instead of stopping", - "opt-in; requires the optional spotapi package", + "connect your own spotify / tidal / listenbrainz — playlist reads then use your account", + "metadata + downloads stay on the admin\'s global accounts, so background work is unchanged", + "two people browse their own playlists at the same time without stepping on each other", + "background auto-sync runs as its owner profile, pulling their playlist from their account", + "admin + anyone who hasn\'t connected fall back to the global accounts exactly as before", ], - usage_note: "Settings → Metadata → Spotify Free", + usage_note: "the music (My Accounts) button by your profile", }, { - title: "Import IDs from File Tags", - description: "your files often already carry the Spotify / MusicBrainz / iTunes / Deezer IDs that SoulSync (or MusicBrainz Picard) embedded when they were tagged — but the media-server scan can't see them. This tool reads them straight from the files into the database, so the enrichment workers can skip those lookups entirely. On an already-tagged library that's a large amount of saved API traffic.", + title: "Secure access — login, recovery & reverse proxy (opt-in)", + description: "SoulSync can now stand on its own as a secured app, or sit cleanly behind a reverse proxy. all of it is off by default, so a normal LAN setup behaves exactly as before and the launch PIN keeps working untouched.", features: [ - "reads embedded Spotify / iTunes / MusicBrainz / Deezer / Tidal / AudioDB / Genius / Last.fm IDs into the DB", - "gap-fill only — never overwrites an existing match, and atomically guarded against races", - "new tracks also get it automatically as the final phase of every library scan", - "enrichment workers skip any entity that already has an ID, so this directly cuts API calls", + "native login: each profile becomes a real username + password account; the sign-in screen replaces the picker + PIN", + "passwords hashed + never sent to the browser, brute-force limited, anti-lockout (can\'t enable until the admin has a password)", + "forgot-password recovery via a security question you set", + "reverse-proxy mode: trusts X-Forwarded-*, secure cookies, security headers — for nginx / caddy / traefik (see Support/REVERSE-PROXY.md)", + "auth-proxy trust (authelia / authentik / oauth2-proxy) + launch-PIN brute-force backoff", ], - usage_note: "Tools → Database & Scanning → Import IDs from File Tags", + usage_note: "Settings → Security", }, { - title: "Library Re-tag", - description: "a proper Library Re-tag job (replacing the old Retag tool): it matches each file to its source tracklist and rewrites the tags — title, artist, album, track / disc numbers, cover art, and the embedded source IDs — and shows a per-track old→new diff so you can review before anything is written.", + title: "Quick-switch active sources (admin)", + description: "the sidebar Service Status panel is now clickable — switch the active metadata source, media server, and download source from one modal, with brand logos, a hero header, and real hybrid drag. it surfaces the configured-vs-effective source so a no-auth source is never confused with the real one.", features: [ - "per-track old→new diff in the finding card before you apply", - "standard dry-run pattern — see what would change, then opt in to apply", - "Light / Full depth settings; cover art + metadata pulled from your configured source order", - "writes the embedded source IDs too, so re-tagged files survive future scans", + "switch metadata / media-server / download source in one place", + "real drag-to-reorder for hybrid source priority", + "Manage Profiles modal got a visual revamp too", ], - usage_note: "Dashboard → Manage Workers → Library Re-tag", + usage_note: "click the sidebar Service Status panel", }, { - title: "Paste a metadata link to open it (#775)", - description: "the Search page now takes a pasted metadata link. Drop in a Spotify / iTunes / Deezer artist, album, or track URL and SoulSync opens that exact item instead of running a name search — perfect when a name search is ambiguous or you already have the link.", + title: "Fixes this release", + description: "a round of bug fixes alongside the big features.", features: [ - "paste an artist / album / track URL → opens the exact item", - "bare IDs are rejected as ambiguous (a link carries the entity type)", - "clear not-found hint when nothing resolves", - ], - usage_note: "Search page", - }, - { - title: "Recent Fixes & Polish (2.6.7)", - description: "a stack of fixes and refinements that shipped alongside the headline features.", - features: [ - "Manual album match now LOCKS the edition it's pinned to (#758) — the auto resolver can't drag it back to the deluxe", - "Write Tags won't overwrite a correct file with placeholder data like \"Various Artists\" / \"[Unknown Album]\" (#800)", - "AcoustID stops false-quarantining correct downloads of non-English artists (#797)", - "full mobile / small-screen responsive pass across the app (#793, #795)", - "new \"Reconcile\" playlist sync mode that edits in place and keeps your custom image / description (#792)", - "fixes: wrong artist on duplicated source IDs, manual fixes reverting on mirrored sync (#799), Find & Add matches surviving rescans (#787), Navidrome library selection (#789), silent streamed tracks, torrent URL without scheme (#790), file/CSV playlist matching (#785)", - ], - usage_note: "browse the What's New panel for the full 2.6.7 changelog", - }, - { - title: "Artist Map, Reimagined", - description: "the Discover artist map got a full rework — it now reads like a living constellation of your library instead of a flat blob. Explore one genre island at a time, watch bubbles bloom into place, and open a side panel with everything about the artist under your cursor.", - features: [ - "one-island-at-a-time genre view so dense libraries stay readable", - "bubbles surface up into place with live, streamed cover art", - "right-side info panel: live watchlist state, library coverage, top artists, and an artist card", - "API-backed toolbar search + genre quick-jump", - "fully mobile responsive — bottom-sheet panel, full-width map, reflow on resize", - ], - usage_note: "Discover → Artist Map", - }, - { - title: "Recommendations That Explain Themselves", - description: "SoulSync now builds a library-wide similar-artists graph (a new MusicMap enrichment worker, matched to your real metadata sources) and turns it into recommendations that actually tell you WHY — \"Because you have X & Y\" — promoted into a first-class section on Discover instead of a buried button.", - features: [ - "new Similar Artists enrichment worker with its own dashboard orb, runs across your whole library", - "every stored similar is matched to a real metadata source, so it's actually usable", - "recommendations show the artists in YOUR library that point to each suggestion", - "new \"Recommended For You\" carousel on the Discover page, add to watchlist in one click", - ], - usage_note: "Discover → Recommended For You", - }, - { - title: "Cover Art Filler That Actually Fills Your Files", - description: "the Cover Art Filler used to only check the database and only update a database thumbnail. Now it finds albums missing art ON DISK and, when you apply it, embeds the artwork into your audio files and writes a cover.jpg — using the same path the importer uses, so your preferred cover-art source order is honored.", - features: [ - "detects albums with no embedded art and no cover.jpg, even when the database had a URL", - "applying art embeds it into the files + writes cover.jpg (not just a DB thumbnail)", - "purely additive — never rewrites your existing tags, skips files that already have art", - "stricter matching so the new sources stop attaching the wrong cover (title AND artist must match)", - ], - usage_note: "Dashboard → Manage Workers → Cover Art Filler", - }, - { - title: "Recent Fixes & Performance (2.6.6)", - description: "a round of fixes and a WebUI speed-up that landed alongside the headline features.", - features: [ - "qBittorrent 5.2.0 / 5.2.1 now connects — it changed its login to return HTTP 204 and SoulSync was rejecting it (no more whitelist workaround needed)", - "Organize by playlist (#780): library registration, wishlist after failed downloads, filesystem-aware Download Missing, and stale-cache fixes", - "faster navigation + smoother scrolling (#783): press-to-navigate, canvases pause during scroll, parallel dashboard load, plus a new Reduce Visual Effects mode", - "dashboard mobile responsiveness + a subtle living-nucleus on the Manage Workers hub", - ], - usage_note: "browse the What's New panel for the full 2.6.6 changelog", - }, - { - title: "iTunes / Apple Music Link Import", - description: "new iTunes Link tab on the Sync page. paste an Apple Music album, track, or playlist URL and SoulSync pulls the tracklist, runs it through discovery, and lets you sync or download like any other link source.", - features: [ - "new iTunes Link tab on the Sync page, sitting between Deezer Link and YouTube", - "accepts album, track, and playlist URLs from music.apple.com or iTunes", - "tracklist runs through the same discovery → sync → download pipeline as Deezer Link / YouTube", - "handles the current Apple Music SPA token flow — token is scraped from the JS bundle on first use, cached for 6 hours, and refetched automatically on 401 if Apple rotates", - ], - usage_note: "Sync → iTunes Link → paste URL → Load", - }, - { - title: "Qobuz Playlist Sync", - description: "Qobuz joins Tidal and Deezer as a first-class playlist sync source on the Sync page. Browse your Qobuz playlists and Favorite Tracks, run them through the same discovery flow as Tidal, sync the resulting Spotify-matched tracks, and queue downloads — same multi-step pipeline you already know.", - features: [ - "new Qobuz tab on the Sync page, grouped with Tidal alongside the other lossless services", - "lists your Qobuz user playlists plus a Favorite Tracks entry (same virtual-playlist treatment Tidal gets)", - "click any card to fire discovery (Spotify-preferred, your primary metadata fallback otherwise), then sync or download just like Tidal / Deezer playlists", - "uses the Qobuz auth token you already configured for downloads — no extra connection step", - ], - usage_note: "Sync → Qobuz → 🔄 Refresh", - }, - { - title: "Earlier in v2.5", - description: "highlights from the 2.5.x cycle that landed just before 2.6.0: new release-based download sources, HiFi instance probing, Jellyfin full-refresh repairs, transient SQLite retries, and tighter Album Completeness artist matching.", - features: [ - "Torrent and Usenet are now available as Prowlarr-backed download sources with release staging, live progress, and source-aware history labels", - "HiFi instances are probed by actually checking whether they can return a playable manifest, including legacy public hifi-api instances", - "Jellyfin full refresh repairs older media databases before importing tracks, so stale schemas no longer drop every track row", - "Cache maintenance and full refresh retry transient SQLite disk I/O errors instead of failing the whole job on the first attempt", - "Album Completeness rejects same-title releases from the wrong artist before importing anything", - "Import album search now labels each card with the source that served it and warns when results came from a fallback instead of your primary", - ], - usage_note: "browse the What's New panel for the full 2.5.x changelog", - }, - { - title: "Torrent and Usenet Are Now Live Download Sources", - description: "the long-awaited payoff. Two new entries in the Download Source dropdown — Torrent Only and Usenet Only — both backed by your Prowlarr indexers. Searches go through Prowlarr filtered by protocol, picked releases get shipped to your configured torrent client (qBit / Transmission / Deluge) or usenet client (SABnzbd / NZBGet), and the resulting files flow through SoulSync's matching pipeline like any other source.", - features: [ - "• new 'Torrent Only (via Prowlarr)' and 'Usenet Only (via Prowlarr)' options in Settings → Downloads → Download Source", - "• both sources also available in hybrid mode — drop them into the priority list alongside soulseek / tidal / etc.", - "• shared archive_pipeline walks the downloaded directory and extracts any .zip / .rar / .tar archives the downloader didn't already unpack (with path-traversal protection)", - "• torrent state polling handles the qBit / Transmission / Deluge state quirks uniformly; usenet polling covers the verify / repair / unpack phases", - "• picking a torrent or usenet source on the Downloads tab now shows a redirect card pointing to the Indexers & Downloaders tab where the actual config lives", - "• caveat: SoulSync needs filesystem access to the torrent / usenet client's save_path. fine for everything-on-one-box; remote downloader hosts need a future sync step", - ], - usage_note: "Settings → Downloads → Download Source → Torrent / Usenet Only", - }, - { - title: "Usenet Client Adapters (SABnzbd, NZBGet)", - description: "third phase of the torrent + usenet rollout. SoulSync now also talks to the two big usenet downloaders through a sibling adapter contract. Prowlarr + torrent + usenet are all stood up — next commit wires them together into actual download sources.", - features: [ - "• supports SABnzbd (API-key auth) and NZBGet (JSON-RPC basic auth)", - "• new Usenet Client section on the Indexers & Downloaders tab; client picker swaps the credential fields automatically (API key vs username + password)", - "• state mapping covers the verify / repair / unpack phases unique to usenet", - "• category override so SoulSync's NZBs land in a predictable post-processing folder", - "• Test Connection probes the live API", - "• next commit wires Prowlarr → adapter → archive extraction → match so the new sources fully download", - ], - usage_note: "Settings → Indexers & Downloaders → Usenet Client", - }, - { - title: "Torrent Client Adapters (qBit, Transmission, Deluge)", - description: "second phase of the torrent + usenet rollout. SoulSync now speaks the three big torrent client APIs through one uniform adapter — pick which client you use and SoulSync handles the auth and protocol quirks for you.", - features: [ - "• supports qBittorrent (WebUI v2), Transmission (RPC), Deluge 2.x (JSON-RPC)", - "• new Torrent Client section on the Indexers & Downloaders tab", - "• single client type picker — switching between clients is a dropdown change, no code path divergence", - "• per-client credential fields with hints (qBit / Transmission use username + password, Deluge uses password only)", - "• optional category/label and save-path overrides so SoulSync's torrents are easy to spot in your client", - "• Test Connection probes the live WebUI and confirms auth works", - "• no downloads triggered yet — the wire-up to Prowlarr search results lands once usenet client adapters ship", - ], - usage_note: "Settings → Indexers & Downloaders → Torrent Client", - }, - { - title: "Prowlarr Integration (Phase 1 of Torrent + Usenet)", - description: "first commit toward torrent and usenet download sources. SoulSync can now talk to your Prowlarr instance and pull the list of configured indexers, setting up the search surface that the torrent and usenet clients will plug into next.", - features: [ - "• new Indexers & Downloaders tab on the Settings page", - "• point SoulSync at Prowlarr with a URL and API key — same kind of setup as Lidarr", - "• Test Connection button confirms Prowlarr is reachable and authenticated", - "• Refresh Indexer List pulls the full list of indexers Prowlarr is currently managing (torrent + usenet, enabled state, privacy level)", - "• optional indexer-ID allowlist if you want SoulSync to only search a subset", - "• no downloads yet — torrent and usenet client adapters land in the next commits", - ], - usage_note: "Settings → Indexers & Downloaders → Prowlarr", - }, - { - title: "MusicBrainz Is Now a First-Class Metadata Source", - description: "MusicBrainz was already available as an optional search tab, but it wasn't selectable as your primary metadata source. now it is — switch to it in Settings → Metadata Source and the whole app routes through it.", - features: [ - "• always available — no account, no API key, no token needed", - "• rate-limited to 1 req/sec at the client layer, consistent with MusicBrainz's terms", - "• covers the full primary-source interface: search, album/track/artist lookup, top tracks, artist albums, discography", - "• watchlist scanner now backfills MusicBrainz artist IDs alongside Spotify / iTunes / Deezer in the similar artists table", - "• discover hero, artist map, and personalized playlists all source from MusicBrainz IDs when it's the active primary", - "• cover art served via Cover Art Archive — no extra API calls, browser fetches the URL directly", - "• fallback source logic in Settings and the registry now reads from source priority order instead of hardcoding 'deezer'", - ], - usage_note: "Settings → Metadata → Primary Source → MusicBrainz", - }, - { - title: "Live Recordings Stop False-Quarantining", - description: "github issue #607: live recordings were quarantining as 'Version mismatch: expected ... (live) but file is ... (original)' because MusicBrainz often stores live recordings with bare titles — venue annotations live on the release entity, not the recording entity itself. AcoustID's fingerprint correctly identified the live recording, but the title-text comparison flagged it as wrong.", - features: [ - "• new escape valve in the version-mismatch gate: when one side is 'live' and the other is bare 'original' + fingerprint score >= 0.85 + bare titles match + artist matches, accept", - "• other version mismatches (instrumental vs vocal, remix vs original, acoustic vs studio, demo) stay strict — those have distinct fingerprints AND MB always annotates them in the recording title", - "• fingerprint-score floor of 0.85 is stricter than the existing 0.80 minimum, so the escape valve only fires when AcoustID is more confident than its own threshold", - "• logic lifted to pure helper core/matching/version_mismatch.py with 23 boundary tests + the existing instrumental/remix tests still pin those cases as strict mismatches", - "• audio-mismatch failure message now reports the actual best-matching recording's strings, not recordings[0]'s — prior code mixed strings from one candidate with scores from another, producing nonsense reasons like \"identified as '' by '' (artist=100%)\"", - ], - usage_note: "no settings to change — applies on next download attempt of a live recording", - }, - { - title: "Quarantine Modal: Apostrophe Bug Fixed + Source Info Added", - description: "github issue #608: quarantined files with an apostrophe in the filename couldn't be Approved or Deleted — the unescaped quote broke the inline JS in the onclick handler, so the click silently no-op'd. Plus a UX add: each entry now shows the source uploader and original soulseek filename when available.", - features: [ - "• id is now wrapped via escapeHtml(JSON.stringify(id)) so apostrophes (and backslashes, double quotes, unicode, newlines) round-trip safely through the HTML attribute → JS string boundary", - "• quarantine entry expanded view shows source uploader (username) and original soulseek filename when the sidecar carries that context — helps trace which uploader the bad file came from", - "• degrades gracefully when the sidecar lacks the source fields (legacy thin sidecars) — fields just hide", - ], - usage_note: "open Library History → Quarantine tab", - }, - { - title: "Reorganize Can Now Use Your Embedded File Tags Instead Of An API Lookup", - description: "github issue #592: for users with a well-enriched, well-tagged library, doing a fresh metadata-source api lookup at reorganize time can drift from what's already on the file — provider naming inconsistencies, missing album-level metadata for niche releases. new optional mode reads each file's embedded tags as the source of truth instead. zero api calls.", - features: [ - "• new \"metadata mode\" dropdown on the per-album reorganize modal + bulk reorganize-all modal: 'API metadata' (default, current behavior) vs 'Embedded file tags'", - "• tag-mode reads via the same mutagen path the audit-trail modal uses — id3 / vorbis / mp4 all covered", - "• handles id3-style \"5/12\" track + disc shapes, multi-value Artists tags, year normalization across 5 date formats, releasetype canonical tokens (album / single / ep / compilation)", - "• partial-album reorganize respects the \"totaldiscs\" tag — disc 1 of a 2-disc set still routes into Disc 1/ subfolder", - "• each track's own album metadata flows through post-process (not a shared one), so per-file enrichment differences are preserved", - "• default stays 'API metadata' so existing reorganize pipelines are completely untouched — opt-in only", - "• per-track unmatched reasons surface in the preview when a file's tags are missing essentials, so you can see exactly which tracks need attention", - ], - usage_note: "artist detail → album → reorganize → 'metadata mode' dropdown; or pass `mode: 'tags'` to the api endpoint", - }, - { - title: "Dashboard Cursor-Following Accent Blob", - description: "the bento dashboard now has a subtle two-layer accent glow that follows your cursor as you sweep across cards. card backgrounds are darker too for better contrast.", - features: [ - "• soft halo (large + blurred) lerps toward your cursor with a delay — liquid trailing feel", - "• brighter inner core (smaller + screen-blended) gives the blob a punchy center", - "• both layers gently pulse on different rhythms (5.5s halo, 3.7s core) so it feels alive", - "• mouse leaves the cards or sits in a gap → blob freezes for 1.5s then drifts back to grid center", - "• container backgrounds darkened to near-black with stronger borders for contrast", - "• fully disabled by the existing reduce visual effects setting", - "• performant: only animates while moving, batches getBoundingClientRect reads per frame", - ], - usage_note: "nothing to configure — visit the home page; toggle reduce visual effects in settings to disable", - }, - { - title: "Dashboard Got A Bento Redesign", - description: "old dashboard had a lot of wasted space and sections fighting for attention. rebuilt as a bento grid with accent-tinted cards and subtle motion.", - features: [ - "• every section lives in its own card with a soft accent-tinted glow matching your theme color", - "• cards fade up on first paint with a staggered reveal — feels alive without being noisy", - "• layout adapts: 3-col bento on desktop (≥1500px), 2-col on laptop, 2-col tighter on tablet, single-column on mobile", - "• enrichment service gauges ride a single 10-tile row at desktop and wrap to 5 / 4 / 3 / 2 as space tightens", - "• system stats render 3-up over 2 rows so all 6 metrics fit without scrolling", - "• recent syncs stack vertically inside their card so you can scan them at a glance", - "• every existing button + status indicator + id preserved — same dashboard, just laid out properly", - ], - usage_note: "nothing to configure — visit the home page to see it", - }, - { - title: "Big Sync Sessions No Longer Wedge After 2-3 Hours", - description: "github issue #499 (bafoed): downloading a big initial sync from spotify playlists worked for 2-3 hours then silently stopped. 3 active tasks stuck in \"searching\" state, replaced every ~10 min, slskd ui showed no actual activity. only fix was restarting the container.", - features: [ - "• root cause: `aiohttp.ClientSession()` was constructed with no timeout — when slskd hung (overloaded / network blip / internal stall), the http call blocked forever and the worker thread blocked with it", - "• download executor only has 3 worker threads — once all 3 wedged on hung calls, no further downloads could start", - "• fix: bounded `aiohttp.ClientTimeout` (total 120s, connect 15s, sock_read 60s) on every slskd session — slskd metadata calls finish in seconds, so the timeout can\'t kill a real operation", - "• timeout fires → caught + logged + return None → caller treats as a normal failure (same code path as a 5xx response) → worker thread unblocks → executor stays healthy", - "• 3 new pinning tests on the timeout config + handler so future drift fails at the test boundary, not against a wedged executor in production", - ], - usage_note: "no settings to change — applies on next container restart", - }, - { - title: "Library Reorganize No Longer Mistakes Album Tracks for Singles", - description: "github issue #500 (bafoed): library reorganize repair job was moving album tracks like `01 - Christine F.flac` to single-template paths because of a fragile classification heuristic.", - features: [ - "• pre-rewrite the job had its own tag-reading + transfer-folder walk + template logic — used `is_album = (group_size > 1)` where group_size was the count of same-album tracks in the transfer folder being scanned", - "• when only one track of an album sat in transfer (rest already moved, or album tags varied slightly like \"Buds\" vs \"Buds (Bonus)\") → group size 1 → routed to single template → wrong destination", - "• fix: delegate to the per-album planner the artist-detail \"reorganize\" modal already uses — db-driven, knows the album has n tracks regardless of how many currently sit in transfer", - "• only iterates albums on the ACTIVE media server (matches what the artist-detail modal sees) — multi-server users (plex + jellyfin etc) won\'t accidentally have the job touch the inactive server\'s files", - "• apply mode dispatches to the existing reorganize queue → one code path for file move + post-processing + db update + sidecar", - "• albums missing a metadata source id get a single \"needs enrichment first\" finding instead of n per-track \"no source\" findings cluttering the ui", - "• dropped ~500 loc that was duplicated against the per-album logic — files in transfer with no db entry are now exclusively the orphan file detector\'s domain", - ], - usage_note: "no settings to change — applies on next library reorganize repair job run", - }, - { - title: "Enrich Now Honors Manual Album Matches", - description: "github issue #501 (tacobell444): manually matching an album then clicking enrich would overwrite your manual match with whatever the worker\'s name-search returned, or revert status to \"not found\". reorganize then read the wrong id and moved files to the wrong destination.", - features: [ - "• every per-source enrichment worker (spotify / itunes / deezer / tidal / qobuz) now reads its stored id column at the top of `_process_*_individual` — if present, fetch directly via that id and refresh metadata without touching the id", - "• fuzzy name search only runs as fallback for entities that have never been matched", - "• discogs / audiodb / musicbrainz already had inline stored-id fast paths and are left alone — same correct behavior, just inline", - "• lastfm / genius are name-based and don\'t store ids — no-op for those", - "• cin-shape lift: same fix in 5 workers gets exactly one shared helper at `core/enrichment/manual_match_honoring.py`, per-worker variability (column name / client method / response shape) plugs in via callbacks", - "• reorganize fixed indirectly — it always honored stored ids correctly, the bug was upstream in enrich corrupting the id", - ], - usage_note: "no settings to change — applies on next click of enrich on a manually-matched album or track", - }, - { - title: "HiFi Instance Add No Longer Errors With \"no such table\"", - description: "github issue #503 (hadshaw21): adding a hifi instance from downloader settings popped up `no such table: hifi_instances` even when the connection test passed.", - features: [ - "• root cause: the bulk db init runs every CREATE TABLE + every migration step inside one sqlite transaction — python\'s sqlite3 module doesn\'t autocommit DDL, so if any later migration throws on your DB shape, the WHOLE batch rolls back including the hifi_instances create that ran successfully", - "• fix: defensive lazy-create — every hifi_instances CRUD method now runs `CREATE TABLE IF NOT EXISTS` right before its operation", - "• idempotent — one no-op cost when the table is already there, fully self-heals when it isn\'t", - "• read methods return empty instead of raising; write methods work end-to-end", - "• doesn\'t paper over the underlying init issue (still worth tracking which migration breaks for which users) but makes hifi instance management work regardless", - ], - usage_note: "no settings to change — applies on next click of \"add\" in hifi instance settings", - }, - { - title: "Plex: Combine All Music Libraries Into One", - description: "github issue #505 (popebruhlxix): users with multiple plex music libraries (e.g. one per plex home user) only saw one library inside soulsync because settings forced you to pick a single library section.", - features: [ - "• new \"all libraries (combined)\" option in settings → connections → plex → music library dropdown — only shows up when your server has more than one music library", - "• picking it flips the plex client into server-wide read mode — every scan / search / library-stat call dispatches through `server.library.search()` instead of querying a single section", - "• one api call, plex handles the aggregation — no per-section iteration code on our side", - "• cross-section dedup at the listing layer — same-name artists across sections (e.g. plex home families that both have drake) collapse to one canonical entry in your library list, so no more visual duplicates", - "• removal detection stays on raw ratingKeys — deduping there would falsely prune tracks linked to non-canonical entries", - "• write methods (genre / poster / metadata updates) and playlists are unaffected — section-agnostic, operate on plex objects via ratingKey", - "• trigger_library_scan + is_library_scanning fan out across every music section in the new mode", - "• backward compatible — existing users with a single library saved see no behavior change", - ], - usage_note: "settings → connections → plex → music library → pick \"all libraries (combined)\"", - }, - { - title: "Download Discography No Longer Shows Wrong Artist", - description: "clicked download discog on 50 cent → modal showed young hot rod\'s albums. clicked weird al → modal showed beatles albums. real bug, not just data weirdness.", - features: [ - "• endpoint received whichever single artist id the frontend happened to pick and dispatched it as-is to whichever source it queried — when the picked id didn\'t match the queried source\'s id format, lookup either returned wrong-artist results (numeric id collisions) or fell back to a fuzzy name search that picked a different artist", - "• fixed: backend now looks up the library row by ANY stored id (db id, spotify, itunes, deezer, musicbrainz) and dispatches the correct stored id to each source — every source gets its OWN id regardless of what the frontend chose to send", - "• mechanism already existed (`MetadataLookupOptions.artist_source_ids`) and the watchlist scanner already used it — discog endpoint just wasn\'t wired to it", - "• also fixed two log-namespace bugs: enhance quality + multi-source search were writing to a logger with no handlers, so every diagnostic line was silently dropped — now lands in app.log where you can actually see them", - ], - usage_note: "no settings to change — applies on next click of download discography", - }, - { - title: "Enhance Quality Now Behaves Like Download Discography", - description: "discord report — clicking enhance quality on an artist with no spotify/deezer was adding tracks as \"unknown artist - unknown album - unknown track\". root issue ran deeper than that single edge case.", - features: [ - "• enhance used to fuzzy text search the configured primary source only — a single-source itunes fallback returning junk matches with empty fields, while track redownload had been doing parallel multi-source search the whole time", - "• extracted that search into a shared module; both enhance and redownload now hit every configured source in parallel and pick the cross-source best match", - "• bigger fix: enhance now uses stored source IDs (spotify_track_id / deezer_id / itunes_track_id / soul_id) the same way download discography uses album IDs — direct lookup against each source's API, no fuzzy text matching, no failures from messy tags like \"Title (Live)\" or featured artists in the artist field", - "• preferred source (your configured primary) tried first so a deezer-primary user gets deezer payloads on the wishlist entry", - "• text search is only the fallback now — kicks in only when no stored IDs exist for the track", - "• modal toast no longer lies \"matching tracks to spotify\" regardless of which sources are actually configured", - ], - usage_note: "no settings to change — applies on next click of enhance quality", - }, - { - title: "Watchlist No Longer Re-Downloads Compilations", - description: "compilation / soundtrack tracks were getting redownloaded on every watchlist scan because the album-name fuzzy check failed on naming drift between spotify and your media server.", - features: [ - "• example: spotify says \"napoleon dynamite (music from the motion picture)\", navidrome says \"napoleon dynamite ost\" — old check scored 0.49, redownloaded daily", - "• now strips qualifier parentheticals (music from..., ost, deluxe edition, remastered, anniversary, etc.) before comparing", - "• volume / disc / part guard so vol 1 vs vol 2 still count as different", - "• one user reported the same song downloaded 7 times — fix kills the loop", - ], - usage_note: "no settings to change — applies on next watchlist scan", - }, - { - title: "Duplicate Detector + Cleanup for slskd Dedup Orphans", - description: "two-step fix for the dupe accumulation problem — stop new orphans from being created, and catch the existing ones.", - features: [ - "• new cleanup pass after every successful import scans the source directory for slskd \"_<timestamp>\" siblings of the canonical file and deletes them", - "• duplicate detector got a new second pass that re-buckets leftover tracks by canonical filename stem so dedup orphans get caught even when the media-server scan parsed inconsistent titles for them", - "• safety net: if both rows have a duration must agree within 3s, otherwise relaxed artist check, otherwise skip", - "• existing same-physical-file guard still runs so bind-mount setups (plex + soulsync sharing a folder) aren\'t flagged", - "• also: same-physical-file dupe filter ships independently — bind-mounted setups stop seeing every file flagged twice", + "#835 — a \"/\" in a song title (e.g. Sawano\'s YouSeeBIGGIRL/T:T) no longer truncates the search + quarantines youtube/tidal downloads", + "#836 — a rejected slskd download no longer hangs at downloading forever", + "#837 — manual find & add appends to a jellyfin/emby playlist instead of recreating it", + "#838 — auto-sync no longer caps public spotify playlists at 100 tracks", + "#843 — discovery-state-not-found after a restart/import is fixed", + "find & add search puts exact title matches first instead of burying them under a case-sensitive sort", + "artist Sync is now a true single-artist deep scan (server-diff stale removal, no disk-check mass-deletes)", ], }, { - title: "Spotify Auth Flow Reworked", - description: "rewrote the spotify connection flow on settings → connections so the state is honest about itself.", - features: [ - "• explicit \"needs auth\" / \"connecting\" / \"connected\" states with consistent labels", - "• fixed completion-sync race where the page said connected before the token finished saving", - "• auth-completion failures surface as toasts instead of silent fails", - "• service status reads simplified — fewer ways for the UI to drift from reality", - "• spotify enrichment worker now pauses when spotify isn\'t your primary source (was burning api budget regardless)", - "• per-day spotify call budget cut to 500 to bound accidental quota burns", - ], - }, - { - title: "Match Engine: Featured Artists + Soundtracks", - description: "two long-standing gaps in the matching logic that caused false \"missing\" verdicts.", - features: [ - "• featured-artist tracks now match across discography completion checks — a guest spot on someone else\'s track no longer reports as missing for the watched artist", - "• OST / compilation tracks now match against the per-track artist credit instead of the album\'s primary (which was usually \"Various Artists\")", - "• fixed a dead fallback path that used to silently swallow these missed matches", - ], - }, - { - title: "Beatport Tab Hidden Temporarily", - description: "beatport rolled out cloudflare turnstile on every public page and locked their official api behind partner registration that isn\'t open to the public.", - features: [ - "• every /api/beatport/* call was 500ing because the scraper got a bot challenge instead of html", - "• tested both curl_cffi (chrome131 impersonate) and cloudscraper — both fail", - "• tab hidden on sync, backend endpoints kept in code so revival is one html change", - "• will revisit when beatport relaxes cf or a workaround surfaces", - ], - }, - { - title: "Provider-Neutral Wishlist + Quality Scanner", - description: "two more spots that hardcoded spotify even when you had a different primary source configured.", - features: [ - "• wishlist UI labels, retry copy, and source defaults now mirror your active primary source", - "• quality scanner refactored to query the configured primary instead of always spotify — no more leaked api calls and discogs / hydrabase data finally gets used", - "• artwork preserved on quality-scanner → wishlist handoff", - "• bulk watchlist add now falls back through every cached source ID before declaring failure (no more dead adds when one source rate-limits)", - ], - }, - { - title: "Parallel Singles Import (3 Workers)", - description: "long backlogs of liked-songs single imports finish ~3x faster.", - features: [ - "• singles / EP imports run through a 3-worker thread pool instead of serial", - "• singles + EPs now route through the album_path template so they file correctly (was using a different code path that drifted out of date)", - ], - }, - { - title: "Service Worker for Cover Art + Installable PWA", - description: "cover art now caches locally and soulsync installs as a standalone app.", - features: [ - "• service worker caches cover art on disk — second visit to any page serves art instantly, no network round trip", - "• PWA manifest added — chrome / edge / safari → install soulsync makes it a standalone app on your home screen / desktop", - "• cache versioned so future strategy changes invalidate cleanly", - "• also: static assets (js / css / icons) cache 1 year browser-side; discover pages cache 5 minutes — fewer round trips, faster repeat loads", - ], - }, - { - title: "Security Tightenings", - description: "two endpoint hardenings.", - features: [ - "• socket.io now defaults to same-origin only (was cors=*) — if your websocket fails, server logs the rejected origin so you can add it to settings → security → allowed websocket origins", - "• /api/settings endpoints (read, write, log-level, config-status, verify) are now admin-only — single-admin setups work transparently", - ], - }, - { - title: "Bug Fix Round-Up", - description: "smaller fixes that landed during the cycle.", - features: [ - "• #434 — config DB lock spam on slow disks, fixed with bounded retry + exponential backoff", - "• #399 — bulk discography losing album source context as it threaded through the pipeline", - "• tidal auth instructions now show tidal\'s callback port (was showing spotify\'s)", - "• discogs primary source gracefully reverts when no token is configured", - "• automation handler-returned errors now surface in last_error instead of being swallowed", - "• wishlist track counts coerced before category gating so mixed-type values don\'t crash", - "• faster docker startup — yt-dlp pinned in requirements.txt instead of pip-installed on every container start", - "• shutdown-time logger noise silenced so CI stderr stops carrying \"I/O on closed file\" tracebacks", - ], - }, - { - title: "Major Internal: web_server.py Decomposition", - description: "internal — large monolith broken up into focused modules under core/. behavior unchanged, but the codebase is meaningfully more testable and easier to navigate.", - features: [ - "• ~30 routes / workers / helpers lifted out of web_server.py into core/search, core/automation, core/stats, core/discovery, core/library, core/downloads, core/workers, core/artists, core/imports, core/watchlist, core/connection, core/debug", - "• metadata helpers reorganized into core/metadata/ package; profile spotify cache lives in registry now", - "• search endpoints lift: 612 fewer lines in web_server.py, 94 new tests", - "• automation endpoints lift: 383 fewer lines in web_server.py, 72 new tests", - "• step-by-step toward retiring the monolith, no behavior change in any individual lift", - ], - }, - { - title: "Earlier in v2.4", - description: "highlights from the 2.4 cycle.", - features: [ - "• reorganize queue with live status panel — bulk reorganize all, atomic status flips, race conditions fixed", - "• discography backfill maintenance job — finds what's missing across your library", - "• standalone library mode — use SoulSync without Plex, Jellyfin, or Navidrome", - "• auto-import background folder watcher — tag-based + acoustid fingerprint identification", - "• wishlist nebula — interactive artist orb visualization with inline download", - "• bidirectional artist sync + server playlist view", - "• provider-agnostic discovery — similar artists, discovery pool, and incremental updates use source priority", - "• multi-artist tagging: configurable separator, multi-value ARTISTS tag, move-to-title mode", - "• search page source icons — per-source cache with cache dots, instant source switching", - "• tidal: rejects silent quality downgrades; spotify: post-ban cooldown bumped to 30 minutes", - ], + title: "Earlier in 2.6.x", + description: "highlights from the cycle just before 2.7.0: the artist/track/album blocklist, the download-retry overhaul, Download Origins + retention cleanup, server-side launch-PIN enforcement + secret masking, Spotify-no-auth metadata, Import IDs from File Tags, Library Re-tag, and a large pile of import / library / watchlist fixes.", + features: [], }, ]; diff --git a/webui/static/init.js b/webui/static/init.js index fe4779c5..e6ea4816 100644 --- a/webui/static/init.js +++ b/webui/static/init.js @@ -340,9 +340,21 @@ async function initProfileSystem() { // Check if a session already has a profile selected const currentRes = await fetch('/api/profiles/current'); const currentData = await currentRes.json(); + // Login mode: show the sign-in screen and defer everything else until + // the user authenticates. + if (currentData.login_required) { + showLoginScreen(); + return false; + } if (currentData.success && currentData.profile) { setCurrentProfile(currentData.profile); + // Login mode → reveal the Sign out button in the profile bar. + if (currentData.login_mode) { + const lb = document.getElementById('logout-btn'); + if (lb) lb.style.display = ''; + } + // Check if launch PIN is required if (currentData.launch_pin_required) { showLaunchPinScreen(); @@ -387,6 +399,109 @@ async function initProfileSystem() { } } +// ── Login Screen (username/password mode) ────────────────────────────── + +function showLoginScreen() { + const overlay = document.getElementById('login-overlay'); + if (!overlay) return; + overlay.style.display = 'flex'; + const u = document.getElementById('login-username'); + if (u) setTimeout(() => u.focus(), 50); +} + +async function submitLogin() { + const username = (document.getElementById('login-username')?.value || '').trim(); + const password = document.getElementById('login-password')?.value || ''; + const errEl = document.getElementById('login-error'); + const btn = document.getElementById('login-submit'); + const showErr = (msg) => { if (errEl) { errEl.textContent = msg; errEl.style.display = 'block'; } }; + if (errEl) errEl.style.display = 'none'; + if (!username || !password) { showErr('Enter your username and password'); return; } + if (btn) { btn.disabled = true; btn.textContent = 'Signing in...'; } + try { + const res = await fetch('/api/auth/login', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + const data = await res.json(); + if (data.success) { + window.location.reload(); // authenticated → reload into the app + } else { + showErr(res.status === 429 ? 'Too many attempts — wait a moment.' : (data.error || 'Sign in failed')); + if (btn) { btn.disabled = false; btn.textContent = 'Sign in'; } + } + } catch (e) { + showErr('Connection error'); + if (btn) { btn.disabled = false; btn.textContent = 'Sign in'; } + } +} + +async function soulsyncLogout() { + try { await fetch('/api/auth/logout', { method: 'POST' }); } catch (e) { /* reload anyway */ } + window.location.reload(); +} + +function showLoginRecovery() { + const entry = document.getElementById('login-entry'); + const rec = document.getElementById('login-recovery'); + if (entry) entry.style.display = 'none'; + if (rec) rec.style.display = 'block'; + const u = document.getElementById('recovery-username'); + const lu = document.getElementById('login-username'); + if (u && lu && lu.value) u.value = lu.value; + const errEl = document.getElementById('recovery-error'); + if (errEl) errEl.style.display = 'none'; +} + +function showLoginEntry() { + const entry = document.getElementById('login-entry'); + const rec = document.getElementById('login-recovery'); + if (rec) rec.style.display = 'none'; + if (entry) entry.style.display = 'block'; +} + +async function fetchRecoveryQuestion() { + const username = (document.getElementById('recovery-username')?.value || '').trim(); + const errEl = document.getElementById('recovery-error'); + const section = document.getElementById('recovery-answer-section'); + const qText = document.getElementById('recovery-question-text'); + const showErr = (m) => { if (errEl) { errEl.textContent = m; errEl.style.display = 'block'; } }; + if (errEl) errEl.style.display = 'none'; + if (!username) { showErr('Enter your username'); return; } + try { + const res = await fetch('/api/auth/recovery-question?username=' + encodeURIComponent(username)); + const data = await res.json(); + if (data.success && data.question) { + if (qText) qText.textContent = data.question; + if (section) section.style.display = 'block'; + } else { + showErr('No recovery question is set for that account.'); + } + } catch (e) { showErr('Connection error'); } +} + +async function submitRecoveryReset() { + const username = (document.getElementById('recovery-username')?.value || '').trim(); + const answer = document.getElementById('recovery-answer')?.value || ''; + const newPassword = document.getElementById('recovery-new-password')?.value || ''; + const confirmPassword = document.getElementById('recovery-new-password-confirm')?.value || ''; + const errEl = document.getElementById('recovery-error'); + const showErr = (m) => { if (errEl) { errEl.textContent = m; errEl.style.display = 'block'; } }; + if (errEl) errEl.style.display = 'none'; + if (!answer || !newPassword) { showErr('Enter your answer and a new password'); return; } + if (newPassword.length < 6) { showErr('New password must be at least 6 characters'); return; } + if (newPassword !== confirmPassword) { showErr('Passwords do not match'); return; } + try { + const res = await fetch('/api/auth/recovery-reset', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, answer, new_password: newPassword }), + }); + const data = await res.json(); + if (data.success) { window.location.reload(); } + else { showErr(res.status === 429 ? 'Too many attempts — wait a moment.' : (data.error || 'Reset failed')); } + } catch (e) { showErr('Connection error'); } +} + // ── Launch PIN Lock Screen ───────────────────────────────────────────── function showLaunchPinScreen() { @@ -451,6 +566,133 @@ function showLaunchPinScreen() { // ── Security Settings Helpers ────────────────────────────────────────── +async function saveLoginPassword() { + const input = document.getElementById('security-login-password'); + const confirmInput = document.getElementById('security-login-password-confirm'); + const msg = document.getElementById('security-login-password-msg'); + const password = input?.value || ''; + const confirm = confirmInput?.value || ''; + const show = (text, ok) => { + if (!msg) return; + msg.textContent = text; + msg.style.color = ok ? '#4caf50' : '#ff5252'; + msg.style.display = 'block'; + }; + if (!password || password.length < 6) { show('Password must be at least 6 characters', false); return; } + if (password !== confirm) { show('Passwords do not match', false); return; } + try { + const res = await fetch('/api/profiles/1/set-password', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }); + const data = await res.json(); + if (data.success) { + show('Admin login password saved', true); + if (input) { input.value = ''; input.placeholder = 'Enter a new password to change it'; } + if (confirmInput) confirmInput.value = ''; + updateRequireLoginGate(true); // Step 1 done → unlock Step 3 + const st = document.getElementById('security-login-password-status'); + if (st) st.style.display = 'block'; + } + else show(data.error || 'Failed to save password', false); + } catch (e) { show('Connection error', false); } +} + +// Lock/unlock the "Require login" toggle based on whether the admin has a +// password — makes the prerequisite (anti-lockout) visible instead of a +// surprise 400 on save. +function updateRequireLoginGate(hasPassword) { + const toggle = document.getElementById('security-require-login'); + const wrap = document.getElementById('security-login-toggle-wrap'); + const help = document.getElementById('security-require-login-help'); + if (!toggle) return; + toggle.disabled = !hasPassword; + if (!hasPassword) toggle.checked = false; + if (wrap) wrap.classList.toggle('security-locked', !hasPassword); + if (help) { + help.innerHTML = hasPassword + ? 'Replaces the profile picker + PIN with a sign-in screen. Best for instances exposed to the internet.' + : '🔒 Set the admin password in <strong>Step 1</strong> first — then you can turn this on.'; + } +} + +// Reflect already-saved login credentials. Passwords are never sent to the +// browser, so instead of an empty field (which looks unset after a refresh) we +// show that one is set and pre-fill the saved recovery question. +function applyLoginSavedState(profile) { + const hasPassword = profile?.has_password || false; + const hasRecovery = profile?.has_recovery || false; + const question = profile?.recovery_question || ''; + + const pwStatus = document.getElementById('security-login-password-status'); + const pwField = document.getElementById('security-login-password'); + const pwConfirm = document.getElementById('security-login-password-confirm'); + if (pwStatus) pwStatus.style.display = hasPassword ? 'block' : 'none'; + if (hasPassword) { + if (pwField) pwField.placeholder = 'Enter a new password to change it'; + if (pwConfirm) pwConfirm.placeholder = 'Confirm new password'; + } + + const recStatus = document.getElementById('security-recovery-status'); + const recSel = document.getElementById('security-recovery-question'); + const recCustom = document.getElementById('security-recovery-custom'); + const recAnswer = document.getElementById('security-recovery-answer'); + if (recStatus) { + recStatus.style.display = hasRecovery ? 'block' : 'none'; + recStatus.textContent = hasRecovery + ? ('✓ Recovery question saved' + (question ? ': “' + question + '”' : '')) + : ''; + } + if (hasRecovery) { + if (recSel && question) { + recSel.value = question; // preset options default value = their text + if (recSel.value !== question) { // not a preset → custom question + recSel.value = '__custom__'; + if (recCustom) { recCustom.style.display = 'block'; recCustom.value = question; } + } + } + if (recAnswer) recAnswer.placeholder = 'Enter a new answer to change it'; + } +} + +function handleRecoveryQuestionChange() { + const sel = document.getElementById('security-recovery-question'); + const custom = document.getElementById('security-recovery-custom'); + if (sel && custom) custom.style.display = (sel.value === '__custom__') ? 'block' : 'none'; +} + +async function saveRecoveryQuestion() { + const sel = document.getElementById('security-recovery-question'); + const custom = document.getElementById('security-recovery-custom'); + const answer = document.getElementById('security-recovery-answer')?.value || ''; + const msg = document.getElementById('security-recovery-msg'); + const show = (text, ok) => { + if (!msg) return; + msg.textContent = text; + msg.style.color = ok ? '#4caf50' : '#ff5252'; + msg.style.display = 'block'; + }; + let question = sel?.value || ''; + if (question === '__custom__') question = (custom?.value || '').trim(); + if (!question) { show('Pick or type a question', false); return; } + if (!answer.trim()) { show('Enter an answer', false); return; } + try { + const res = await fetch('/api/profiles/1/set-recovery', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ question, answer }), + }); + const data = await res.json(); + if (data.success) { + show('Recovery question saved', true); + const a = document.getElementById('security-recovery-answer'); + if (a) { a.value = ''; a.placeholder = 'Enter a new answer to change it'; } + const rst = document.getElementById('security-recovery-status'); + if (rst) { rst.style.display = 'block'; rst.textContent = '✓ Recovery question saved: “' + question + '”'; } + } + else show(data.error || 'Failed to save', false); + } catch (e) { show('Connection error', false); } +} + async function saveSecurityPin() { const pin = document.getElementById('security-new-pin').value; const confirm = document.getElementById('security-confirm-pin').value; @@ -863,6 +1105,11 @@ function updateProfileIndicator() { name.textContent = currentProfile.name; indicator.style.display = 'flex'; + // Service Status quick-switch is admin-only — drop the clickable affordance + // for non-admins so it doesn't look interactive. + const statusSection = document.querySelector('.status-section--clickable'); + if (statusSection) statusSection.classList.toggle('status-section--locked', !currentProfile.is_admin); + indicator.onclick = async () => { const res = await fetch('/api/profiles'); const data = await res.json(); @@ -909,56 +1156,16 @@ async function openPersonalSettings() { body.innerHTML = '<div style="text-align:center;padding:20px;color:rgba(255,255,255,0.4);">Loading...</div>'; try { - // Load all per-profile service data in parallel - const [lbRes, spotifyRes] = await Promise.all([ - fetch('/api/profiles/me/listenbrainz'), - fetch('/api/profiles/me/spotify'), - ]); - const lbData = await lbRes.json(); - const spotifyData = await spotifyRes.json(); - body.innerHTML = ''; const isNonAdmin = currentProfile && !currentProfile.is_admin; + // Streaming-account connections now live in the My Accounts modal (the ♫ + // button). Personal Settings keeps only the per-profile server library. if (isNonAdmin) { - // Tabbed layout for non-admin with multiple sections - const tabs = [ - { id: 'music', label: 'Music Services' }, - { id: 'server', label: 'Server' }, - { id: 'scrobble', label: 'Scrobbling' }, - ]; - const tabBar = document.createElement('div'); - tabBar.className = 'ps-tabbar'; - tabs.forEach((t, i) => { - const btn = document.createElement('button'); - btn.className = 'ps-tab' + (i === 0 ? ' active' : ''); - btn.textContent = t.label; - btn.onclick = () => { - tabBar.querySelectorAll('.ps-tab').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - body.querySelectorAll('.ps-tab-content').forEach(c => c.classList.remove('active')); - const target = document.getElementById(`ps-tab-${t.id}`); - if (target) target.classList.add('active'); - }; - tabBar.appendChild(btn); - }); - body.appendChild(tabBar); - - // Music Services tab - const musicTab = document.createElement('div'); - musicTab.id = 'ps-tab-music'; - musicTab.className = 'ps-tab-content active'; - renderPersonalSettingsSpotify(musicTab, spotifyData); - renderPersonalSettingsTidal(musicTab); - body.appendChild(musicTab); - - // Server tab const serverTab = document.createElement('div'); - serverTab.id = 'ps-tab-server'; - serverTab.className = 'ps-tab-content'; + serverTab.style.padding = '18px 22px 22px'; serverTab.innerHTML = '<div style="text-align:center;padding:20px;color:rgba(255,255,255,0.3);">Loading libraries...</div>'; body.appendChild(serverTab); - // Load server libraries async (don't block modal) fetch('/api/profiles/me/server-library').then(r => r.json()).then(libData => { serverTab.innerHTML = ''; renderPersonalSettingsServerLibrary(serverTab, libData); @@ -966,21 +1173,13 @@ async function openPersonalSettings() { serverTab.innerHTML = ''; renderPersonalSettingsServerLibrary(serverTab, {}); }); - - // Scrobbling tab - const scrobbleTab = document.createElement('div'); - scrobbleTab.id = 'ps-tab-scrobble'; - scrobbleTab.className = 'ps-tab-content'; - body.appendChild(scrobbleTab); - // Render LB into the scrobble tab - const origBody = body; - renderPersonalSettingsLB(lbData, scrobbleTab); } else { - // Admin: just ListenBrainz, no tabs const content = document.createElement('div'); - content.style.padding = '18px 22px 22px'; + content.style.padding = '24px'; + content.innerHTML = '<div style="color:rgba(255,255,255,0.55);font-size:0.9rem;line-height:1.7;">' + + 'Your streaming accounts are in <b>My Accounts</b> (the ♫ button next to your profile).<br>' + + 'Global service setup lives in <b>Settings</b>.</div>'; body.appendChild(content); - renderPersonalSettingsLB(lbData, content); } } catch (e) { body.innerHTML = '<div style="color:#ef4444;padding:16px;">Failed to load settings</div>'; @@ -1669,6 +1868,8 @@ async function loadProfileManageList() { profiles.forEach(p => { const item = document.createElement('div'); item.className = 'profile-manage-item'; + const isCurrent = currentProfile && currentProfile.id === p.id; + if (isCurrent) item.classList.add('is-current'); const av = document.createElement('div'); renderProfileAvatar(av, p); @@ -1680,14 +1881,21 @@ async function loadProfileManageList() { nameDiv.className = 'name'; nameDiv.textContent = p.name + (p.has_pin ? ' 🔒' : ''); info.appendChild(nameDiv); - const roleTags = []; - if (p.is_admin) roleTags.push('Admin'); - if (p.can_download === false) roleTags.push('No Downloads'); - if (p.allowed_pages) roleTags.push(`${p.allowed_pages.length} pages`); - if (roleTags.length) { + // Role/status as pills + const pills = []; + if (isCurrent) pills.push({ text: 'You', cls: 'profile-role-pill--current' }); + if (p.is_admin) pills.push({ text: 'Admin', cls: 'profile-role-pill--admin' }); + if (p.can_download === false) pills.push({ text: 'No Downloads', cls: '' }); + if (p.allowed_pages) pills.push({ text: `${p.allowed_pages.length} pages`, cls: '' }); + if (pills.length) { const roleDiv = document.createElement('div'); roleDiv.className = 'role'; - roleDiv.textContent = roleTags.join(' · '); + pills.forEach(pill => { + const span = document.createElement('span'); + span.className = ('profile-role-pill ' + pill.cls).trim(); + span.textContent = pill.text; + roleDiv.appendChild(span); + }); info.appendChild(roleDiv); } item.appendChild(info); @@ -1706,7 +1914,7 @@ async function loadProfileManageList() { editBtn.dataset.canDownload = p.can_download !== false ? '1' : '0'; editBtn.dataset.isAdmin = p.is_admin ? '1' : '0'; editBtn.title = 'Edit profile'; - editBtn.textContent = '✏️'; + editBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>'; actions.appendChild(editBtn); if (!p.is_admin) { @@ -1714,7 +1922,7 @@ async function loadProfileManageList() { delBtn.className = 'profile-delete-btn'; delBtn.dataset.id = p.id; delBtn.title = 'Delete profile'; - delBtn.textContent = '🗑️'; + delBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>'; actions.appendChild(delBtn); } @@ -2072,10 +2280,16 @@ document.addEventListener('DOMContentLoaded', async function () { if (!forceSetup) { try { const setupResp = await fetch('/api/setup/status'); - const setupData = await setupResp.json(); - if (!setupData.setup_complete) { - showWizard = true; - localStorage.removeItem('soulsync_setup_complete'); + // Fail-safe (#842): only launch the wizard when the server DEFINITIVELY + // says setup isn't done. A non-OK response (e.g. 401 while the launch + // PIN is locked) must NOT trigger the wizard — otherwise a PIN-gated + // returning user gets the full setup flow every visit. + if (setupResp.ok) { + const setupData = await setupResp.json(); + if (setupData.setup_complete === false) { + showWizard = true; + localStorage.removeItem('soulsync_setup_complete'); + } } } catch (e) { console.warn('Setup status check failed, continuing normal init:', e); diff --git a/webui/static/library.js b/webui/static/library.js index 34823ce6..4eaf6c03 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -3192,16 +3192,26 @@ function renderArtistMetaPanel(artist) { const res = await fetch(`/api/library/artist/${artist.id}/sync`, { method: 'POST' }); const data = await res.json(); if (data.success) { - const parts = []; - if (data.new_albums > 0) parts.push(`+${data.new_albums} albums`); - if (data.new_tracks > 0) parts.push(`+${data.new_tracks} tracks`); - if (data.stale_removed > 0) parts.push(`${data.stale_removed} stale removed`); - if (data.empty_albums_removed > 0) parts.push(`${data.empty_albums_removed} empty albums cleaned`); - if (data.name_updated) parts.push('name updated'); - if (parts.length === 0) parts.push('Already in sync'); - showToast(`${data.artist_name}: ${parts.join(', ')}`, 'success'); - // Refresh enhanced view if anything changed - if (data.stale_removed > 0 || data.empty_albums_removed > 0) { + if (data.removal_skipped) { + // Couldn't get a trustworthy server view — we deliberately did NOT delete. + const parts = []; + if (data.new_albums > 0) parts.push(`+${data.new_albums} albums`); + if (data.new_tracks > 0) parts.push(`+${data.new_tracks} tracks`); + if (data.name_updated) parts.push('name updated'); + const added = parts.length ? ` (${parts.join(', ')})` : ''; + showToast(`${data.artist_name}: couldn't fully confirm against your media server — skipped removing tracks to be safe${added}.`, 'warning'); + } else { + const parts = []; + if (data.new_albums > 0) parts.push(`+${data.new_albums} albums`); + if (data.new_tracks > 0) parts.push(`+${data.new_tracks} tracks`); + if (data.stale_removed > 0) parts.push(`${data.stale_removed} stale removed`); + if (data.empty_albums_removed > 0) parts.push(`${data.empty_albums_removed} empty albums cleaned`); + if (data.name_updated) parts.push('name updated'); + if (parts.length === 0) parts.push('Already in sync'); + showToast(`${data.artist_name}: ${parts.join(', ')}`, 'success'); + } + // Refresh enhanced view if anything changed (additions OR removals) + if (data.new_albums > 0 || data.new_tracks > 0 || data.stale_removed > 0 || data.empty_albums_removed > 0) { loadEnhancedViewData(artist.id); } } else { diff --git a/webui/static/my-accounts.js b/webui/static/my-accounts.js new file mode 100644 index 00000000..2ae792d0 --- /dev/null +++ b/webui/static/my-accounts.js @@ -0,0 +1,185 @@ +/* + * My Accounts — per-profile self-auth for playlist services. + * + * Each profile connects its OWN streaming accounts (their token, the app's + * shared client). Used for that profile's playlist operations; the global/admin + * auth keeps running the background app. Spotify is the first service; the others + * follow the same pattern. + * + * Backend: GET /api/profiles/me/connections, the per-service OAuth popups, and + * POST /api/profiles/me/connections/<service>/disconnect. + */ + +// Playlist services shown in My Accounts. `connect` returns the OAuth URL for a +// given profile id (popup); services are wired in over time. +const _MA_SERVICES = [ + { + id: 'spotify', name: 'Spotify', brand: '#1db954', + logo: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png', + connect: (pid) => `/auth/spotify?profile_id=${pid}`, + }, + { + id: 'tidal', name: 'Tidal', brand: '#00cfe8', + logo: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/tidal.png', + connect: (pid) => `/auth/tidal?profile_id=${pid}`, + }, + { + id: 'listenbrainz', name: 'ListenBrainz', brand: '#eb743b', dark: true, + logo: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/listenbrainz.png', + type: 'token', + saveUrl: '/api/profiles/me/listenbrainz', + hint: 'Paste your token from listenbrainz.org/profile', + }, +]; + +function _maEsc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, c => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} + +function _maProfileId() { + try { + const ctx = (typeof getCurrentProfileContext === 'function') ? getCurrentProfileContext() : null; + return ctx ? ctx.profileId : 1; + } catch (_e) { return 1; } +} + +function openMyAccountsModal() { + let overlay = document.getElementById('my-accounts-overlay'); + if (!overlay) { + overlay = document.createElement('div'); + overlay.id = 'my-accounts-overlay'; + overlay.className = 'modal-overlay ma-overlay hidden'; + overlay.onclick = (e) => { if (e.target === overlay) closeMyAccountsModal(); }; + overlay.innerHTML = ` + <div class="ma-modal" role="dialog" aria-modal="true" aria-label="My Accounts" tabindex="-1"> + <div class="ma-topbar"> + <div class="ma-topbar-icon"><img src="/static/trans2.png" alt="SoulSync" class="ma-topbar-logo"></div> + <div class="ma-topbar-titles"> + <h3 class="ma-topbar-title">My Accounts</h3> + <div class="ma-topbar-sub">Connect your own streaming accounts — used for your playlists, just for you.</div> + </div> + <button class="ma-icon-btn" title="Close" onclick="closeMyAccountsModal()">×</button> + </div> + <div class="ma-body" id="ma-body"></div> + </div>`; + document.body.appendChild(overlay); + } + overlay.classList.remove('hidden'); + const modal = overlay.querySelector('.ma-modal'); + if (modal) { modal.classList.remove('ma-in'); void modal.offsetWidth; modal.classList.add('ma-in'); } + document.addEventListener('keydown', _maOnKeydown); + _maLoad(); +} + +function closeMyAccountsModal() { + const o = document.getElementById('my-accounts-overlay'); + if (o) o.classList.add('hidden'); + document.removeEventListener('keydown', _maOnKeydown); +} + +function _maOnKeydown(e) { if (e.key === 'Escape') closeMyAccountsModal(); } + +async function _maLoad() { + const body = document.getElementById('ma-body'); + if (body) body.innerHTML = '<div class="ma-empty">Loading…</div>'; + let data = null; + try { + data = await (await fetch('/api/profiles/me/connections')).json(); + } catch (e) { /* render disconnected */ } + _maRender(body, data || { connections: {}, is_admin: false }); +} + +function _maRender(body, data) { + const conns = data.connections || {}; + const isAdmin = !!data.is_admin; + const rows = _MA_SERVICES.map(svc => { + const c = conns[svc.id] || {}; + const connected = !!c.connected; + // Admin uses the global app account (set up in Settings) for every + // service — not a personal connection here. + const adminNote = isAdmin; + let action; + if (adminNote) { + action = `<span class="ma-note">Managed in Settings (app account)</span>`; + } else if (connected) { + action = ` + <span class="ma-account">${_maEsc(c.account || 'Connected')}</span> + <button class="ma-btn ma-btn--ghost" onclick="disconnectMyAccount('${svc.id}')">Disconnect</button>`; + } else if (svc.type === 'token') { + action = ` + <input type="password" class="ma-token-input" id="ma-token-${svc.id}" placeholder="Paste token" + title="${_maEsc(svc.hint || '')}"> + <button class="ma-btn ma-btn--connect" onclick="saveMyAccountToken('${svc.id}')">Save</button>`; + } else { + action = `<button class="ma-btn ma-btn--connect" onclick="connectMyAccount('${svc.id}')">Connect</button>`; + } + return ` + <div class="ma-row" style="--ma-brand:${svc.brand}"> + <span class="ma-disc${svc.dark ? ' ma-disc--dark' : ''}"><img class="ma-logo" src="${svc.logo}" alt="" + onerror="this.style.display='none'"></span> + <div class="ma-row-info"> + <div class="ma-row-name">${_maEsc(svc.name)}</div> + <div class="ma-row-status ${connected ? 'is-on' : ''}">${connected ? 'Connected' : (adminNote ? '' : 'Not connected')}</div> + </div> + <div class="ma-row-action">${action}</div> + </div>`; + }).join(''); + body.innerHTML = rows || '<div class="ma-empty">No services available.</div>'; +} + +let _maPollTimer = null; + +function connectMyAccount(serviceId) { + const svc = _MA_SERVICES.find(s => s.id === serviceId); + if (!svc) return; + const pid = _maProfileId(); + const popup = window.open(svc.connect(pid), 'soulsync-connect-' + serviceId, + 'width=560,height=720,menubar=no,toolbar=no'); + // Poll for the popup closing, then refresh status. + if (_maPollTimer) clearInterval(_maPollTimer); + _maPollTimer = setInterval(() => { + if (!popup || popup.closed) { + clearInterval(_maPollTimer); + _maPollTimer = null; + setTimeout(_maLoad, 600); // give the callback a moment to persist + } + }, 800); +} + +async function saveMyAccountToken(serviceId) { + const svc = _MA_SERVICES.find(s => s.id === serviceId); + if (!svc || !svc.saveUrl) return; + const input = document.getElementById(`ma-token-${serviceId}`); + const token = (input && input.value || '').trim(); + if (!token) { if (typeof showToast === 'function') showToast('Paste a token first', 'info'); return; } + try { + const res = await fetch(svc.saveUrl, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + const data = await res.json(); + if (data.success) { + if (typeof showToast === 'function') showToast(`${svc.name} connected`, 'success'); + _maLoad(); + } else if (typeof showToast === 'function') { + showToast(data.error || 'Could not connect', 'error'); + } + } catch (e) { + if (typeof showToast === 'function') showToast('Could not connect', 'error'); + } +} + +async function disconnectMyAccount(serviceId) { + if (!confirm(`Disconnect your ${serviceId} account from this profile?`)) return; + try { + const res = await fetch(`/api/profiles/me/connections/${serviceId}/disconnect`, { method: 'POST' }); + const data = await res.json(); + if (data.success) { + if (typeof showToast === 'function') showToast('Disconnected', 'success'); + _maLoad(); + } else if (typeof showToast === 'function') { + showToast(data.error || 'Disconnect failed', 'error'); + } + } catch (e) { /* no-op */ } +} diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index d930bb37..0dd10a46 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -1680,6 +1680,10 @@ async function serverSearchReplace(trackIndex, mode) { const searchQuery = src.name ? src.name.trim() : (svr.title || '').trim(); const contextArtist = src.artist || svr.artist || ''; const contextName = src.name || svr.title || ''; + // Pass the source artist as a relevance hint so an exact title+artist match + // ranks to the top of the library search instead of being buried under + // same-title tracks by other artists (#: "bad guy" by Billie Eilish). + _serverEditorState.searchArtist = contextArtist; const existing = document.getElementById('server-search-overlay'); if (existing) existing.remove(); @@ -1756,7 +1760,9 @@ async function _serverSearchExecute() { if (resultsHeader) resultsHeader.textContent = ''; try { - const response = await fetch(`/api/library/search-tracks?q=${encodeURIComponent(query)}&limit=20`); + const artistHint = (_serverEditorState && _serverEditorState.searchArtist) || ''; + const response = await fetch(`/api/library/search-tracks?q=${encodeURIComponent(query)}&limit=20` + + (artistHint ? `&artist=${encodeURIComponent(artistHint)}` : '')); const data = await response.json(); if (!data.success || !data.tracks || data.tracks.length === 0) { diff --git a/webui/static/service-switch.js b/webui/static/service-switch.js new file mode 100644 index 00000000..acef7a7d --- /dev/null +++ b/webui/static/service-switch.js @@ -0,0 +1,361 @@ +/* + * Quick-switch modal — active Metadata / Server / Download source selection. + * Opens from the sidebar Service Status panel; styled after the Manage Workers + * hub (topbar + rail + panel, brand-logo cards). + * + * Admin writes the GLOBAL active source/server/download (same as Settings). + * Non-admins see it read-only for now (per-profile override is a later layer): + * the backend reports `editable`, and the UI disables changes when false. + * + * Backend: GET /api/profiles/me/active-sources, POST /api/profiles/active-sources. + */ + +const _SS_TABS = [ + { id: 'metadata', name: 'Metadata', emoji: '🎼' }, + { id: 'server', name: 'Server', emoji: '🖥️' }, + { id: 'download', name: 'Download', emoji: '⬇️' }, +]; + +// Brand logos. Metadata pulls from SOURCE_LABELS (shared-helpers.js) when +// available; server + download have their own small maps. +const _SS_SERVER_INFO = { + // `dark`: the logo is a white/light wordmark, so it needs a dark disc to be + // visible (it'd vanish on the default white disc). + plex: { name: 'Plex', logo: 'https://www.plex.tv/wp-content/themes/plex/assets/img/plex-logo.svg', dark: true }, + jellyfin: { name: 'Jellyfin', logo: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/jellyfin.png' }, + navidrome: { name: 'Navidrome', logo: 'https://tweakers.net/ext/i/2007323764.png' }, + soulsync: { name: 'SoulSync', logo: '/static/trans2.png', dark: true }, +}; +const _SS_META_FALLBACK = { + spotify_free: { text: 'Spotify (no auth)', icon: '🆓', logo: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png' }, +}; +// Brand colors drive each card's logo ring + active glow (the Manage-Workers feel). +const _SS_BRAND = { + spotify: '#1db954', spotify_free: '#1db954', itunes: '#fc5c7d', deezer: '#a238ff', + discogs: '#ff5500', musicbrainz: '#ba478f', amazon: '#ff9900', + plex: '#e5a00d', jellyfin: '#aa5cc3', navidrome: '#3b6cf6', soulsync: '#7c5cff', + soulseek: '#22a7f0', youtube: '#ff0000', tidal: '#00cfe8', qobuz: '#0a6e9e', + hifi: '#16c79a', torrent: '#8a2be2', usenet: '#e67e22', +}; +function _ssBrand(id) { return _SS_BRAND[id] || 'var(--accent-light-rgb-hex, #7c5cff)'; } + +let _ssState = { tab: 'metadata', data: null }; + +function _ssEsc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, c => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} + +function _ssMetaInfo(id) { + if (typeof SOURCE_LABELS !== 'undefined' && SOURCE_LABELS[id]) return SOURCE_LABELS[id]; + if (_SS_META_FALLBACK[id]) return _SS_META_FALLBACK[id]; + return { text: id, icon: '🎵' }; +} + +function _ssDownloadInfo(id) { + if (typeof HYBRID_SOURCES !== 'undefined') { + const h = HYBRID_SOURCES.find(s => s.id === id); + if (h) return { name: h.name, logo: h.icon, emoji: h.emoji }; + } + return { name: id, emoji: '⬇️' }; +} + +function openServiceSwitchModal(tab) { + // Admin-only: active metadata source / media server / download source are + // app-wide infrastructure. Non-admins manage their own playlist accounts + // elsewhere (per-profile), not here. + try { + const ctx = (typeof getCurrentProfileContext === 'function') ? getCurrentProfileContext() : null; + if (ctx && !ctx.isAdmin) { + if (typeof showToast === 'function') showToast('Only the admin can change the active sources', 'info'); + return; + } + } catch (_e) { /* if context unknown, fall through (defaults to admin) */ } + _ssState.tab = _SS_TABS.some(t => t.id === tab) ? tab : 'metadata'; + let overlay = document.getElementById('service-switch-overlay'); + if (!overlay) { + overlay = document.createElement('div'); + overlay.id = 'service-switch-overlay'; + overlay.className = 'modal-overlay ss-overlay hidden'; + overlay.onclick = (e) => { if (e.target === overlay) closeServiceSwitchModal(); }; + overlay.innerHTML = ` + <div class="ss-modal" role="dialog" aria-modal="true" aria-label="Active Sources" tabindex="-1"> + <div class="ss-topbar"> + <div class="ss-topbar-icon"><img src="/static/trans2.png" alt="SoulSync" class="ss-topbar-logo"></div> + <div class="ss-topbar-titles"> + <h3 class="ss-topbar-title">Active Sources</h3> + <div class="ss-topbar-sub" id="ss-topbar-sub">What this profile uses for metadata, library, and downloads</div> + </div> + <button class="ss-icon-btn ss-icon-btn--close" title="Close" onclick="closeServiceSwitchModal()">×</button> + </div> + <div class="ss-body"> + <div class="ss-rail" id="ss-rail"></div> + <div class="ss-panel" id="ss-panel"></div> + </div> + </div>`; + document.body.appendChild(overlay); + } + overlay.classList.remove('hidden', 'ss-closing'); + const modal = overlay.querySelector('.ss-modal'); + if (modal) { modal.classList.remove('ss-in'); void modal.offsetWidth; modal.classList.add('ss-in'); } + document.addEventListener('keydown', _ssOnKeydown); + _ssLoad(); +} + +function closeServiceSwitchModal() { + const o = document.getElementById('service-switch-overlay'); + if (o) o.classList.add('hidden'); + document.removeEventListener('keydown', _ssOnKeydown); +} + +function _ssOnKeydown(e) { if (e.key === 'Escape') closeServiceSwitchModal(); } + +async function _ssLoad() { + _ssRenderRail(); + const panel = document.getElementById('ss-panel'); + if (panel) panel.innerHTML = '<div class="ss-empty">Loading…</div>'; + try { + const res = await fetch('/api/profiles/me/active-sources'); + _ssState.data = await res.json(); + } catch (e) { + _ssState.data = null; + } + _ssRenderRail(); // re-render now that we know each tab's active choice + _ssRenderPanel(); +} + +function _ssRailCurrent(tabId) { + // The active choice for a tab → {logo/emoji, label, brand} for the rail chip. + const d = _ssState.data; + if (!d || !d.success) return null; + if (tabId === 'metadata') { + const id = d.metadata.active; const info = _ssMetaInfo(id); + return { logo: info.logo, emoji: info.icon, label: info.text || id, brand: _ssBrand(id) }; + } + if (tabId === 'server') { + const id = d.server.active; const info = _SS_SERVER_INFO[id] || { name: id }; + return { logo: info.logo, emoji: '🖥️', label: info.name, brand: _ssBrand(id), dark: info.dark }; + } + const id = d.download.mode; + if (id === 'hybrid') return { emoji: '🔀', label: 'Hybrid', brand: 'var(--accent-light-rgb-hex,#7c5cff)' }; + const info = _ssDownloadInfo(id); + return { logo: info.logo, emoji: info.emoji, label: info.name, brand: _ssBrand(id) }; +} + +function _ssRenderRail() { + const rail = document.getElementById('ss-rail'); + if (!rail) return; + rail.innerHTML = _SS_TABS.map(t => { + const cur = _ssRailCurrent(t.id); + const media = cur + ? (cur.logo + ? `<img class="ss-tab-logo" src="${cur.logo}" onerror="this.outerHTML='<span class=\\'ss-tab-emoji\\'>${cur.emoji}</span>'">` + : `<span class="ss-tab-emoji">${cur.emoji}</span>`) + : `<span class="ss-tab-emoji">${t.emoji}</span>`; + return ` + <button class="ss-tab${t.id === _ssState.tab ? ' active' : ''}" style="--ss-brand:${cur ? cur.brand : '#7c5cff'}" + onclick="switchServiceSwitchTab('${t.id}')"> + <span class="ss-tab-disc${cur && cur.dark ? ' ss-disc--dark' : ''}">${media}</span> + <span class="ss-tab-text"> + <span class="ss-tab-cat">${t.name}</span> + <span class="ss-tab-cur">${cur ? _ssEsc(cur.label) : '…'}</span> + </span> + </button>`; + }).join(''); +} + +function switchServiceSwitchTab(tab) { + _ssState.tab = tab; + _ssRenderRail(); + _ssRenderPanel(); +} + +function _ssCard({ logo, emoji, label, active, available, onclick, badge, brand, dark }) { + const dim = available === false ? ' ss-card--locked' : ''; + const act = active ? ' active' : ''; + const media = logo + ? `<img class="ss-card-logo" src="${logo}" alt="" onerror="this.outerHTML='<span class=\\'ss-card-emoji\\'>${emoji || '🎵'}</span>'">` + : `<span class="ss-card-emoji">${emoji || '🎵'}</span>`; + return ` + <button class="ss-card${act}${dim}" style="--ss-brand:${brand || '#7c5cff'}" ${onclick ? `onclick="${onclick}"` : 'disabled'}> + <span class="ss-card-disc${dark ? ' ss-disc--dark' : ''}">${media}</span> + <span class="ss-card-label">${_ssEsc(label)}</span> + ${badge ? `<span class="ss-card-badge">${_ssEsc(badge)}</span>` : ''} + ${active ? '<span class="ss-card-check">✓</span>' : ''} + </button>`; +} + +const _SS_TAB_BLURB = { + metadata: 'Where artist, album & track details come from.', + server: 'The library backend SoulSync reads and writes.', + download: 'Where SoulSync grabs tracks you don\'t have yet.', +}; + +function _ssHero(kind) { + const cur = _ssRailCurrent(kind); + if (!cur) return ''; + const media = cur.logo + ? `<img class="ss-hero-logo" src="${cur.logo}" onerror="this.outerHTML='<span class=\\'ss-hero-emoji\\'>${cur.emoji}</span>'">` + : `<span class="ss-hero-emoji">${cur.emoji}</span>`; + const eyebrow = kind === 'metadata' ? 'Active metadata source' + : kind === 'server' ? 'Active media server' : 'Active download source'; + return ` + <div class="ss-hero" style="--ss-brand:${cur.brand}"> + <div class="ss-hero-disc${cur.dark ? ' ss-disc--dark' : ''}">${media}</div> + <div class="ss-hero-info"> + <div class="ss-hero-eyebrow">${eyebrow}</div> + <div class="ss-hero-name">${_ssEsc(cur.label)}</div> + <div class="ss-hero-sub">${_SS_TAB_BLURB[kind] || ''}</div> + </div> + <span class="ss-hero-pill">Active</span> + </div>`; +} + +function _ssRenderPanel() { + const panel = document.getElementById('ss-panel'); + const d = _ssState.data; + if (!panel) return; + if (!d || !d.success) { panel.innerHTML = '<div class="ss-empty">Could not load active sources.</div>'; return; } + const editable = !!d.editable; + panel.style.setProperty('--ss-brand', (_ssRailCurrent(_ssState.tab) || {}).brand || '#7c5cff'); + const sub = document.getElementById('ss-topbar-sub'); + if (sub) sub.textContent = editable + ? 'What this profile uses for metadata, library, and downloads' + : 'Set by the admin — view only for now'; + + if (_ssState.tab === 'metadata') { + const cards = d.metadata.options.map(o => { + const info = _ssMetaInfo(o.id); + return _ssCard({ + logo: info.logo, emoji: info.icon, label: info.text || o.id, brand: _ssBrand(o.id), + active: d.metadata.active === o.id, available: o.available, + onclick: (editable && o.available) ? `setActiveSource('metadata','${o.id}')` : null, + }); + }).join(''); + // Surface the EFFECTIVE source when it differs from the configured one + // (e.g. configured Spotify but not authenticated → running on a fallback). + const eff = d.metadata.effective; + const note = (eff && eff !== d.metadata.active) + ? `<div class="ss-effective-note">Configured source isn't connected — actually using <b>${_ssEsc((_ssMetaInfo(eff).text) || eff)}</b> right now.</div>` + : ''; + panel.innerHTML = `${_ssHero('metadata')}<div class="ss-section-title">Choose source</div>${note}<div class="ss-grid">${cards}</div>`; + } else if (_ssState.tab === 'server') { + const cards = d.server.options.map(o => { + const info = _SS_SERVER_INFO[o.id] || { name: o.id }; + return _ssCard({ + logo: info.logo, emoji: '🖥️', label: info.name, brand: _ssBrand(o.id), dark: info.dark, + active: d.server.active === o.id, available: o.available, + onclick: (editable && o.available) ? `setActiveSource('server','${o.id}')` : null, + }); + }).join(''); + panel.innerHTML = `${_ssHero('server')}<div class="ss-section-title">Choose server</div><div class="ss-grid">${cards}</div>`; + } else { + _ssRenderDownloadPanel(panel, d, editable); + } +} + +function _ssRenderDownloadPanel(panel, d, editable) { + const isHybrid = d.download.mode === 'hybrid'; + const toggle = ` + <div class="ss-seg"> + <button class="ss-seg-btn${!isHybrid ? ' active' : ''}" ${editable ? `onclick="setDownloadMode('single')"` : 'disabled'}>Single source</button> + <button class="ss-seg-btn${isHybrid ? ' active' : ''}" ${editable ? `onclick="setDownloadMode('hybrid')"` : 'disabled'}>Hybrid</button> + </div>`; + + let body; + if (isHybrid) { + const order = (d.download.hybrid_order && d.download.hybrid_order.length) + ? d.download.hybrid_order + : d.download.options.map(o => o.id); + body = `<div class="ss-hint">Drag to set priority — SoulSync tries each in order.</div> + <div class="ss-hybrid-list" id="ss-hybrid-list">` + + order.map((id, i) => { + const info = _ssDownloadInfo(id); + return `<div class="ss-hybrid-item" draggable="${editable}" data-src="${id}"> + <span class="ss-hybrid-rank">${i + 1}</span> + ${info.logo ? `<img class="ss-hybrid-logo" src="${info.logo}" onerror="this.outerHTML='<span class=\\'ss-card-emoji\\'>${info.emoji}</span>'">` : `<span class="ss-card-emoji">${info.emoji}</span>`} + <span class="ss-hybrid-name">${_ssEsc(info.name)}</span> + </div>`; + }).join('') + `</div>`; + } else { + const cards = d.download.options.map(o => { + const info = _ssDownloadInfo(o.id); + return _ssCard({ + logo: info.logo, emoji: info.emoji, label: info.name, brand: _ssBrand(o.id), + active: d.download.mode === o.id, available: true, + onclick: editable ? `setActiveSource('download','${o.id}')` : null, + }); + }).join(''); + body = `<div class="ss-grid">${cards}</div>`; + } + panel.innerHTML = `${_ssHero('download')}<div class="ss-section-title">Choose source</div>${toggle}${body}`; + if (isHybrid && editable) _ssWireHybridDrag(); +} + +function _ssWireHybridDrag() { + const list = document.getElementById('ss-hybrid-list'); + if (!list) return; + list.querySelectorAll('.ss-hybrid-item').forEach(item => { + item.addEventListener('dragstart', (e) => { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', item.dataset.src); + item.classList.add('dragging'); + }); + item.addEventListener('dragend', () => item.classList.remove('dragging')); + item.addEventListener('dragover', (e) => { e.preventDefault(); }); + item.addEventListener('drop', (e) => { + e.preventDefault(); + const dragged = e.dataTransfer.getData('text/plain'); + if (dragged && dragged !== item.dataset.src) _ssReorderHybrid(dragged, item.dataset.src); + }); + }); +} + +function _ssReorderHybrid(draggedId, targetId) { + const order = (_ssState.data.download.hybrid_order && _ssState.data.download.hybrid_order.length) + ? _ssState.data.download.hybrid_order.slice() + : _ssState.data.download.options.map(o => o.id); + const from = order.indexOf(draggedId); + if (from < 0) return; + order.splice(from, 1); + const to = order.indexOf(targetId); + order.splice(to < 0 ? order.length : to, 0, draggedId); + _ssSave({ hybrid_order: order }); +} + +async function setActiveSource(kind, id) { + const key = kind === 'metadata' ? 'metadata_source' : kind === 'server' ? 'media_server' : 'download_mode'; + await _ssSave({ [key]: id }); +} + +async function setDownloadMode(which) { + if (which === 'hybrid') { + await _ssSave({ download_mode: 'hybrid' }); + } else { + // Switch to a single source — keep the current single choice if it was + // already single, else default to the first option. + const d = _ssState.data; + const cur = d.download.mode; + const single = (cur && cur !== 'hybrid') ? cur : (d.download.options[0] && d.download.options[0].id) || 'soulseek'; + await _ssSave({ download_mode: single }); + } +} + +async function _ssSave(patch) { + try { + const res = await fetch('/api/profiles/active-sources', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }); + const data = await res.json(); + if (!data.success) { + if (typeof showToast === 'function') showToast(data.error || 'Change failed', 'error'); + return; + } + if (typeof showToast === 'function') showToast('Updated', 'success'); + await _ssLoad(); // re-read + re-render with the new active state + if (typeof fetchAndUpdateServiceStatus === 'function') fetchAndUpdateServiceStatus(); + } catch (e) { + if (typeof showToast === 'function') showToast('Change failed', 'error'); + } +} diff --git a/webui/static/settings.js b/webui/static/settings.js index 533d66b8..4868a305 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -699,6 +699,21 @@ function buildHybridSourceList() { </label> `; + // Real drag-to-reorder (the help text promised it; previously only the + // arrow buttons worked — item.draggable was set with no handlers). + item.addEventListener('dragstart', (e) => { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', srcId); + item.classList.add('dragging'); + }); + item.addEventListener('dragend', () => item.classList.remove('dragging')); + item.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }); + item.addEventListener('drop', (e) => { + e.preventDefault(); + const draggedId = e.dataTransfer.getData('text/plain'); + if (draggedId && draggedId !== srcId) _reorderHybridSource(draggedId, srcId); + }); + container.appendChild(item); }); @@ -723,6 +738,22 @@ function moveHybridSource(srcId, direction) { debouncedAutoSaveSettings(); } +function _reorderHybridSource(draggedId, targetId) { + // Move draggedId to just before targetId in the visual order, then rebuild + // the enabled subset + persist — same model moveHybridSource uses. + if (!_hybridVisualOrder) return; + const from = _hybridVisualOrder.indexOf(draggedId); + if (from < 0) return; + _hybridVisualOrder.splice(from, 1); + const to = _hybridVisualOrder.indexOf(targetId); + if (to < 0) { _hybridVisualOrder.splice(from, 0, draggedId); return; } // target gone — undo + _hybridVisualOrder.splice(to, 0, draggedId); + _hybridSourceOrder = _hybridVisualOrder.filter(id => _hybridSourceEnabled[id] !== false); + buildHybridSourceList(); + updateDownloadSourceUI(); + debouncedAutoSaveSettings(); +} + function toggleHybridSource(srcId, enabled) { _hybridSourceEnabled[srcId] = enabled; // Rebuild enabled order from visual order so priority matches position @@ -1367,6 +1398,14 @@ async function loadSettingsData() { const corsField = document.getElementById('security-cors-origins'); if (corsField) corsField.value = corsOrigins; + // Reverse-proxy mode + auth-proxy header (default off / empty). + const trustProxy = document.getElementById('security-trust-proxy'); + if (trustProxy) trustProxy.checked = settings.security?.trust_reverse_proxy || false; + const authHeader = document.getElementById('security-auth-proxy-header'); + if (authHeader) authHeader.value = settings.security?.auth_proxy_header || ''; + const reqLogin = document.getElementById('security-require-login'); + if (reqLogin) reqLogin.checked = settings.security?.require_login || false; + // Check if admin has a PIN set const profilesRes = await fetch('/api/profiles'); const profilesData = await profilesRes.json(); @@ -1382,6 +1421,12 @@ async function loadSettingsData() { document.getElementById('security-require-pin').checked = false; document.getElementById('security-require-pin').disabled = true; } + + // Login: the "Require login" toggle is gated on an admin password — + // visually locked until Step 1 is done (anti-lockout, made obvious). + updateRequireLoginGate(adminProfile?.has_password || false); + // Show already-saved password/recovery state (vs looking unset). + applyLoginSavedState(adminProfile); } catch (error) { console.error('Error loading security settings:', error); } @@ -3115,6 +3160,9 @@ async function saveSettings(quiet = false) { security: { require_pin_on_launch: document.getElementById('security-require-pin')?.checked || false, cors_origins: document.getElementById('security-cors-origins')?.value?.trim() || '', + trust_reverse_proxy: document.getElementById('security-trust-proxy')?.checked || false, + auth_proxy_header: document.getElementById('security-auth-proxy-header')?.value?.trim() || '', + require_login: document.getElementById('security-require-login')?.checked || false, } }; @@ -3404,6 +3452,9 @@ async function revokeApiKey(keyId, label) { // Dashboard-specific test functions that create activity items async function testDashboardConnection(service) { + // 'spotify_free' is a display-only label for the no-auth composite; the real + // service to test is 'spotify'. + if (service === 'spotify_free') service = 'spotify'; try { showLoadingOverlay(`Testing ${service} service...`); diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index 5e1c1e62..f5a0b958 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -42,6 +42,11 @@ const SOURCE_LABELS = { logo: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png', tabClass: 'enh-tab-spotify', badgeClass: 'enh-badge-spotify', }, + spotify_free: { + text: 'Spotify (no auth)', icon: '🎵', + logo: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png', + tabClass: 'enh-tab-spotify', badgeClass: 'enh-badge-spotify', + }, itunes: { text: 'Apple Music', icon: '🍎', logo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/ITunes_logo.svg/960px-ITunes_logo.svg.png', @@ -3633,6 +3638,7 @@ function getMetadataSourceLabel(source) { if (source === 'hydrabase') return 'Hydrabase'; if (source === 'itunes') return 'iTunes'; if (source === 'musicbrainz') return 'MusicBrainz'; + if (source === 'spotify_free') return 'Spotify (no auth)'; if (source === 'spotify') return 'Spotify'; return 'Unmapped'; } @@ -3641,9 +3647,12 @@ function getMetadataSourcePresentation(metadataStatus, spotifyStatus) { const source = metadataStatus?.source; const sourceLabel = getMetadataSourceLabel(source); const connected = metadataStatus?.connected === true; - const sessionActive = spotifyStatus?.authenticated === true || (source === 'spotify' && connected); - const rateLimited = !!(source === 'spotify' && spotifyStatus?.rate_limited && spotifyStatus?.rate_limit); - const cooldown = !!(source === 'spotify' && spotifyStatus?.post_ban_cooldown > 0); + // 'spotify_free' (the no-auth composite) is part of the Spotify family for + // session/rate-limit/cooldown display. + const spotifyFamily = (source === 'spotify' || source === 'spotify_free'); + const sessionActive = spotifyStatus?.authenticated === true || (spotifyFamily && connected); + const rateLimited = !!(spotifyFamily && spotifyStatus?.rate_limited && spotifyStatus?.rate_limit); + const cooldown = !!(spotifyFamily && spotifyStatus?.post_ban_cooldown > 0); if (rateLimited) { const remaining = spotifyStatus.rate_limit?.remaining_seconds || 0; @@ -3670,7 +3679,7 @@ function getMetadataSourcePresentation(metadataStatus, spotifyStatus) { if (source) { return { statusClass: connected ? 'connected' : 'disconnected', - statusText: connected ? (source === 'spotify' ? `Connected (${metadataStatus?.response_time}ms)` : sourceLabel) : 'Disconnected', + statusText: connected ? (spotifyFamily ? `Connected (${metadataStatus?.response_time}ms)` : sourceLabel) : 'Disconnected', dotClass: connected ? 'connected' : 'disconnected', dotTitle: connected ? sourceLabel : 'Disconnected', sessionActive diff --git a/webui/static/style.css b/webui/static/style.css index bdf6f023..2be4fc5a 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -44272,119 +44272,247 @@ div.artist-hero-badge { .profile-manage-panel { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.7); + background: rgba(8, 10, 16, 0.72); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); display: flex; align-items: center; justify-content: center; z-index: 100000; + animation: pm-fade 0.18s ease; } +@keyframes pm-fade { from { opacity: 0; } to { opacity: 1; } } +@keyframes pm-rise { from { opacity: 0; transform: translateY(14px) scale(0.985); } to { opacity: 1; transform: none; } } + .profile-manage-content { - background: #1f2937; - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 16px; - padding: 28px; - width: 420px; - max-height: 80vh; + position: relative; + background: + radial-gradient(120% 80% at 50% -10%, rgba(99, 102, 241, 0.16), transparent 60%), + linear-gradient(180deg, #1c2230 0%, #161a24 100%); + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 20px; + padding: 0 0 24px; + width: 460px; + max-width: calc(100vw - 32px); + max-height: 86vh; overflow-y: auto; + box-shadow: 0 28px 80px -20px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.03) inset; + animation: pm-rise 0.26s cubic-bezier(0.22, 1, 0.36, 1); } .profile-manage-header { display: flex; - justify-content: space-between; align-items: center; - margin-bottom: 20px; + gap: 14px; + padding: 22px 24px 18px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + position: sticky; + top: 0; + background: linear-gradient(180deg, rgba(28, 34, 48, 0.96), rgba(28, 34, 48, 0.82)); + backdrop-filter: blur(8px); + z-index: 2; + margin-bottom: 18px; +} + +.profile-manage-header::before { + content: ""; + flex: 0 0 40px; + height: 40px; + border-radius: 12px; + background: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='9' cy='7' r='4'/%3E%3Cpath d='M23 21v-2a4 4 0 0 0-3-3.87'/%3E%3Cpath d='M16 3.13a4 4 0 0 1 0 7.75'/%3E%3C/svg%3E") center / 21px no-repeat, + linear-gradient(135deg, #6366f1, #8b5cf6); + box-shadow: 0 6px 18px -4px rgba(99, 102, 241, 0.6); } .profile-manage-header h3 { - color: #e5e7eb; - font-size: 18px; + color: #f3f4f6; + font-size: 19px; + font-weight: 650; margin: 0; + flex: 1; + letter-spacing: -0.2px; +} + +.profile-manage-header h3 small { + display: block; + color: rgba(229, 231, 235, 0.5); + font-size: 12px; + font-weight: 400; + margin-top: 2px; + letter-spacing: 0; } .profile-manage-close-btn { - background: none; - border: none; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); color: #9ca3af; - font-size: 24px; + font-size: 20px; + line-height: 1; + width: 32px; + height: 32px; + border-radius: 9px; cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; +} + +.profile-manage-close-btn:hover { + background: rgba(239, 68, 68, 0.14); + border-color: rgba(239, 68, 68, 0.3); + color: #f87171; + transform: rotate(90deg); } .profile-manage-list { display: flex; flex-direction: column; - gap: 8px; - margin-bottom: 24px; + gap: 9px; + margin-bottom: 22px; + padding: 0 24px; } .profile-manage-item { display: flex; align-items: center; - gap: 12px; - padding: 10px 12px; - background: rgba(0, 0, 0, 0.2); - border-radius: 8px; + gap: 13px; + padding: 12px 14px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 13px; + transition: transform 0.16s ease, background 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; +} + +.profile-manage-item:hover { + transform: translateY(-2px); + background: rgba(255, 255, 255, 0.055); + border-color: rgba(255, 255, 255, 0.12); + box-shadow: 0 10px 24px -14px rgba(0, 0, 0, 0.8); +} + +/* The profile you're currently signed in as */ +.profile-manage-item.is-current { + background: rgba(99, 102, 241, 0.1); + border-color: rgba(99, 102, 241, 0.45); + box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.25) inset; } .profile-manage-item .profile-avatar { - width: 36px; - height: 36px; - border-radius: 8px; - font-size: 16px; + width: 42px; + height: 42px; + border-radius: 11px; + font-size: 17px; + flex-shrink: 0; + box-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.6); } .profile-manage-item .profile-info { flex: 1; + min-width: 0; } .profile-manage-item .profile-info .name { - color: #e5e7eb; - font-size: 14px; - font-weight: 500; + color: #f3f4f6; + font-size: 14.5px; + font-weight: 600; + display: flex; + align-items: center; + gap: 7px; } .profile-manage-item .profile-info .role { - color: #6366f1; - font-size: 11px; + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 5px; +} + +.profile-role-pill { + display: inline-flex; + align-items: center; + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.3px; text-transform: uppercase; + padding: 2px 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.07); + color: rgba(229, 231, 235, 0.72); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.profile-role-pill--admin { + background: rgba(99, 102, 241, 0.16); + color: #a5b4fc; + border-color: rgba(99, 102, 241, 0.35); +} + +.profile-role-pill--current { + background: rgba(16, 185, 129, 0.16); + color: #6ee7b7; + border-color: rgba(16, 185, 129, 0.35); } .profile-manage-actions { display: flex; - gap: 4px; + gap: 6px; align-items: center; + flex-shrink: 0; } .profile-manage-item .profile-edit-btn, .profile-manage-item .profile-delete-btn { - background: none; - border: none; - color: #6b7280; - font-size: 16px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.07); + color: #9ca3af; cursor: pointer; - padding: 4px 8px; - border-radius: 4px; + width: 34px; + height: 34px; + padding: 0; + border-radius: 9px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; +} + +.profile-manage-item .profile-edit-btn svg, +.profile-manage-item .profile-delete-btn svg { + width: 15px; + height: 15px; } .profile-manage-item .profile-edit-btn:hover { - color: #6366f1; - background: rgba(99, 102, 241, 0.1); + color: #c7d2fe; + background: rgba(99, 102, 241, 0.16); + border-color: rgba(99, 102, 241, 0.4); + transform: translateY(-1px); } .profile-manage-item .profile-delete-btn:hover { - color: #ef4444; - background: rgba(239, 68, 68, 0.1); + color: #fca5a5; + background: rgba(239, 68, 68, 0.16); + border-color: rgba(239, 68, 68, 0.4); + transform: translateY(-1px); +} + +.profile-manage-add, .admin-pin-section { + padding: 0 24px; } .profile-edit-form { display: flex; flex-direction: column; gap: 10px; - padding: 12px; - background: rgba(99, 102, 241, 0.05); - border: 1px solid rgba(99, 102, 241, 0.2); - border-radius: 8px; - margin-top: 4px; + padding: 14px; + background: rgba(99, 102, 241, 0.07); + border: 1px solid rgba(99, 102, 241, 0.25); + border-radius: 12px; + margin-top: 8px; + animation: pm-rise 0.2s ease; } .profile-edit-buttons { @@ -44401,55 +44529,87 @@ div.artist-hero-badge { } .profile-manage-add h4, .admin-pin-section h4 { - color: #e5e7eb; + color: #f3f4f6; font-size: 14px; - margin: 0 0 12px; + font-weight: 650; + margin: 0 0 14px; + display: flex; + align-items: center; + gap: 8px; +} + +.profile-manage-add h4::before, .admin-pin-section h4::before { + content: ""; + width: 3px; + height: 14px; + border-radius: 2px; + background: linear-gradient(180deg, #6366f1, #8b5cf6); } .profile-input { width: 100%; - padding: 10px 14px; - background: rgba(0, 0, 0, 0.3); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 8px; - font-size: 13px; + padding: 11px 14px; + background: rgba(0, 0, 0, 0.28); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; color: #e5e7eb; font-size: 14px; margin-bottom: 10px; outline: none; box-sizing: border-box; + transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease; } .profile-input:focus { - border-color: #6366f1; + border-color: rgba(99, 102, 241, 0.7); + background: rgba(0, 0, 0, 0.38); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); } .profile-input::placeholder { - font-size: 12px; + font-size: 12.5px; + color: rgba(229, 231, 235, 0.38); } .profile-color-picker { display: flex; - gap: 8px; - margin-bottom: 10px; + gap: 9px; + margin-bottom: 12px; flex-wrap: wrap; } .profile-color-swatch { - width: 28px; - height: 28px; - border-radius: 6px; + width: 30px; + height: 30px; + border-radius: 9px; cursor: pointer; border: 2px solid transparent; - transition: border-color 0.2s, transform 0.15s; + box-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.5); + transition: transform 0.15s ease, box-shadow 0.15s ease; + position: relative; } .profile-color-swatch:hover { - transform: scale(1.15); + transform: scale(1.18) translateY(-1px); } .profile-color-swatch.selected { border-color: #fff; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.25), 0 4px 12px -2px rgba(0, 0, 0, 0.6); + transform: scale(1.1); +} + +.profile-color-swatch.selected::after { + content: "✓"; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 13px; + font-weight: 700; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); } /* .profile-create-btn migrated to .btn .btn--block .btn--primary. The class @@ -55534,6 +55694,12 @@ tr.tag-diff-same { border-radius: 10px; transition: all 0.2s; user-select: none; + cursor: grab; +} +.hybrid-source-item.dragging { + opacity: 0.5; + cursor: grabbing; + border-color: rgb(var(--accent-light-rgb)); } .hybrid-source-item:hover { border-color: rgba(255, 255, 255, 0.12); @@ -67515,3 +67681,255 @@ body.em-scroll-lock { overflow: hidden; } .blocklist-empty { padding: 26px 16px; text-align: center; color: rgba(255,255,255,0.4); font-size: 12.5px; } .blocklist-search-results::-webkit-scrollbar, .blocklist-current::-webkit-scrollbar { width: 8px; } .blocklist-search-results::-webkit-scrollbar-thumb, .blocklist-current::-webkit-scrollbar-thumb { background: rgba(var(--accent-rgb),0.28); border-radius: 999px; } + +/* ── Quick-switch modal (active Metadata / Server / Download) — Manage-Workers style ── */ +.ss-overlay { display: flex; align-items: center; justify-content: center; } +.ss-modal { + background: linear-gradient(160deg, #181a21 0%, #121319 100%); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 18px; width: min(760px, 94vw); max-height: 86vh; + display: flex; flex-direction: column; overflow: hidden; + box-shadow: 0 30px 80px rgba(0, 0, 0, 0.65); +} +.ss-modal.ss-in { animation: ss-pop 0.22s cubic-bezier(0.2, 0.9, 0.3, 1.2); } +@keyframes ss-pop { from { opacity: 0; transform: translateY(14px) scale(0.97); } to { opacity: 1; transform: none; } } + +.ss-topbar { + display: flex; align-items: center; gap: 14px; padding: 18px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(var(--accent-light-rgb), 0.05); +} +.ss-topbar-icon { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; } +.ss-topbar-logo { width: 36px; height: 36px; object-fit: contain; } +.ss-topbar-titles { flex: 1; } +.ss-topbar-title { margin: 0; font-size: 1.2rem; } +.ss-topbar-sub { color: rgba(255, 255, 255, 0.5); font-size: 0.82rem; margin-top: 2px; } +.ss-icon-btn { + background: rgba(255, 255, 255, 0.06); border: none; color: #fff; + width: 34px; height: 34px; border-radius: 9px; cursor: pointer; font-size: 1.1rem; +} +.ss-icon-btn:hover { background: rgba(255, 255, 255, 0.14); } + +.ss-body { display: flex; min-height: 320px; max-height: calc(86vh - 78px); } +.ss-rail { + width: 196px; flex-shrink: 0; padding: 14px 10px; display: flex; flex-direction: column; gap: 8px; + border-right: 1px solid rgba(255, 255, 255, 0.06); background: rgba(0, 0, 0, 0.22); +} +.ss-tab { + position: relative; display: flex; align-items: center; gap: 12px; padding: 11px 12px; + background: rgba(255, 255, 255, 0.02); border: 1px solid transparent; border-radius: 12px; cursor: pointer; + color: rgba(255, 255, 255, 0.7); text-align: left; transition: all 0.18s; overflow: hidden; +} +.ss-tab:hover { background: rgba(255, 255, 255, 0.05); color: #fff; } +.ss-tab.active { + background: linear-gradient(100deg, color-mix(in srgb, var(--ss-brand) 22%, transparent), rgba(255,255,255,0.02)); + border-color: color-mix(in srgb, var(--ss-brand) 55%, transparent); color: #fff; +} +.ss-tab.active::before { content: ''; position: absolute; left: 0; top: 8px; bottom: 8px; width: 3px; border-radius: 3px; background: var(--ss-brand); } +.ss-tab-disc { + width: 38px; height: 38px; flex-shrink: 0; border-radius: 50%; display: flex; align-items: center; justify-content: center; + background: #fff; box-shadow: 0 0 0 2px color-mix(in srgb, var(--ss-brand) 60%, transparent); +} +.ss-tab-logo { width: 24px; height: 24px; object-fit: contain; } +.ss-tab-disc .ss-tab-emoji { font-size: 1.2rem; } +.ss-tab-text { display: flex; flex-direction: column; line-height: 1.25; min-width: 0; } +.ss-tab-cat { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em; color: rgba(255,255,255,0.45); } +.ss-tab.active .ss-tab-cat { color: color-mix(in srgb, var(--ss-brand) 75%, white); } +.ss-tab-cur { font-size: 0.92rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.ss-panel { + flex: 1; padding: 22px 24px; overflow-y: auto; + background: + radial-gradient(120% 80% at 100% 0%, color-mix(in srgb, var(--ss-brand) 9%, transparent), transparent 60%), + radial-gradient(90% 60% at 0% 100%, rgba(255,255,255,0.02), transparent 70%); +} +.ss-hero { + position: relative; display: flex; align-items: center; gap: 16px; padding: 16px 18px; margin-bottom: 20px; + border-radius: 16px; overflow: hidden; + background: linear-gradient(120deg, color-mix(in srgb, var(--ss-brand) 24%, transparent), rgba(255,255,255,0.025)); + border: 1px solid color-mix(in srgb, var(--ss-brand) 40%, transparent); +} +.ss-hero::after { + content: ''; position: absolute; right: -40px; top: -60px; width: 180px; height: 180px; border-radius: 50%; + background: radial-gradient(circle, color-mix(in srgb, var(--ss-brand) 40%, transparent), transparent 70%); + pointer-events: none; +} +.ss-hero-disc { + width: 60px; height: 60px; flex-shrink: 0; border-radius: 50%; background: #fff; + display: flex; align-items: center; justify-content: center; + box-shadow: 0 0 0 3px var(--ss-brand), 0 0 26px color-mix(in srgb, var(--ss-brand) 55%, transparent); +} +.ss-hero-logo { width: 36px; height: 36px; object-fit: contain; } +.ss-hero-emoji { font-size: 1.8rem; } +.ss-hero-info { flex: 1; min-width: 0; } +.ss-hero-eyebrow { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.1em; color: color-mix(in srgb, var(--ss-brand) 70%, white); font-weight: 700; } +.ss-hero-name { font-size: 1.35rem; font-weight: 700; margin: 1px 0 3px; } +.ss-hero-sub { font-size: 0.82rem; color: rgba(255,255,255,0.55); } +.ss-hero-pill { + align-self: flex-start; font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; + padding: 4px 10px; border-radius: 999px; color: #0a0a0a; background: var(--ss-brand); +} +.ss-section-title { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.08em; color: rgba(255, 255, 255, 0.45); margin-bottom: 16px; } +.ss-hint { color: rgba(255, 255, 255, 0.5); font-size: 0.82rem; margin-bottom: 12px; } +.ss-empty { color: rgba(255, 255, 255, 0.4); font-style: italic; padding: 30px 0; text-align: center; } +.ss-effective-note { + background: rgba(255, 180, 60, 0.1); border: 1px solid rgba(255, 180, 60, 0.3); + color: #ffcf8a; border-radius: 10px; padding: 9px 13px; font-size: 0.82rem; margin-bottom: 16px; +} + +.ss-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(128px, 1fr)); gap: 14px; } +.ss-card { + position: relative; display: flex; flex-direction: column; align-items: center; gap: 12px; + padding: 20px 12px 16px; background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 16px; + cursor: pointer; transition: transform 0.16s, box-shadow 0.16s, border-color 0.16s, background 0.16s; color: #fff; +} +.ss-card:hover:not([disabled]) { transform: translateY(-3px); border-color: color-mix(in srgb, var(--ss-brand) 50%, transparent); } +.ss-card.active { + border-color: var(--ss-brand); + background: linear-gradient(160deg, color-mix(in srgb, var(--ss-brand) 16%, transparent), rgba(255,255,255,0.02)); + box-shadow: 0 0 0 1px var(--ss-brand), 0 10px 30px color-mix(in srgb, var(--ss-brand) 30%, transparent); +} +.ss-card-disc { + width: 58px; height: 58px; border-radius: 50%; background: #fff; + display: flex; align-items: center; justify-content: center; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--ss-brand) 45%, transparent); transition: box-shadow 0.16s; +} +.ss-card.active .ss-card-disc { box-shadow: 0 0 0 3px var(--ss-brand), 0 0 22px color-mix(in srgb, var(--ss-brand) 55%, transparent); } +/* White/light wordmark logos (Plex, SoulSync) need a dark disc to be visible. */ +.ss-disc--dark { background: #1f2329 !important; } +.ss-card--locked { opacity: 0.38; cursor: not-allowed; } +.ss-card[disabled] { cursor: default; } +.ss-card-logo { width: 34px; height: 34px; object-fit: contain; } +.ss-card-emoji { font-size: 1.7rem; line-height: 1; } +.ss-card-label { font-size: 0.85rem; text-align: center; font-weight: 500; } +.ss-card-badge { + position: absolute; top: 6px; left: 6px; font-size: 0.62rem; padding: 2px 6px; + background: rgba(0, 0, 0, 0.5); border-radius: 6px; color: rgba(255, 255, 255, 0.7); +} +.ss-card-check { + position: absolute; top: 6px; right: 8px; color: rgb(var(--accent-light-rgb)); font-weight: 700; +} + +.ss-seg { display: inline-flex; background: rgba(0, 0, 0, 0.25); border-radius: 10px; padding: 4px; margin-bottom: 16px; } +.ss-seg-btn { + border: none; background: transparent; color: rgba(255, 255, 255, 0.6); + padding: 7px 18px; border-radius: 8px; cursor: pointer; font-size: 0.85rem; transition: all 0.15s; +} +.ss-seg-btn.active { background: rgb(var(--accent-light-rgb)); color: #0a0a0a; font-weight: 600; } +.ss-seg-btn[disabled] { cursor: default; } + +.ss-hybrid-list { display: flex; flex-direction: column; gap: 8px; } +.ss-hybrid-item { + display: flex; align-items: center; gap: 12px; padding: 10px 14px; + background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; cursor: grab; +} +.ss-hybrid-item.dragging { opacity: 0.5; cursor: grabbing; border-color: rgb(var(--accent-light-rgb)); } +.ss-hybrid-rank { + width: 22px; height: 22px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; + background: rgba(var(--accent-light-rgb), 0.2); color: rgb(var(--accent-light-rgb)); + border-radius: 50%; font-size: 0.72rem; font-weight: 700; +} +.ss-hybrid-logo { width: 26px; height: 26px; object-fit: contain; } +.ss-hybrid-name { font-size: 0.9rem; } + +/* Service Status quick-switch (admin only) — show the hand cursor so it's + clearly clickable; non-admins get no clickable affordance. */ +.status-section--clickable { cursor: pointer; border-radius: 10px; transition: background 0.15s; } +.status-section--clickable:hover { background: rgba(255, 255, 255, 0.04); } +.status-section--clickable .status-indicator { cursor: pointer; } +.status-section--locked, .status-section--locked .status-indicator { cursor: default; } +.status-section--locked:hover { background: transparent; } + +/* ── My Accounts (per-profile self-auth for playlist services) ── */ +.ma-overlay { display: flex; align-items: center; justify-content: center; } +.ma-modal { + background: linear-gradient(160deg, #181a21 0%, #121319 100%); + border: 1px solid rgba(255,255,255,0.08); border-radius: 18px; + width: min(560px, 94vw); max-height: 86vh; display: flex; flex-direction: column; + overflow: hidden; box-shadow: 0 30px 80px rgba(0,0,0,0.65); +} +.ma-modal.ma-in { animation: ss-pop 0.22s cubic-bezier(0.2,0.9,0.3,1.2); } +.ma-topbar { + display: flex; align-items: center; gap: 14px; padding: 18px 20px; + border-bottom: 1px solid rgba(255,255,255,0.06); background: rgba(var(--accent-light-rgb),0.05); +} +.ma-topbar-icon { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; } +.ma-topbar-logo { width: 36px; height: 36px; object-fit: contain; } +.ma-topbar-titles { flex: 1; } +.ma-topbar-title { margin: 0; font-size: 1.2rem; } +.ma-topbar-sub { color: rgba(255,255,255,0.5); font-size: 0.82rem; margin-top: 2px; } +.ma-icon-btn { background: rgba(255,255,255,0.06); border: none; color: #fff; width: 34px; height: 34px; border-radius: 9px; cursor: pointer; font-size: 1.1rem; } +.ma-icon-btn:hover { background: rgba(255,255,255,0.14); } +.ma-body { padding: 16px 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; } +.ma-empty { color: rgba(255,255,255,0.4); font-style: italic; padding: 24px 0; text-align: center; } +.ma-row { + display: flex; align-items: center; gap: 14px; padding: 14px 16px; + background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; +} +.ma-disc { + width: 46px; height: 46px; flex-shrink: 0; border-radius: 50%; background: #fff; + display: flex; align-items: center; justify-content: center; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--ma-brand) 55%, transparent); +} +.ma-logo { width: 28px; height: 28px; object-fit: contain; } +.ma-row-info { flex: 1; min-width: 0; } +.ma-row-name { font-weight: 600; font-size: 0.98rem; } +.ma-row-status { font-size: 0.8rem; color: rgba(255,255,255,0.45); } +.ma-row-status.is-on { color: var(--ma-brand); } +.ma-row-action { display: flex; align-items: center; gap: 10px; } +.ma-account { font-size: 0.82rem; color: rgba(255,255,255,0.7); max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ma-note { font-size: 0.78rem; color: rgba(255,255,255,0.4); font-style: italic; } +.ma-btn { border: none; border-radius: 9px; padding: 8px 18px; cursor: pointer; font-size: 0.85rem; font-weight: 600; } +.ma-btn--connect { background: var(--ma-brand); color: #0a0a0a; } +.ma-btn--connect:hover { filter: brightness(1.1); } +.ma-btn--ghost { background: transparent; color: rgba(255,255,255,0.6); border: 1px solid rgba(255,255,255,0.18); } +.ma-btn--ghost:hover { background: rgba(255,255,255,0.06); color: #fff; } +.ma-disc.ma-disc--dark { background: #1f2329; } +.ma-token-input { + background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; + padding: 7px 11px; color: #fff; font-size: 0.82rem; width: 150px; +} +.ma-token-input:focus { outline: none; border-color: var(--ma-brand); } + +/* ── Security settings: grouped sub-sections + dependency visuals ── */ +.security-subgroup { + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 10px; + padding: 14px 16px 4px; + margin-bottom: 16px; + background: rgba(255, 255, 255, 0.02); +} +.security-subhead { + margin: 0 0 12px; + font-size: 14px; + font-weight: 600; + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} +.security-subhead-note { + font-size: 11px; + font-weight: 400; + opacity: 0.55; + letter-spacing: 0.02em; +} +.security-nested { + margin-left: 14px; + padding-left: 14px; + border-left: 2px solid rgba(255, 255, 255, 0.08); +} +.security-locked { + opacity: 0.5; +} +.security-locked .toggle-label { + cursor: not-allowed; +} +.security-saved-status { + font-size: 12px; + color: #4caf50; + font-weight: 500; + margin-bottom: 6px; +} diff --git a/webui/static/wishlist-tools.js b/webui/static/wishlist-tools.js index 1842e72a..64230654 100644 --- a/webui/static/wishlist-tools.js +++ b/webui/static/wishlist-tools.js @@ -410,6 +410,11 @@ async function selectDiscoveryFixTrack(track) { const requestBody = { identifier: backendIdentifier, track_index: trackIndex, + // #843: send the original (source) track so the backend can still save + // the match to the discovery cache when its in-memory discovery state + // is gone (server restart / imported playlist not discovered this run). + original_name: currentDiscoveryFix.sourceTrack || '', + original_artist: currentDiscoveryFix.sourceArtist || '', spotify_track: { id: track.id, name: track.name,