resolve merge conflict in style.css

This commit is contained in:
nick2000713 2026-06-11 18:21:04 +02:00
commit bf5affd03c
38 changed files with 2661 additions and 802 deletions

View file

@ -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:

134
Support/REVERSE-PROXY.md Normal file
View file

@ -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`.

View file

@ -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):

View file

@ -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,

View file

@ -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
@ -400,17 +409,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)

View file

@ -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"]

View file

@ -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

View file

@ -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"]

View file

@ -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']

View file

@ -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"]

View file

@ -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"]

View file

@ -460,6 +460,8 @@ 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)
@ -5267,6 +5269,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,
@ -5297,6 +5302,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,
@ -5398,6 +5406,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
@ -6654,17 +6828,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")
@ -6704,14 +6883,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 = []
@ -6734,6 +6917,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"""
@ -6742,7 +6952,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)

View file

@ -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'}

View file

@ -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'

View file

@ -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 / 'YouSeeBIGGIRLT: 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)

View file

@ -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

31
tests/test_auth_proxy.py Normal file
View file

@ -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

View file

@ -346,3 +346,68 @@ 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

View file

@ -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 == []

View file

@ -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

36
tests/test_login_gate.py Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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:')

View file

@ -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', '')

View file

@ -0,0 +1,68 @@
"""Find & Add library search relevance (Billie Eilish 'bad guy' report).
Root cause proven against the real DB: `ORDER BY tracks.title` is case-SENSITIVE
(SQLite BINARY sorts 'B' before 'b'), so a lowercase exact title like Billie
Eilish's "bad guy" sorted BELOW every capitalised "Bad Guy" and fell past the
result LIMIT it never showed in the modal even though it was in the library.
Fix: rank by relevance (exact title first, case-insensitive), and accept a
rank-only artist hint so an exact title+artist match wins without FILTERING
(filtering would re-hide the track if it's tagged under a slightly different
artist on the server).
"""
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 _insert(db, tid, title, artist_id, artist_name):
with db._get_connection() as conn:
conn.execute("INSERT OR IGNORE INTO artists (id, name) VALUES (?, ?)", (artist_id, artist_name))
conn.execute("INSERT OR IGNORE INTO albums (id, title, artist_id) VALUES (?, ?, ?)", (artist_id, "Alb", artist_id))
conn.execute(
"INSERT INTO tracks (id, album_id, artist_id, title, track_number, duration, file_path) "
"VALUES (?, ?, ?, ?, 1, 180, ?)",
(tid, artist_id, artist_id, title, f"/m/{tid}.mp3"),
)
conn.commit()
def test_lowercase_exact_title_not_buried_by_case(db):
# Capitalised "Bad Guy" tracks would (pre-fix) fill the LIMIT and sort
# the lowercase "bad guy" below them, cutting it off.
_insert(db, 1, "Bad Guy", 1, "Yara")
_insert(db, 2, "Bad Guy", 2, "Zelda")
_insert(db, 3, "bad guy", 3, "Billie Eilish")
names = [t.artist_name for t in db.search_tracks(title="bad guy", limit=2)]
assert "Billie Eilish" in names, "lowercase exact title must not be sorted past the limit"
def test_rank_artist_hint_floats_match_to_top_without_filtering(db):
_insert(db, 1, "Bad Guy", 1, "Aaa Artist")
_insert(db, 2, "Bad Guy", 2, "Bbb Artist")
_insert(db, 3, "bad guy", 3, "Billie Eilish")
results = db.search_tracks(title="bad guy", limit=10, rank_artist="Billie Eilish")
names = [t.artist_name for t in results]
assert names[0] == "Billie Eilish", "the hinted artist's exact match should rank first"
# …but it must NOT filter — the other artists' versions are still there.
assert len(results) == 3
assert {"Aaa Artist", "Bbb Artist"} <= set(names)
def test_exact_title_outranks_superstring_title(db):
# "bad guy" should beat "Bad Guy Necessity" / "Bad Guys" for the query.
_insert(db, 1, "Bad Guy Necessity", 1, "Aardvark") # would sort first alphabetically
_insert(db, 2, "bad guy", 2, "Billie Eilish")
top = db.search_tracks(title="bad guy", limit=5)[0]
assert top.title.lower() == "bad guy" and top.artist_name == "Billie Eilish"

28
tests/test_stale_guard.py Normal file
View file

@ -0,0 +1,28 @@
"""Storage-unreachable guard for library stale-removal (artist sync, #828 pattern)."""
from __future__ import annotations
from core.library.stale_guard import is_implausible_stale_removal as g
def test_all_missing_in_a_real_collection_is_blocked():
# 40/40 missing → almost certainly a down mount, not 40 real deletions.
assert g(40, 40) is True
assert g(30, 40) is True # 75% missing — still implausible
def test_a_few_genuinely_missing_files_are_allowed():
assert g(3, 40) is False # normal cleanup of a few gone files
assert g(20, 40) is False # exactly 50% is NOT over the threshold
def test_tiny_sets_are_never_blocked():
# A 2-track artist legitimately losing both must still clean up.
assert g(2, 2) is False
assert g(4, 4) is False # below min_total (5)
def test_edge_inputs():
assert g(0, 0) is False
assert g(0, 100) is False # nothing missing
assert g(5, 5) is True # min_total met, all missing

View file

@ -40,7 +40,7 @@ logger = setup_logging(_log_level, _log_path)
# App version — single source of truth for backup metadata, system-info, update check, etc.
# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release.
_SOULSYNC_BASE_VERSION = "2.6.9"
_SOULSYNC_BASE_VERSION = "2.7.0"
def _build_version_string():
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
@ -346,6 +346,14 @@ def _init_flask_secret_key():
app.secret_key = _init_flask_secret_key()
# --- Reverse-proxy mode (opt-in, default OFF) ---
# OFF by default → a strict no-op, so direct/LAN installs are unchanged. Only when
# the operator sets security.trust_reverse_proxy=true (behind nginx/Caddy/Traefik
# with TLS) does this trust X-Forwarded-* + mark the session cookie Secure.
from core.security.reverse_proxy import apply_reverse_proxy_mode as _apply_reverse_proxy_mode
if _apply_reverse_proxy_mode(app, config_manager.get):
logger.info("[Security] Reverse-proxy mode ON: trusting X-Forwarded-* and Secure session cookie")
# --- WebSocket (Socket.IO) Setup ---
from core.socketio_cors import (
resolve_cors_origins as _resolve_socketio_cors_origins,
@ -390,6 +398,41 @@ def inject_webui_assets():
'vite_assets': build_webui_vite_assets,
}
# Brute-force limiter for the launch-PIN unlock (lenient; only a flood of wrong
# PINs from one IP trips it — correct entry clears it instantly).
from core.security.rate_limit import AttemptLimiter as _AttemptLimiter
_launch_pin_limiter = _AttemptLimiter(max_attempts=10, window_seconds=300)
_login_limiter = _AttemptLimiter(max_attempts=10, window_seconds=300)
def _require_login_enabled():
try:
return bool(config_manager.get('security.require_login', False)) if config_manager else False
except Exception:
return False
# --- Login gate (opt-in username/password mode; replaces the launch PIN) ---
@app.before_request
def _enforce_login():
"""Server-side enforcement of username/password login. No-op unless
security.require_login is on. When on, an unauthenticated session can only
reach the page shell + the login flow + the key-authed public API."""
if not _require_login_enabled():
return
from core.security.login_gate import login_request_is_blocked
from core.security.launch_lock import is_html_navigation
if login_request_is_blocked(
request.path, request.method,
require_login=True,
authenticated=bool(session.get('login_authenticated', False)),
):
if is_html_navigation(request.method, request.headers.get('Accept', ''),
request.headers.get('Sec-Fetch-Mode', '')):
return redirect('/')
return jsonify({"error": "login_required", "login_required": True}), 401
# --- Launch PIN gate (before_request hook) ---
@app.before_request
def _enforce_launch_pin():
@ -401,6 +444,10 @@ def _enforce_launch_pin():
on, except the page shell + the unlock flow + the key-authed public API.
No-ops entirely when ``security.require_pin_on_launch`` is off (the default).
"""
# Login mode replaces the launch PIN entirely — when it's on, _enforce_login
# owns the gate and this no-ops.
if _require_login_enabled():
return
try:
require_pin = bool(config_manager.get('security.require_pin_on_launch', False)) if config_manager else False
except Exception:
@ -408,10 +455,21 @@ def _enforce_launch_pin():
if not require_pin:
return
from core.security.launch_lock import request_is_locked, is_html_navigation
# An auth proxy (Authelia/Authentik/oauth2-proxy) that already authenticated the
# user counts as verified — opt-in via security.auth_proxy_header, OFF (empty)
# by default so a direct install is unaffected.
from core.security.auth_proxy import trusted_proxy_user
try:
_proxy_header = config_manager.get('security.auth_proxy_header', '') or ''
except Exception:
_proxy_header = ''
_verified = bool(session.get('launch_pin_verified', False)) or bool(
trusted_proxy_user(request.headers.get, _proxy_header)
)
if request_is_locked(
request.path, request.method,
require_pin=require_pin,
pin_verified=bool(session.get('launch_pin_verified', False)),
pin_verified=_verified,
):
# A browser navigating to a sub-page (deep link / refresh) should land
# on the lock screen, not raw JSON — bounce it to the root, which serves
@ -3055,6 +3113,14 @@ def handle_settings():
if not new_settings:
return jsonify({"success": False, "error": "No data received."}), 400
# Anti-lockout: refuse to turn ON login mode until the admin account
# has a password — otherwise enabling it would lock everyone out.
_sec_in = new_settings.get('security') or {}
if _sec_in.get('require_login') and not config_manager.get('security.require_login', False):
if not get_database().profile_has_password(1):
return jsonify({"success": False,
"error": "Set an admin password before enabling login mode."}), 400
if 'active_media_server' in new_settings:
config_manager.set_active_media_server(new_settings['active_media_server'])
@ -12613,6 +12679,7 @@ def redownload_start(track_id):
@app.route('/api/library/artist/<artist_id>/sync', methods=['POST'])
@admin_only
def sync_artist_library(artist_id):
"""Bidirectional sync: pull new content from media server AND remove stale entries."""
try:
@ -12658,6 +12725,11 @@ def sync_artist_library(artist_id):
new_albums = 0
new_tracks = 0
name_updated = False
# Single-artist deep scan: collect the server track IDs we see during the
# pull. Stale removal (Phase 2) is a server-diff against this set — the SAME
# mechanism the whole-library deep scan uses, just scoped to one artist.
seen_track_ids = set()
pull_succeeded = False
if server_source:
media_client = None
@ -12712,55 +12784,77 @@ def sync_artist_library(artist_id):
artist_name = new_name
name_updated = True
# Process artist content (deep scan mode — skip existing, preserve enrichment)
# Process artist content (deep scan mode — skip existing,
# preserve enrichment) and collect the server's track IDs
# for this artist into seen_track_ids.
success, details, new_albums, new_tracks = worker._process_artist_with_content(
server_artist, skip_existing_tracks=True
server_artist, skip_existing_tracks=True, seen_track_ids=seen_track_ids
)
# Only a successful pull gives a trustworthy 'seen' set; a
# failure/partial would make every track look stale.
pull_succeeded = bool(success)
logger.info(f"[Artist Sync] Server pull for {artist_name}: {details}")
except Exception as e:
logger.error(f"[Artist Sync] Server pull failed for {artist_name}: {e}")
# ── Phase 2: Remove stale entries (files no longer on disk) ──
# ── Phase 2: Remove stale entries (tracks the server no longer has) ──
# Server-diff, exactly like the whole-library deep scan: stale = this
# artist's DB tracks that were NOT seen on the server during the pull.
stale_removed = 0
empty_albums_removed = 0
removal_skipped = False
with database._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT id, file_path FROM tracks WHERE artist_id = ?", (db_artist_id,))
tracks = cursor.fetchall()
if not pull_succeeded:
# No trustworthy server view (no server configured, unreachable, or the
# pull failed) — without it we can't tell stale from "server was down",
# so we remove nothing rather than risk wiping the artist.
removal_skipped = True
logger.info(f"[Artist Sync] {artist_name}: server pull unavailable — skipping stale removal")
else:
with database._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT id FROM tracks WHERE artist_id = ? AND server_source = ?",
(db_artist_id, server_source),
)
artist_track_ids = {row['id'] for row in cursor.fetchall()}
stale = artist_track_ids - seen_track_ids
stale_ids = []
for track in tracks:
fp = track['file_path']
if not fp:
stale_ids.append(track['id'])
continue
resolved = _resolve_library_file_path(fp)
if not resolved or not os.path.exists(resolved):
stale_ids.append(track['id'])
# Same safety net as deep scan (#828): if an implausibly large share
# of the artist's tracks went unseen, treat it as a flaky server
# response and skip rather than mass-delete.
from core.library.stale_guard import is_implausible_stale_removal
if is_implausible_stale_removal(len(stale), len(artist_track_ids)):
removal_skipped = True
logger.warning(
f"[Artist Sync] {artist_name}: {len(stale)}/{len(artist_track_ids)} tracks "
f"unseen on server — skipping stale removal (likely a flaky response)"
)
elif stale:
stale_removed = database.delete_stale_tracks(stale, server_source)
if stale_ids:
placeholders = ','.join('?' for _ in stale_ids)
cursor.execute(f"DELETE FROM tracks WHERE id IN ({placeholders})", stale_ids)
stale_removed = len(stale_ids)
cursor.execute("""
DELETE FROM albums WHERE artist_id = ?
AND id NOT IN (SELECT DISTINCT album_id FROM tracks)
""", (db_artist_id,))
empty_albums_removed = cursor.rowcount
cursor.execute("""
UPDATE albums SET track_count = (
SELECT COUNT(*) FROM tracks WHERE tracks.album_id = albums.id
) WHERE artist_id = ?
""", (db_artist_id,))
conn.commit()
if not removal_skipped:
with database._get_connection() as conn:
cursor = conn.cursor()
# Prune albums left with no tracks. ``album_id IS NOT NULL``
# avoids the NOT IN-with-NULL gotcha that would otherwise no-op
# this whenever a track has a null album_id.
cursor.execute("""
DELETE FROM albums WHERE artist_id = ?
AND id NOT IN (SELECT DISTINCT album_id FROM tracks WHERE album_id IS NOT NULL)
""", (db_artist_id,))
empty_albums_removed = cursor.rowcount
cursor.execute("""
UPDATE albums SET track_count = (
SELECT COUNT(*) FROM tracks WHERE tracks.album_id = albums.id
) WHERE artist_id = ?
""", (db_artist_id,))
conn.commit()
logger.warning(f"[Artist Sync] {artist_name}: +{new_albums} albums, +{new_tracks} tracks, "
f"-{stale_removed} stale, -{empty_albums_removed} empty albums")
f"-{stale_removed} stale, -{empty_albums_removed} empty albums"
f"{' (removal skipped — storage unreachable)' if removal_skipped else ''}")
return jsonify({
"success": True,
@ -12770,6 +12864,7 @@ def sync_artist_library(artist_id):
"new_tracks": new_tracks,
"stale_removed": stale_removed,
"empty_albums_removed": empty_albums_removed,
"removal_skipped": removal_skipped,
})
except Exception as e:
@ -19449,14 +19544,24 @@ def server_playlist_add_track(playlist_id):
elif active_server == 'jellyfin' and media_server_engine.client('jellyfin'):
from core.sync.playlist_edit import plan_playlist_add
current_tracks = media_server_engine.client('jellyfin').get_playlist_tracks(playlist_id) or []
jf = media_server_engine.client('jellyfin')
current_tracks = jf.get_playlist_tracks(playlist_id) or []
track_ids = [str(t.ratingKey) for t in current_tracks]
# Matching an unmatched source to a track already in the playlist
# is a LINK, not a second copy — don't duplicate it (#768).
plan = plan_playlist_add(track_ids, track_id, is_link=bool(source_track_id), position=position)
if plan['should_insert']:
new_track_objs = [type('T', (), {'ratingKey': tid, 'title': ''})() for tid in plan['new_ids']]
media_server_engine.client('jellyfin').update_playlist(playlist_name, new_track_objs)
# #837: append the ONE found track IN PLACE. The old path called
# update_playlist(full track list), which on Jellyfin/Emby deletes
# and recreates the playlist — wiping its description + cover image.
# append_to_playlist adds in place (dedupe-safe), the same
# non-destructive op the 'append' sync mode already uses. It reads
# `.id` (not ratingKey) off each track, so set both.
new_track_obj = type('T', (), {
'id': str(track_id), 'ratingKey': str(track_id),
'title': server_track_title or '',
})()
jf.append_to_playlist(playlist_name, [new_track_obj])
_persist_find_and_add_match(source_track_id, active_server, track_id, server_track_title, source_title, source_artist, source_provider)
return jsonify({"success": True, "message": "Track linked" if not plan['should_insert'] else "Track added"})
@ -19567,6 +19672,10 @@ def library_search_tracks():
"""Search SoulSync's local database for tracks (for manual match correction)."""
try:
query = request.args.get('q', '').strip()
# Optional source-artist relevance hint (Find & Add knows the artist of
# the track it's matching) — used only to rank exact title+artist matches
# to the top, NOT to filter.
artist_hint = request.args.get('artist', '').strip()
limit = int(request.args.get('limit', 10))
if not query:
return jsonify({"success": True, "tracks": []})
@ -19596,7 +19705,8 @@ def library_search_tracks():
return f"{_art_prefix}{url}{_art_suffix}"
return url
results = database.search_tracks(title=query, artist='', limit=limit, server_source=active_server)
results = database.search_tracks(title=query, artist='', limit=limit,
server_source=active_server, rank_artist=artist_hint)
tracks = []
for t in results:
@ -25565,6 +25675,12 @@ def select_profile():
def get_current_profile():
"""Get the currently selected profile from session"""
try:
# Login mode: when on and the session isn't authenticated, tell the
# frontend to show the sign-in screen (this is checked before profile
# selection, since there's no profile until you log in).
if _require_login_enabled() and not session.get('login_authenticated', False):
return jsonify({'success': False, 'login_required': True}), 200
pid = session.get('profile_id')
if not pid:
return jsonify({'success': False, 'error': 'No profile selected'}), 200
@ -25588,6 +25704,7 @@ def get_current_profile():
'success': True,
'profile': profile,
'launch_pin_required': bool(require_pin) and not pin_verified,
'login_mode': _require_login_enabled(),
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@ -25596,6 +25713,15 @@ def get_current_profile():
def verify_launch_pin():
"""Verify PIN for launch lock screen"""
try:
# Brute-force guard: only a flood of WRONG PINs from one IP trips this; a
# correct entry clears it instantly, so normal use is never affected.
_ip = request.remote_addr or 'unknown'
_now = time.time()
_locked, _retry_after = _launch_pin_limiter.is_locked(_ip, _now)
if _locked:
return (jsonify({'success': False, 'error': 'Too many attempts — please wait and try again'}),
429, {'Retry-After': str(_retry_after)})
data = request.json or {}
pin = data.get('pin', '')
if not pin:
@ -25604,13 +25730,135 @@ def verify_launch_pin():
database = get_database()
# Validate against admin profile (ID 1)
if not database.verify_profile_pin(1, pin):
_launch_pin_limiter.record_failure(_ip, _now)
return jsonify({'success': False, 'error': 'Invalid PIN'}), 401
_launch_pin_limiter.record_success(_ip)
session['launch_pin_verified'] = True
return jsonify({'success': True})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/auth/login', methods=['POST'])
def auth_login():
"""Username/password login (opt-in login mode). Username = profile name.
Brute-force limited per IP; a profile with no password set can't log in."""
try:
_ip = request.remote_addr or 'unknown'
_now = time.time()
_locked, _retry_after = _login_limiter.is_locked(_ip, _now)
if _locked:
return (jsonify({'success': False, 'error': 'Too many attempts — please wait and try again'}),
429, {'Retry-After': str(_retry_after)})
data = request.json or {}
username = (data.get('username') or '').strip()
password = data.get('password') or ''
if not username or not password:
return jsonify({'success': False, 'error': 'Username and password required'}), 400
database = get_database()
profile = database.get_profile_by_name(username)
# Same generic error + a recorded failure whether the name or password is
# wrong — don't leak which names exist.
if not profile or not database.verify_profile_password(profile['id'], password):
_login_limiter.record_failure(_ip, _now)
return jsonify({'success': False, 'error': 'Invalid username or password'}), 401
_login_limiter.record_success(_ip)
session['login_authenticated'] = True
session['profile_id'] = profile['id']
# A fresh login also clears any stale launch-PIN flag.
session.pop('launch_pin_verified', None)
return jsonify({'success': True, 'profile': {
'id': profile['id'], 'name': profile['name'], 'is_admin': profile['is_admin'],
}})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/auth/logout', methods=['POST'])
def auth_logout():
"""Log out — clears the authenticated session."""
try:
session.pop('login_authenticated', None)
session.pop('profile_id', None)
session.pop('launch_pin_verified', None)
return jsonify({'success': True})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/auth/recovery-question', methods=['GET'])
def auth_recovery_question():
"""Return the recovery security-question for a username (forgot-password flow).
Generic when the user/question is absent don't confirm which names exist."""
try:
username = (request.args.get('username') or '').strip()
database = get_database()
profile = database.get_profile_by_name(username) if username else None
question = database.get_profile_recovery_question(profile['id']) if profile else None
if not question:
return jsonify({'success': False, 'error': 'No recovery question available'}), 404
return jsonify({'success': True, 'question': question})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/auth/recovery-reset', methods=['POST'])
def auth_recovery_reset():
"""Reset a login password by answering the recovery question. Brute-force
limited; a correct answer sets the new password and authenticates the session."""
try:
_ip = request.remote_addr or 'unknown'
_now = time.time()
_locked, _retry_after = _login_limiter.is_locked(_ip, _now)
if _locked:
return (jsonify({'success': False, 'error': 'Too many attempts — please wait and try again'}),
429, {'Retry-After': str(_retry_after)})
data = request.json or {}
username = (data.get('username') or '').strip()
answer = data.get('answer') or ''
new_password = data.get('new_password') or ''
if not username or not answer or not new_password:
return jsonify({'success': False, 'error': 'Username, answer and new password are required'}), 400
if len(new_password) < 6:
return jsonify({'success': False, 'error': 'New password must be at least 6 characters'}), 400
database = get_database()
profile = database.get_profile_by_name(username)
if not profile or not database.verify_profile_recovery_answer(profile['id'], answer):
_login_limiter.record_failure(_ip, _now)
return jsonify({'success': False, 'error': 'Incorrect answer'}), 401
_login_limiter.record_success(_ip)
database.set_profile_password(profile['id'], new_password)
session['login_authenticated'] = True
session['profile_id'] = profile['id']
session.pop('launch_pin_verified', None)
return jsonify({'success': True})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/profiles/<int:profile_id>/set-recovery', methods=['POST'])
def set_profile_recovery_endpoint(profile_id):
"""Set or clear a profile's recovery question + answer (admin, or self)."""
try:
database = get_database()
current_pid = get_current_profile_id()
current = database.get_profile(current_pid)
if not current or (not current['is_admin'] and current_pid != profile_id):
return jsonify({'success': False, 'error': 'Unauthorized'}), 403
data = request.json or {}
ok = database.set_profile_recovery(profile_id, data.get('question', ''), data.get('answer', ''))
return jsonify({'success': bool(ok), 'has_recovery': database.profile_has_recovery(profile_id)})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/profiles/reset-pin-via-credential', methods=['POST'])
def reset_pin_via_credential():
"""Reset admin PIN by verifying a known API credential"""
@ -25690,6 +25938,24 @@ def set_profile_pin(profile_id):
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/profiles/<int:profile_id>/set-password', methods=['POST'])
def set_profile_password_endpoint(profile_id):
"""Set or clear a profile's LOGIN password (admin, or the profile itself).
Distinct from the quick-switch PIN."""
try:
database = get_database()
current_pid = get_current_profile_id()
current = database.get_profile(current_pid)
if not current or (not current['is_admin'] and current_pid != profile_id):
return jsonify({'success': False, 'error': 'Unauthorized'}), 403
data = request.json or {}
password = data.get('password', '')
ok = database.set_profile_password(profile_id, password)
return jsonify({'success': bool(ok), 'has_password': database.profile_has_password(profile_id)})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
# --- Per-Profile ListenBrainz Settings ---
def _get_lb_credentials_for_profile(profile_id=None):

View file

@ -51,6 +51,40 @@
</div>
</div>
<!-- Login Screen (username/password mode) -->
<div id="login-overlay" class="launch-pin-overlay" style="display: none;">
<div class="launch-pin-container">
<!-- Sign-in view -->
<div id="login-entry">
<div class="launch-pin-icon">🔐</div>
<h2 class="launch-pin-title">Sign in to SoulSync</h2>
<p class="launch-pin-subtitle">Enter your account name and password</p>
<input type="text" id="login-username" class="launch-pin-input" placeholder="Username" autocomplete="username" maxlength="40" style="margin-bottom: 8px; letter-spacing: normal;">
<input type="password" id="login-password" class="launch-pin-input" placeholder="Password" autocomplete="current-password" maxlength="200" style="letter-spacing: normal;" onkeydown="if(event.key==='Enter') submitLogin()">
<button id="login-submit" class="launch-pin-submit" onclick="submitLogin()">Sign in</button>
<p id="login-error" class="launch-pin-error" style="display: none;"></p>
<button class="launch-pin-forgot" onclick="showLoginRecovery()">Forgot password?</button>
</div>
<!-- Recovery view -->
<div id="login-recovery" style="display: none;">
<div class="launch-pin-icon">🔑</div>
<h2 class="launch-pin-title">Reset your password</h2>
<p class="launch-pin-subtitle">Answer your recovery question</p>
<input type="text" id="recovery-username" class="launch-pin-input" placeholder="Username" autocomplete="username" maxlength="40" style="margin-bottom: 8px; letter-spacing: normal;">
<button id="recovery-fetch-btn" class="launch-pin-submit" onclick="fetchRecoveryQuestion()">Continue</button>
<div id="recovery-answer-section" style="display: none;">
<p id="recovery-question-text" class="launch-pin-subtitle" style="margin-top: 12px; font-weight: 600;"></p>
<input type="text" id="recovery-answer" class="launch-pin-input" placeholder="Your answer" autocomplete="off" maxlength="120" style="margin-bottom: 8px; letter-spacing: normal;">
<input type="password" id="recovery-new-password" class="launch-pin-input" placeholder="New password (min 6)" autocomplete="new-password" maxlength="200" style="margin-bottom: 8px; letter-spacing: normal;">
<input type="password" id="recovery-new-password-confirm" class="launch-pin-input" placeholder="Confirm new password" autocomplete="new-password" maxlength="200" style="letter-spacing: normal;" onkeydown="if(event.key==='Enter') submitRecoveryReset()">
<button class="launch-pin-submit" onclick="submitRecoveryReset()">Reset password</button>
</div>
<p id="recovery-error" class="launch-pin-error" style="display: none;"></p>
<button class="launch-pin-forgot" onclick="showLoginEntry()">← Back to sign in</button>
</div>
</div>
</div>
<!-- Profile Picker Overlay -->
<div id="profile-picker-overlay" class="profile-picker-overlay" style="display: none;">
<div class="profile-picker-container">
@ -193,6 +227,9 @@
<button id="personal-settings-btn" class="personal-settings-trigger" onclick="event.stopPropagation(); openPersonalSettings()" title="My Settings">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
<button id="logout-btn" class="personal-settings-trigger" style="display:none" onclick="event.stopPropagation(); soulsyncLogout()" title="Sign out">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</button>
</div>
</div>
@ -5958,40 +5995,122 @@
</div><!-- end Library Preferences body -->
<!-- Security Settings -->
<!-- Security & Access Settings -->
<div class="settings-group" data-stg="advanced">
<h3>🔒 Security</h3>
<div class="form-group" id="security-pin-setup" style="display: none;">
<label>Set Admin PIN:</label>
<div class="setting-help-text" style="margin-bottom: 8px;">
You need to set a PIN before enabling the lock screen.
</div>
<input type="password" id="security-new-pin" placeholder="Enter PIN" maxlength="20" autocomplete="off" style="margin-bottom: 6px;">
<input type="password" id="security-confirm-pin" placeholder="Confirm PIN" maxlength="20" autocomplete="off" style="margin-bottom: 6px;">
<button class="auth-button" id="security-save-pin-btn" onclick="saveSecurityPin()">Save PIN</button>
<p id="security-pin-msg" class="setting-help-text" style="margin-top: 6px; display: none;"></p>
<h3>🔒 Security &amp; Access</h3>
<div class="setting-help-text" style="margin-bottom: 18px;">
By default, anyone who can reach SoulSync on your network can use it. Protect access below with a simple shared <strong>PIN</strong>, or full <strong>user accounts</strong> with passwords — you only need one. The last group is just for exposing SoulSync over the internet.
</div>
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" id="security-require-pin" onchange="handleSecurityPinToggle(this)">
<span>Require PIN to access SoulSync</span>
</label>
<div class="setting-help-text">
When enabled, a lock screen appears on every page load. PIN is verified against the admin account. Closing the browser tab requires re-entry.
<!-- ── Method A: PIN ── -->
<div class="security-subgroup">
<h4 class="security-subhead">🔑 Lock with a PIN <span class="security-subhead-note">simple · one shared PIN</span></h4>
<div class="form-group" id="security-pin-setup" style="display: none;">
<label>Step 1 — Set admin PIN:</label>
<div class="setting-help-text" style="margin-bottom: 8px;">
Set a PIN before you can turn on the lock screen.
</div>
<input type="password" id="security-new-pin" placeholder="Enter PIN" maxlength="20" autocomplete="off" style="margin-bottom: 6px;">
<input type="password" id="security-confirm-pin" placeholder="Confirm PIN" maxlength="20" autocomplete="off" style="margin-bottom: 6px;">
<button class="auth-button" id="security-save-pin-btn" onclick="saveSecurityPin()">Save PIN</button>
<p id="security-pin-msg" class="setting-help-text" style="margin-top: 6px; display: none;"></p>
</div>
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" id="security-require-pin" onchange="handleSecurityPinToggle(this)">
<span>Require PIN to access SoulSync</span>
</label>
<div class="setting-help-text">
A lock screen appears on every page load, verified against the admin PIN. Closing the tab requires re-entry.
</div>
</div>
<div class="form-group" id="security-change-pin-section" style="display: none;">
<button class="auth-button" onclick="showChangeSecurityPin()">Change PIN</button>
</div>
</div>
<div class="form-group" id="security-change-pin-section" style="display: none;">
<button class="auth-button" onclick="showChangeSecurityPin()">Change PIN</button>
<!-- ── Method B: Login accounts ── -->
<div class="security-subgroup">
<h4 class="security-subhead">👤 User accounts (login) <span class="security-subhead-note">per-person · best for public access</span></h4>
<div class="setting-help-text" style="margin-bottom: 12px;">
Everyone signs in with their account name + password. Turning this on <strong>replaces</strong> the PIN and the profile picker. Set passwords for other people in <strong>Manage Profiles</strong>.
</div>
<div class="form-group">
<label>Step 1 — Admin login password:</label>
<div class="security-saved-status" id="security-login-password-status" style="display:none;">✓ A login password is set</div>
<input type="password" id="security-login-password" placeholder="Enter password (min 6)" maxlength="200" autocomplete="new-password" style="margin: 6px 0;">
<input type="password" id="security-login-password-confirm" placeholder="Confirm password" maxlength="200" autocomplete="new-password" style="margin-bottom: 6px;">
<button class="auth-button" onclick="saveLoginPassword()">Save Password</button>
<p id="security-login-password-msg" class="setting-help-text" style="margin-top: 6px; display: none;"></p>
</div>
<div class="form-group">
<label>Step 2 <span style="opacity:0.6">(recommended)</span> — Password recovery question:</label>
<div class="setting-help-text" style="margin-bottom: 8px;">
So you can reset a forgotten password by answering it on the sign-in screen.
</div>
<div class="security-saved-status" id="security-recovery-status" style="display:none;"></div>
<select id="security-recovery-question" class="form-select" onchange="handleRecoveryQuestionChange()" style="margin-bottom: 6px;">
<option value="">— Select a question —</option>
<option>What was the name of your first pet?</option>
<option>What city were you born in?</option>
<option>What was your first concert?</option>
<option>What is your all-time favorite album?</option>
<option>What was the make of your first car?</option>
<option value="__custom__">Custom question…</option>
</select>
<input type="text" id="security-recovery-custom" placeholder="Type your own question" maxlength="120" style="display:none; margin-bottom: 6px;">
<input type="text" id="security-recovery-answer" placeholder="Your answer" maxlength="120" autocomplete="off" style="margin-bottom: 6px;">
<button class="auth-button" onclick="saveRecoveryQuestion()">Save Recovery Question</button>
<p id="security-recovery-msg" class="setting-help-text" style="margin-top: 6px; display: none;"></p>
</div>
<div class="form-group" id="security-login-toggle-wrap">
<label class="toggle-label">
<input type="checkbox" id="security-require-login">
<span>Step 3 — Require login (username + password)</span>
</label>
<div class="setting-help-text" id="security-require-login-help">
Replaces the profile picker + PIN with a sign-in screen. Best for instances exposed to the internet.
</div>
</div>
</div>
<div class="form-group">
<label for="security-cors-origins">Allowed WebSocket Origins:</label>
<textarea id="security-cors-origins" rows="3" placeholder="https://soulsync.example.com&#10;http://192.168.1.5:8888" style="width: 100%; font-family: monospace; font-size: 12px;"></textarea>
<div class="setting-help-text">
Origins (full URL, no trailing slash) allowed to open WebSocket connections to this instance — one per line, or comma-separated. Leave empty for same-origin only (the secure default; works for direct access and most reverse-proxy setups). Add your public domain here if you reach SoulSync via a reverse proxy or custom domain and the WebSocket fails to connect. Use <code>*</code> on its own line to allow any origin (insecure — only do this if you understand why you need it).
<!-- ── Reverse proxy & remote access ── -->
<div class="security-subgroup">
<h4 class="security-subhead">🌐 Reverse proxy &amp; remote access</h4>
<div class="setting-help-text" style="margin-bottom: 12px;">
Only needed if you expose SoulSync to the internet behind nginx / Caddy / Traefik. See <code>Support/REVERSE-PROXY.md</code>.
</div>
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" id="security-trust-proxy">
<span>Behind a reverse proxy</span>
</label>
<div class="setting-help-text">
Trusts the proxy's <code>X-Forwarded-*</code> headers, marks the session cookie HTTPS-only, and adds security headers. <strong>Leave OFF for direct / LAN access over http://</strong> — it would otherwise break plain-HTTP login. <strong>Takes effect after a restart.</strong>
</div>
</div>
<div class="form-group security-nested">
<label for="security-auth-proxy-header">↳ Auth proxy user header <span style="opacity:0.6">(optional)</span>:</label>
<input type="text" id="security-auth-proxy-header" placeholder="Remote-User" autocomplete="off" style="width: 100%; font-family: monospace; font-size: 12px;">
<div class="setting-help-text">
If an auth proxy (Authelia / Authentik / oauth2-proxy) logs users in in front of SoulSync, enter the header it sets and SoulSync trusts it. <strong>Only set this behind a proxy that strips any client-supplied copy of the header.</strong> Blank = off.
</div>
</div>
<div class="form-group">
<label for="security-cors-origins">Allowed WebSocket Origins:</label>
<textarea id="security-cors-origins" rows="3" placeholder="https://soulsync.example.com&#10;http://192.168.1.5:8888" style="width: 100%; font-family: monospace; font-size: 12px;"></textarea>
<div class="setting-help-text">
Origins (full URL, no trailing slash) allowed to open WebSocket connections — one per line or comma-separated. Empty = same-origin only (the secure default; fine for direct access and most reverse-proxy setups). Add your public domain if live updates fail behind a proxy. <code>*</code> on its own line allows any origin (insecure).
</div>
</div>
</div>
</div>

View file

@ -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
? `<span class="candidates-quality-badge candidates-quality-${c.quality.toLowerCase()}">${c.quality.toUpperCase()}</span>`
: '';

View file

@ -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 14 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/<batch_id>/``). (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 ``<batch_id>`` 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 ``<download_dir>/<username>/<filename>`` 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: \'<playlist name>\'" 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 - <title>` 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: [],
},
];

View file

@ -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;

View file

@ -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 {

View file

@ -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) {

View file

@ -1400,6 +1400,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();
@ -1415,6 +1423,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);
}
@ -3149,6 +3163,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,
}
};

View file

@ -67936,3 +67936,44 @@ body.em-scroll-lock { overflow: hidden; }
.verif-detail-label { color: rgba(255,255,255,0.4); margin-right: 4px; }
.verif-banner-spacer { flex: 1; }
.verif-bulk-danger { border-color: rgba(248,113,113,0.4) !important; color: #f87171 !important; }
/* ── 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;
}

View file

@ -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,