From a41eccbe3c29adced7f5e65bb2a9df1bf30c2d8d Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 22 May 2026 08:28:56 -0700 Subject: [PATCH 01/26] Fix Usenet settings reload without restart Refresh registry-backed download plugins when settings are saved so cached Prowlarr clients pick up new indexer credentials immediately. This preserves active download state by reloading existing plugin instances instead of rebuilding the registry. Add regression coverage for orchestrator reload fanout and the Usenet plugin's cached ProwlarrClient refresh path. --- core/download_orchestrator.py | 16 ++++++++++ tests/downloads/test_download_orchestrator.py | 31 +++++++++++++++++++ tests/test_torrent_usenet_plugins.py | 28 +++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/core/download_orchestrator.py b/core/download_orchestrator.py index 879d0f52..1984c0a6 100644 --- a/core/download_orchestrator.py +++ b/core/download_orchestrator.py @@ -113,6 +113,22 @@ class DownloadOrchestrator: if hasattr(amazon, '_client') and amazon._client: amazon._client.preferred_codec = quality + # Let registry-backed plugins refresh any config they cache at + # construction time. This covers Prowlarr-backed torrent / usenet + # clients without rebuilding the registry and losing active downloads. + for name, client in self.registry.all_plugins(): + if not hasattr(client, 'reload_settings'): + continue + try: + client.reload_settings() + logger.info("%s client settings reloaded", self.registry.display_name(name)) + except Exception as exc: + logger.warning( + "%s client settings reload failed: %s", + self.registry.display_name(name), + exc, + ) + # Reload download path for all clients that cache it. # Soulseek owns the path config and is reloaded above; every # other source mirrors that path so files all land in one diff --git a/tests/downloads/test_download_orchestrator.py b/tests/downloads/test_download_orchestrator.py index e157047e..b551750e 100644 --- a/tests/downloads/test_download_orchestrator.py +++ b/tests/downloads/test_download_orchestrator.py @@ -198,6 +198,37 @@ def test_reload_instances_with_no_args_reloads_every_source(): assert b.reload_called is True +def test_reload_settings_refreshes_registry_plugins(monkeypatch): + """Settings saves should refresh plugins that cache config at init. + + Prowlarr-backed torrent / usenet clients keep a ProwlarrClient + instance, so without this hook newly-saved indexer settings only + took effect after process restart. + """ + + class _ReloadSettingsClient(_FakeClient): + def __init__(self): + super().__init__() + self.reload_calls = 0 + + def reload_settings(self): + self.reload_calls += 1 + + torrent = _ReloadSettingsClient() + usenet = _ReloadSettingsClient() + orch = _build_orchestrator(torrent=torrent, usenet=usenet) + + monkeypatch.setattr( + 'core.download_orchestrator.config_manager.get', + lambda _key, default=None: default, + ) + + orch.reload_settings() + + assert torrent.reload_calls == 1 + assert usenet.reload_calls == 1 + + # --------------------------------------------------------------------------- # Singleton factory (matches Cin's get_metadata_engine pattern) # --------------------------------------------------------------------------- diff --git a/tests/test_torrent_usenet_plugins.py b/tests/test_torrent_usenet_plugins.py index 57f11079..1dd9f75b 100644 --- a/tests/test_torrent_usenet_plugins.py +++ b/tests/test_torrent_usenet_plugins.py @@ -381,6 +381,34 @@ def test_usenet_is_configured_requires_both_sides() -> None: # --------------------------------------------------------------------------- +def test_usenet_reload_settings_refreshes_cached_prowlarr_config(monkeypatch) -> None: + """Settings saves must update the plugin's held ProwlarrClient. + + The active usenet adapter is rebuilt from config on each call, but + ProwlarrClient is cached inside the plugin. This is the path that + used to require a process restart after entering Prowlarr settings. + """ + settings = { + 'prowlarr.url': '', + 'prowlarr.api_key': '', + } + monkeypatch.setattr( + 'core.prowlarr_client.config_manager.get', + lambda key, default=None: settings.get(key, default), + ) + + plugin = UsenetDownloadPlugin() + assert plugin._prowlarr.is_configured() is False + + settings.update({ + 'prowlarr.url': 'http://prowlarr:9696', + 'prowlarr.api_key': 'secret', + }) + plugin.reload_settings() + + assert plugin._prowlarr.is_configured() is True + + def test_plugins_conform_to_protocol() -> None: from core.download_plugins.base import DownloadSourcePlugin assert isinstance(TorrentDownloadPlugin(), DownloadSourcePlugin) From 41799268997bc72bc17e5cd2449be69fcf610352 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 22 May 2026 08:34:42 -0700 Subject: [PATCH 02/26] Fix missing album placeholder asset path Update Import Music album and queue artwork fallbacks to use the shipped /static/placeholder-album.png asset instead of the nonexistent /static/placeholder.png path. Replace the remaining static UI fallback to the missing placeholder path and add a regression test that fails if static JS references it again. --- tests/webui/test_assets.py | 16 ++++++++++++++++ webui/static/discover.js | 2 +- webui/static/stats-automations.js | 10 +++++----- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/tests/webui/test_assets.py b/tests/webui/test_assets.py index 21660899..8c6a921d 100644 --- a/tests/webui/test_assets.py +++ b/tests/webui/test_assets.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import os +from pathlib import Path import time import pytest @@ -94,3 +95,18 @@ def test_load_webui_vite_manifest_reloads_when_file_changes(tmp_path): second = load_webui_vite_manifest(manifest_path) assert second["src/app/main.tsx"]["file"] == "assets/two.js" + + +def test_static_ui_uses_existing_album_placeholder_asset(): + repo_root = Path(__file__).resolve().parents[2] + static_dir = repo_root / "webui" / "static" + + assert (static_dir / "placeholder-album.png").exists() + assert not (static_dir / "placeholder.png").exists() + + stale_refs = [] + for path in static_dir.glob("*.js"): + if "/static/placeholder.png" in path.read_text(encoding="utf-8"): + stale_refs.append(path.name) + + assert stale_refs == [] diff --git a/webui/static/discover.js b/webui/static/discover.js index e1d05c12..b5303e5d 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -6841,7 +6841,7 @@ function _renderByltTrackCard(t) { return `
- ${t.image_url ? `` : '
🎡
'} + ${t.image_url ? `` : '
🎡
'}
${_esc(t.name)}
${_esc(t.artist)}
diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 100f4a55..6ed96413 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -1248,7 +1248,7 @@ function _renderSuggestionCard(a) { id: a.id, name: a.name || '', artist: a.artist || '', source: a.source || '', }; return `
- ${_escAttr(a.name)} + ${_escAttr(a.name)}
${_esc(a.name)}
${_esc(a.artist)}
${a.total_tracks} tracks Β· ${a.release_date ? a.release_date.substring(0, 4) : ''}
@@ -1282,7 +1282,7 @@ async function importPageSearchAlbum() { }; return `
- ${_escAttr(a.name)} + ${_escAttr(a.name)}
${_esc(a.name)}
${_esc(a.artist)}
${a.total_tracks} tracks Β· ${a.release_date ? a.release_date.substring(0, 4) : ''}
@@ -1341,7 +1341,7 @@ async function importPageSelectAlbum(albumId) { // Render hero const album = data.album; document.getElementById('import-page-album-hero').innerHTML = ` - ${_escAttr(album.name)} + ${_escAttr(album.name)}
${_esc(album.name)}
${_esc(album.artist)}
@@ -1761,7 +1761,7 @@ async function importPageSearchSingleTrack(fileIdx, query) { const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : ''; return `
- ${t.image_url ? `` : ''} + ${t.image_url ? `` : ''}
${_esc(t.name)} - ${_esc(t.artist)}
${_esc(t.album)}${dur ? ' Β· ' + dur : ''}
@@ -1923,7 +1923,7 @@ function _importQueueRender() { return `
${j.imageUrl - ? `` + ? `` : `
`}
${_esc(j.label)}
From 5d1f3c1b48d2334a945371571012a06090928801 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 22 May 2026 08:43:57 -0700 Subject: [PATCH 03/26] Fix Picard albumartist orphan false positives Teach the orphan file detector to match tracked files by both track artist and album artist. This prevents Picard-style albumartist/album (year)/track layouts from being reported as orphans when the DB track artist differs from the album artist. Also check file albumartist tags during fallback matching and add a regression test for the reported Picard folder layout. --- core/repair_jobs/orphan_file_detector.py | 47 ++++++++++---- tests/test_orphan_file_detector.py | 81 ++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 12 deletions(-) create mode 100644 tests/test_orphan_file_detector.py diff --git a/core/repair_jobs/orphan_file_detector.py b/core/repair_jobs/orphan_file_detector.py index 80c5a237..5c6e69cd 100644 --- a/core/repair_jobs/orphan_file_detector.py +++ b/core/repair_jobs/orphan_file_detector.py @@ -67,22 +67,28 @@ class OrphanFileDetectorJob(RepairJob): suffix = '/'.join(parts[-depth:]).lower() known_suffixes.add(suffix) - # Build title+artist sets for tag-based fallback matching + # Build title+artist sets for fallback matching. Include both + # track artist and album artist so Picard-style albumartist paths + # don't look orphaned when tracks have featured/guest artists. cursor.execute(""" - SELECT t.title, ar.name FROM tracks t - LEFT JOIN artists ar ON ar.id = t.artist_id + SELECT t.title, track_ar.name, album_ar.name FROM tracks t + LEFT JOIN artists track_ar ON track_ar.id = t.artist_id + LEFT JOIN albums al ON al.id = t.album_id + LEFT JOIN artists album_ar ON album_ar.id = al.artist_id WHERE t.title IS NOT NULL AND t.title != '' """) for row in cursor.fetchall(): title = (row[0] or '').lower().strip() - artist = (row[1] or '').lower().strip() if title: - known_titles.add((title, artist)) - # Also store normalized version (stripped of feat., parentheticals, etc.) - clean_t = _strip_extras(title) - clean_a = _strip_extras(artist) - if clean_t: - known_titles_clean.add((clean_t, clean_a)) + for artist_value in (row[1], row[2]): + artist = (artist_value or '').lower().strip() + known_titles.add((title, artist)) + # Also store normalized version (stripped of feat., + # parentheticals, etc.) + clean_t = _strip_extras(title) + clean_a = _strip_extras(artist) + if clean_t: + known_titles_clean.add((clean_t, clean_a)) except Exception as e: logger.error("Error reading known file paths from DB: %s", e, exc_info=True) result.errors += 1 @@ -144,14 +150,21 @@ class OrphanFileDetectorJob(RepairJob): if audio: file_title = ((audio.get('title') or [None])[0] or '').lower().strip() file_artist = ((audio.get('artist') or [None])[0] or '').lower().strip() + file_albumartist = ( + (audio.get('albumartist') or audio.get('album_artist') or [None])[0] + or '' + ).lower().strip() if file_title: + file_artists = [a for a in (file_artist, file_albumartist) if a] + if not file_artists: + file_artists = [''] # Exact match first (fast path) - if (file_title, file_artist) in known_titles: + if any((file_title, artist) in known_titles for artist in file_artists): is_known = True else: # Normalized match: strip (feat. X), [FLAC 16bit], etc. clean_title = _strip_extras(file_title) - clean_artist = _strip_extras(file_artist) + clean_artist = _strip_extras(file_artists[0]) # Also try first artist only (handles "Gorillaz, Dennis Hopper" β†’ "Gorillaz") first_artist = clean_artist.split(',')[0].strip() if clean_artist else '' if clean_title and ( @@ -159,6 +172,16 @@ class OrphanFileDetectorJob(RepairJob): (first_artist and (clean_title, first_artist) in known_titles_clean) ): is_known = True + if clean_title and not is_known: + for artist in file_artists[1:]: + clean_artist = _strip_extras(artist) + first_artist = clean_artist.split(',')[0].strip() if clean_artist else '' + if ( + (clean_title, clean_artist) in known_titles_clean or + (first_artist and (clean_title, first_artist) in known_titles_clean) + ): + is_known = True + break except Exception as e: logger.debug("tag-based orphan check: %s", e) diff --git a/tests/test_orphan_file_detector.py b/tests/test_orphan_file_detector.py new file mode 100644 index 00000000..02ba7150 --- /dev/null +++ b/tests/test_orphan_file_detector.py @@ -0,0 +1,81 @@ +"""Regression tests for the orphan file detector.""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +from core.repair_jobs.base import JobContext +from core.repair_jobs.orphan_file_detector import OrphanFileDetectorJob + + +class _DB: + def __init__(self, path: Path) -> None: + self.path = path + + def _get_connection(self): + return sqlite3.connect(self.path) + + +def _seed_library(db_path: Path) -> None: + conn = sqlite3.connect(db_path) + try: + conn.executescript( + """ + CREATE TABLE artists ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + ); + CREATE TABLE albums ( + id INTEGER PRIMARY KEY, + artist_id INTEGER NOT NULL, + title TEXT NOT NULL + ); + CREATE TABLE tracks ( + id INTEGER PRIMARY KEY, + album_id INTEGER NOT NULL, + artist_id INTEGER NOT NULL, + title TEXT NOT NULL, + file_path TEXT + ); + INSERT INTO artists (id, name) VALUES + (1, 'Clouddead89'), + (2, 'Featured Artist'); + INSERT INTO albums (id, artist_id, title) VALUES + (10, 1, 'Perfect Match Error'); + INSERT INTO tracks (id, album_id, artist_id, title, file_path) VALUES + (100, 10, 2, 'Perfect Match', '/old/prefix/elsewhere.mp3'); + """ + ) + conn.commit() + finally: + conn.close() + + +def test_orphan_detector_accepts_picard_albumartist_folder_match(tmp_path: Path) -> None: + """Picard paths use albumartist/album (year)/track - title. + + Even when the DB track artist is a featured artist, the album artist + folder should be enough to recognize the file as tracked. + """ + db_path = tmp_path / "library.sqlite" + _seed_library(db_path) + + transfer = tmp_path / "Clouddead89" / "Perfect Match Error (2026)" + transfer.mkdir(parents=True) + audio_path = transfer / "01 - Perfect Match.mp3" + audio_path.write_bytes(b"not a real mp3; filename fallback handles this") + + findings = [] + context = JobContext( + db=_DB(db_path), + transfer_folder=str(tmp_path), + config_manager=None, + create_finding=lambda **kwargs: findings.append(kwargs) or True, + ) + + result = OrphanFileDetectorJob().scan(context) + + assert result.scanned == 1 + assert result.findings_created == 0 + assert findings == [] From 4ebbffb8989f990ff7c3dd0d737140d7c3a031d6 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 22 May 2026 17:18:06 -0700 Subject: [PATCH 04/26] Fix admin PIN after profile switches Reset profile PIN dialog controls each time it opens so stale profile-specific event listeners cannot submit an admin PIN against a previously selected profile. Keep failed PIN attempts retryable and restrict launch-lock verification to the admin profile PIN only, so non-admin profile PINs cannot mark the admin lock as verified. --- web_server.py | 15 ++++++++++----- webui/static/init.js | 21 ++++++++++++++++----- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/web_server.py b/web_server.py index 3966f93c..deea944a 100644 --- a/web_server.py +++ b/web_server.py @@ -24315,9 +24315,10 @@ def select_profile(): return jsonify({'success': False, 'error': 'Invalid PIN'}), 401 session['profile_id'] = profile_id - # If PIN was just validated, also mark launch PIN as verified - # so the subsequent page reload doesn't ask again - if pin: + # If the admin PIN was just validated, also mark launch PIN as + # verified so the subsequent page reload doesn't ask again. A + # non-admin profile PIN must not unlock the admin launch lock. + if pin and profile_id == 1: session['launch_pin_verified'] = True return jsonify({'success': True, 'profile': profile}) except Exception as e: @@ -24402,12 +24403,16 @@ def reset_pin_via_credential(): # Credential verified β€” clear PIN for the requested profile (default: admin) database = get_database() - target_profile = data.get('profile_id', 1) + try: + target_profile = int(data.get('profile_id', 1)) + except (TypeError, ValueError): + target_profile = 1 database.update_profile(target_profile, pin_hash=None) # If clearing admin PIN, also disable launch lock if target_profile == 1: config_manager.set('security.require_pin_on_launch', False) - session['launch_pin_verified'] = True + if target_profile == 1: + session['launch_pin_verified'] = True return jsonify({'success': True, 'message': 'PIN cleared. You can set a new PIN in Settings.'}) except Exception as e: diff --git a/webui/static/init.js b/webui/static/init.js index 3a0cf496..0465f70d 100644 --- a/webui/static/init.js +++ b/webui/static/init.js @@ -707,8 +707,19 @@ function showPinDialog(profile) { const dialog = document.getElementById('profile-pin-dialog'); const avatar = document.getElementById('profile-pin-avatar'); const nameEl = document.getElementById('profile-pin-name'); - const input = document.getElementById('profile-pin-input'); const errorEl = document.getElementById('profile-pin-error'); + const oldInput = document.getElementById('profile-pin-input'); + const oldSubmit = document.getElementById('profile-pin-submit'); + const oldCancel = document.getElementById('profile-pin-cancel'); + + // Replace controls on every open so stale listeners from a previous + // profile cannot submit the new PIN against the old profile id. + const input = oldInput.cloneNode(true); + const submit = oldSubmit.cloneNode(true); + const cancel = oldCancel.cloneNode(true); + oldInput.parentNode.replaceChild(input, oldInput); + oldSubmit.parentNode.replaceChild(submit, oldSubmit); + oldCancel.parentNode.replaceChild(cancel, oldCancel); renderProfileAvatar(avatar, profile); nameEl.textContent = profile.name; @@ -718,13 +729,12 @@ function showPinDialog(profile) { dialog.style.display = 'flex'; setTimeout(() => input.focus(), 100); - const submit = document.getElementById('profile-pin-submit'); - const cancel = document.getElementById('profile-pin-cancel'); - const wasSwitching = !!currentProfile; const handleSubmit = async () => { const pin = input.value; if (!pin) return; + submit.disabled = true; + submit.textContent = 'Verifying...'; try { const res = await fetch('/api/profiles/select', { method: 'POST', @@ -753,7 +763,8 @@ function showPinDialog(profile) { errorEl.textContent = 'Connection error'; errorEl.style.display = ''; } - cleanup(); + submit.disabled = false; + submit.textContent = 'Submit'; }; const handleCancel = () => { From de8e079a6d43585be066e901497c4821be28a762 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 22 May 2026 21:19:50 -0700 Subject: [PATCH 05/26] feat(media-player): playable tracks across modals + lyrics + cleanups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related improvements to the now-playing media player and the "add to wishlist" / "download missing" modals. 1. Play buttons across track-list modals Every track row in the download-missing modals (Spotify, Tidal, YouTube, services, artist album, wishlist download-missing) and the add-to-wishlist modal now carries a play button. Click runs playTrackFromLibraryOrStream: - If the track has a local file_path β†’ playLibraryTrack - Else POST /api/stats/resolve-track to find it in the library by title + artist β†’ playLibraryTrack - Else fall back to _gsPlayTrack streaming Backend ownership response gains track_id / title / file_path so the wishlist modal's owned tracks can hand the right metadata to the player without an extra round trip. The add-to-wishlist modal previously showed the play button only on owned tracks; now the button is unconditional so the streaming fallback can take over for unowned ones (matches the standard pattern from the rest of the app). 2. Clean media-player display titles YouTube / Tidal / Qobuz / torrent / usenet plugins encode their source-side identifier into the filename field as || so download() can recover it later. The media player's track-title renderer never knew about this convention and showed strings like "wvgFsXoGFnQ||Sometimes I Cry When I'm Alone" verbatim in the now-playing UI. extractTrackTitle and setTrackInfo now strip the || prefix defensively so any path into the player gets a clean display. Local library playback also fetches canonical metadata from /api/stats/resolve-track when track.id is present so title / artist / album / album art come straight from the SoulSync DB instead of whatever the caller passed in. Falls back silently to caller values on any error so playback never blocks on the metadata fetch. 3. Lyrics panel + View Artist close New collapsed lyrics panel between the playback controls and queue panel. POST /api/lyrics/fetch (new backend endpoint) prefers the local .lrc / .txt sidecar files SoulSync writes during post-processing so downloaded tracks resolve lyrics with zero network hits; falls back to LRClib exact-match (when album + duration are available) then to LRClib search. Synced LRC results are parsed (handles multi-stamp lines for repeated choruses), and the active line highlights + smooth- scrolls into the middle of the viewport on every audio timeupdate. Plain-text results render without highlighting. Per-track cache prevents re-fetching when the user revisits the same track. Lyrics fetch is fire-and-forget β€” failure shows "No lyrics found" without ever blocking playback. View Artist on the expanded player now calls closeNowPlayingModal before navigating; the modal was previously sitting open over the artist page, hiding it. Handler is bound once and is a no-op when no artist_id is attached. CSS additions are additive (new .modal-track-play-btn and .np-lyrics-* rules); no existing styles touched. Backend endpoint returns 200-with-success-false on any miss so callers can render "no lyrics" without treating it as an error. WHATS_NEW updated under 2.5.9 with two entries (lyrics + View Artist close). --- web_server.py | 98 +++++++++++++ webui/index.html | 16 +++ webui/static/downloads.js | 87 ++++++++++- webui/static/helper.js | 2 + webui/static/library.js | 39 +++++ webui/static/media-player.js | 254 ++++++++++++++++++++++++++++++++- webui/static/shared-helpers.js | 2 +- webui/static/style.css | 114 +++++++++++++++ webui/static/sync-services.js | 2 +- webui/static/sync-spotify.js | 2 +- webui/static/wishlist-tools.js | 54 +++++++ 11 files changed, 661 insertions(+), 9 deletions(-) diff --git a/web_server.py b/web_server.py index deea944a..f5b817fb 100644 --- a/web_server.py +++ b/web_server.py @@ -8738,6 +8738,9 @@ def library_check_tracks(): file_ext = os.path.splitext(matched_db_track.file_path or '')[1].lstrip('.').upper() or None owned_map[track_name] = { "owned": True, + "track_id": getattr(matched_db_track, 'id', None), + "title": getattr(matched_db_track, 'title', track_name), + "file_path": getattr(matched_db_track, 'file_path', None), "format": file_ext, "bitrate": matched_db_track.bitrate, "album": getattr(matched_db_track, 'album_title', None) @@ -33164,6 +33167,101 @@ def stats_recent(): except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 +@app.route('/api/lyrics/fetch', methods=['POST']) +def fetch_lyrics_endpoint(): + """Fetch lyrics for the now-playing media player. + + Body: ``{title, artist, album?, duration?}``. Returns + ``{success, synced, plain, source}`` where ``synced`` is an LRC + string with ``[mm:ss.xx] line`` timestamps (or None) and ``plain`` + is the untimestamped text (or None). ``source`` is the lookup + strategy that hit (``exact`` / ``search`` / ``sidecar``). + + Tries the local ``.lrc`` / ``.txt`` sidecar first when a + ``file_path`` is supplied β€” already-downloaded tracks should not + bounce LRClib on every play. Falls through to LRClib's exact- + match endpoint when title+artist+album+duration are all available, + then to its generic search endpoint. + """ + try: + data = request.get_json() or {} + title = (data.get('title') or '').strip() + artist = (data.get('artist') or '').strip() + album = (data.get('album') or '').strip() or None + try: + duration = int(data.get('duration') or 0) or None + except (TypeError, ValueError): + duration = None + file_path = data.get('file_path') or None + + if not title or not artist: + return jsonify({'success': False, 'error': 'title and artist required', + 'synced': None, 'plain': None, 'source': None}), 400 + + # 1. Sidecar β€” fastest, no network. The post-processing flow + # drops .lrc / .txt next to the audio for every successful + # enrichment, so existing downloads almost always have one. + if file_path: + try: + import os as _os + stem, _ = _os.path.splitext(file_path) + lrc_path = stem + '.lrc' + txt_path = stem + '.txt' + if _os.path.exists(lrc_path): + with open(lrc_path, 'r', encoding='utf-8') as fh: + body = fh.read().strip() + if body: + return jsonify({'success': True, 'synced': body, + 'plain': None, 'source': 'sidecar'}) + if _os.path.exists(txt_path): + with open(txt_path, 'r', encoding='utf-8') as fh: + body = fh.read().strip() + if body: + return jsonify({'success': True, 'synced': None, + 'plain': body, 'source': 'sidecar'}) + except Exception as sidecar_err: + logger.debug("lyrics sidecar read skipped: %s", sidecar_err) + + # 2. LRClib network lookup via the shared client instance. + from core.lyrics_client import lyrics_client as _lyrics_client + api = getattr(_lyrics_client, 'api', None) + if api is None: + return jsonify({'success': False, 'error': 'lrclib unavailable', + 'synced': None, 'plain': None, 'source': None}), 200 + + result = None + # Exact-match endpoint requires all four fields. LRClib's API + # will 404 on any miss; treat as soft failure and fall through + # to the search endpoint. + if album and duration: + try: + result = api.get_lyrics(track_name=title, artist_name=artist, + album_name=album, duration=duration) + except Exception as exact_err: + logger.debug("lrclib exact lookup failed: %s", exact_err) + + if result is None: + try: + hits = api.search_lyrics(track_name=title, artist_name=artist) + if hits: + result = hits[0] + except Exception as search_err: + logger.debug("lrclib search lookup failed: %s", search_err) + + if result is None: + return jsonify({'success': False, 'error': 'no lyrics found', + 'synced': None, 'plain': None, 'source': None}) + + synced = getattr(result, 'synced_lyrics', None) or None + plain = getattr(result, 'plain_lyrics', None) or None + return jsonify({'success': bool(synced or plain), 'synced': synced, + 'plain': plain, 'source': 'lrclib'}) + except Exception as e: + logger.error("lyrics fetch failed: %s", e) + return jsonify({'success': False, 'error': str(e), + 'synced': None, 'plain': None, 'source': None}), 500 + + @app.route('/api/stats/resolve-track', methods=['POST']) def stats_resolve_track(): """Resolve a track by title+artist to get its file_path for playback.""" diff --git a/webui/index.html b/webui/index.html index 41393284..fac604ed 100644 --- a/webui/index.html +++ b/webui/index.html @@ -7275,6 +7275,22 @@
+ + +
diff --git a/webui/static/downloads.js b/webui/static/downloads.js index 36142faf..a8c590cf 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -582,7 +582,7 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam onchange="updateTrackSelectionCount('${virtualPlaylistId}')"> ${index + 1} - ${escapeHtml(track.name)} + ${renderModalTrackPlayButton(virtualPlaylistId, index)}${escapeHtml(track.name)} ${escapeHtml(formatArtists(track.artists))} ${formatDuration(track.duration_ms)} πŸ” Pending @@ -2143,7 +2143,7 @@ async function openDownloadMissingWishlistModal(category = null, selectedTrackId ${tracks.map((track, index) => ` ${index + 1} - ${escapeHtml(track.name)} + ${renderModalTrackPlayButton(playlistId, index)}${escapeHtml(track.name)} ${escapeHtml(formatArtists(track.artists))} πŸ” Pending - @@ -2613,6 +2613,89 @@ function updateTrackAnalysisResults(playlistId, results) { } } +function getModalTrackArtistName(track, fallbackArtist = '') { + const formatted = formatArtists(track?.artists); + if (formatted && formatted !== 'Unknown Artist') return formatted; + return track?.artist_name || track?.artist || fallbackArtist || formatted || ''; +} + +function getModalTrackAlbumTitle(track, process = null) { + if (track?.album) { + if (typeof track.album === 'string') return track.album; + if (track.album.name) return track.album.name; + if (track.album.title) return track.album.title; + } + if (process?.album) { + return process.album.name || process.album.title || ''; + } + return ''; +} + +function renderModalTrackPlayButton(playlistId, trackIndex) { + return ``; +} + +async function playTrackFromLibraryOrStream(track, albumTitle = '', artistName = '') { + const title = track?.title || track?.name || ''; + if (!title) { + showToast('No track title available to play', 'error'); + return; + } + + if (track?.file_path && typeof playLibraryTrack === 'function') { + await playLibraryTrack({ + id: track.id || track.track_id || null, + title, + file_path: track.file_path, + _stats_image: track._stats_image || track.album_thumb_url || null, + bitrate: track.bitrate, + artist_id: track.artist_id, + album_id: track.album_id + }, albumTitle, artistName); + return; + } + + try { + const res = await fetch('/api/stats/resolve-track', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, artist: artistName }) + }); + const data = await res.json(); + if (data.success && data.track && data.track.file_path && typeof playLibraryTrack === 'function') { + await playLibraryTrack({ + ...data.track, + title: data.track.title || title, + _stats_image: data.track.album_thumb_url || data.track.artist_thumb_url || null + }, data.track.album_title || albumTitle, data.track.artist_name || artistName); + return; + } + } catch (e) { + console.debug('Library resolve failed before stream fallback:', e); + } + + if (typeof _gsPlayTrack === 'function') { + await _gsPlayTrack(title, artistName, albumTitle); + } else { + showToast('Playback is not available here', 'error'); + } +} + +async function playDownloadModalTrack(playlistId, trackIndex) { + const process = activeDownloadProcesses[playlistId]; + const track = process?.tracks?.[trackIndex] || playlistTrackCache[playlistId]?.[trackIndex]; + if (!track) { + showToast('Track is no longer available in this modal', 'error'); + return; + } + + await playTrackFromLibraryOrStream( + track, + getModalTrackAlbumTitle(track, process), + getModalTrackArtistName(track, process?.artist?.name || '') + ); +} + // ============================================================================ diff --git a/webui/static/helper.js b/webui/static/helper.js index 2935a313..14aaa032 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3415,6 +3415,8 @@ function closeHelperSearch() { const WHATS_NEW = { '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.' }, diff --git a/webui/static/library.js b/webui/static/library.js index cbbcb74e..e9d4186e 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -7974,6 +7974,45 @@ async function playLibraryTrack(track, albumTitle, artistName) { return; } + // Library tracks have authoritative metadata in the SoulSync DB β€” + // any title / artist / album the caller passes in is downstream of + // whatever modal triggered playback and may carry noise like the + // ``||`` filename prefix from a Prowlarr result. + // When the caller has a track.id, fetch the canonical row from + // resolve-track and overwrite the caller-supplied fields with the + // DB values. Falls back silently to the caller-supplied values on + // any error so we never lose the play action over a metadata fetch. + if (track.id && (track.title || track.name) && (artistName || track.artist_name)) { + try { + const _dbResp = await fetch('/api/stats/resolve-track', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: track.title || track.name, + artist: artistName || track.artist_name || '', + }), + }); + const _dbData = await _dbResp.json(); + if (_dbData && _dbData.success && _dbData.track) { + const _row = _dbData.track; + track = { + ...track, + id: _row.id ?? track.id, + title: _row.title || track.title, + file_path: _row.file_path || track.file_path, + bitrate: _row.bitrate ?? track.bitrate, + artist_id: _row.artist_id ?? track.artist_id, + album_id: _row.album_id ?? track.album_id, + _stats_image: _row.image_url || _row.album_thumb_url || track._stats_image || null, + }; + if (_row.album_title) albumTitle = _row.album_title; + if (_row.artist_name) artistName = _row.artist_name; + } + } catch (_dbErr) { + console.debug('library track DB refresh skipped:', _dbErr); + } + } + try { // Stop any current playback first if (audioPlayer && !audioPlayer.paused) { diff --git a/webui/static/media-player.js b/webui/static/media-player.js index b300de5a..fd7eb526 100644 --- a/webui/static/media-player.js +++ b/webui/static/media-player.js @@ -64,8 +64,20 @@ function toggleMediaPlayerExpansion() { function extractTrackTitle(filename) { if (!filename) return null; + // Strip the ``||`` prefix used by YouTube / + // Tidal / Qobuz / torrent / usenet plugins to thread the source- + // side identifier through ``filename`` without polluting the + // display string. The id always comes first, the human title + // after. If no separator is present, fall through with the raw + // value so existing slskd / streaming-source paths are untouched. + let title = filename; + const sepIdx = title.indexOf('||'); + if (sepIdx >= 0) { + title = title.slice(sepIdx + 2); + } + // Remove file extension - let title = filename.replace(/\.[^/.]+$/, ''); + title = title.replace(/\.[^/.]+$/, ''); // Remove path components, keep only the filename title = title.split('/').pop().split('\\').pop(); @@ -82,17 +94,28 @@ function extractTrackTitle(filename) { return title || null; } +function _stripSourceIdPrefix(value) { + // Defensive cleanup for callers that pass a raw ``||`` + // string straight into setTrackInfo without first running + // extractTrackTitle. The id always precedes the separator; the display + // string follows. Strings with no separator pass through unchanged. + if (!value || typeof value !== 'string') return value; + const idx = value.indexOf('||'); + if (idx < 0) return value; + return value.slice(idx + 2); +} + function setTrackInfo(track) { currentTrack = track; const trackTitleElement = document.getElementById('track-title'); - const trackTitle = track.title || 'Unknown Track'; + const trackTitle = _stripSourceIdPrefix(track.title) || 'Unknown Track'; // Set up the HTML structure for scrolling trackTitleElement.innerHTML = `${escapeHtml(trackTitle)}`; - document.getElementById('artist-name').textContent = track.artist || 'Unknown Artist'; - document.getElementById('album-name').textContent = track.album || 'Unknown Album'; + document.getElementById('artist-name').textContent = _stripSourceIdPrefix(track.artist) || 'Unknown Artist'; + document.getElementById('album-name').textContent = _stripSourceIdPrefix(track.album) || 'Unknown Album'; // Check if title needs scrolling (similar to GUI app) setTimeout(() => { @@ -120,12 +143,35 @@ function setTrackInfo(track) { gotoArtistBtn.setAttribute('aria-disabled', 'true'); gotoArtistBtn.tabIndex = -1; } + // Close the expanded now-playing modal when the user navigates + // to the artist page β€” otherwise the modal sits open over the + // page they just opened. ``_npGotoArtistHandlerAttached`` flag + // keeps us from binding multiple listeners across setTrackInfo + // calls (fires on every track change). + if (!gotoArtistBtn._npGotoArtistHandlerAttached) { + gotoArtistBtn.addEventListener('click', () => { + if (gotoArtistBtn.getAttribute('aria-disabled') === 'true') return; + try { closeNowPlayingModal(); } catch (e) { console.debug('closeNowPlayingModal failed:', e); } + }); + gotoArtistBtn._npGotoArtistHandlerAttached = true; + } } // Sync expanded player and media session updateNpTrackInfo(); updateMediaSessionMetadata(); updateMediaSessionPlaybackState(); + + // Kick off lyrics fetch for the new track. The panel stays + // collapsed by default β€” fetching in the background means the + // user gets instant lyrics the first time they expand it. + _npLyricsLoadForTrack({ + title: track.title, + artist: track.artist, + album: track.album, + is_library: track.is_library, + filename: track.filename, + }); } function checkAndEnableScrolling(element, text) { @@ -922,6 +968,202 @@ function updateAudioProgress() { // Sync expanded player modal if (npModalOpen) updateNpProgress(); + + // Sync lyrics highlight when synced LRC is loaded. + if (_npLyricsState.synced && _npLyricsState.lines.length) { + _npLyricsHighlight(audioPlayer.currentTime); + } +} + +// ───────────────────────────────────────────────────────────────── +// Lyrics panel (now-playing modal) +// ───────────────────────────────────────────────────────────────── + +// Module-level state for the currently-loaded lyrics. Reset on each +// track change. ``lines`` is an array of {time, text} for synced +// lyrics or null for plain text. ``activeIndex`` tracks the last +// highlighted line to avoid re-rendering on every timeupdate tick. +const _npLyricsState = { + trackKey: null, + lines: [], + synced: false, + activeIndex: -1, + fetchInFlight: false, + autoOpen: false, +}; + +function _npLyricsResetUI() { + const content = document.getElementById('np-lyrics-content'); + const status = document.getElementById('np-lyrics-status'); + if (content) content.innerHTML = '
No lyrics loaded
'; + if (status) status.textContent = ''; +} + +function _npLyricsParseLrc(synced) { + // Parse a standard LRC string. Lines without a timestamp are + // dropped (metadata tags like ``[ti:Title]`` aren't lyrics). The + // same line can carry multiple timestamps β€” emit one entry per + // timestamp so seeks land correctly when a chorus repeats. + const out = []; + if (!synced) return out; + const re = /\[(\d+):(\d+(?:\.\d+)?)\]/g; + synced.split(/\r?\n/).forEach(raw => { + const stamps = []; + let m; + re.lastIndex = 0; + while ((m = re.exec(raw)) !== null) { + const minutes = parseInt(m[1], 10); + const seconds = parseFloat(m[2]); + if (!Number.isFinite(minutes) || !Number.isFinite(seconds)) continue; + stamps.push(minutes * 60 + seconds); + } + if (!stamps.length) return; + const text = raw.replace(re, '').trim(); + stamps.forEach(t => out.push({ time: t, text })); + }); + out.sort((a, b) => a.time - b.time); + return out; +} + +function _npLyricsRenderSynced(lines) { + const content = document.getElementById('np-lyrics-content'); + if (!content) return; + if (!lines.length) { + content.innerHTML = '
No timestamped lyrics for this track
'; + return; + } + content.innerHTML = lines.map((line, idx) => { + const safe = (line.text || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])) || ' '; + return `
${safe}
`; + }).join(''); +} + +function _npLyricsRenderPlain(text) { + const content = document.getElementById('np-lyrics-content'); + if (!content) return; + const safe = (text || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + content.innerHTML = `
${safe.replace(/\n/g, '
')}
`; +} + +function _npLyricsHighlight(currentTime) { + const { lines } = _npLyricsState; + if (!lines.length) return; + let idx = -1; + // Binary-search style linear scan β€” N is small (200 lines max). + for (let i = 0; i < lines.length; i++) { + if (lines[i].time <= currentTime) idx = i; + else break; + } + if (idx === _npLyricsState.activeIndex) return; + _npLyricsState.activeIndex = idx; + const content = document.getElementById('np-lyrics-content'); + if (!content) return; + content.querySelectorAll('.np-lyrics-line').forEach((el, i) => { + el.classList.remove('active', 'passed', 'upcoming'); + if (i === idx) el.classList.add('active'); + else if (i < idx) el.classList.add('passed'); + else el.classList.add('upcoming'); + }); + const activeEl = content.querySelector('.np-lyrics-line.active'); + if (activeEl) { + // Smooth-scroll the active line into the middle of the lyrics body. + const body = document.getElementById('np-lyrics-body'); + if (body) { + const bodyRect = body.getBoundingClientRect(); + const lineRect = activeEl.getBoundingClientRect(); + const targetTop = (lineRect.top - bodyRect.top) - (bodyRect.height / 2) + (lineRect.height / 2); + body.scrollTo({ top: body.scrollTop + targetTop, behavior: 'smooth' }); + } + } +} + +function _npLyricsTrackKey(track) { + if (!track) return null; + return `${track.title || ''}|${track.artist || ''}|${track.album || ''}`; +} + +async function _npLyricsLoadForTrack(track) { + const key = _npLyricsTrackKey(track); + if (!key) return; + if (_npLyricsState.trackKey === key) return; // already loaded + if (_npLyricsState.fetchInFlight) return; + _npLyricsState.trackKey = key; + _npLyricsState.lines = []; + _npLyricsState.synced = false; + _npLyricsState.activeIndex = -1; + _npLyricsResetUI(); + const status = document.getElementById('np-lyrics-status'); + if (status) status.textContent = 'Fetching…'; + _npLyricsState.fetchInFlight = true; + try { + const resp = await fetch('/api/lyrics/fetch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: _stripSourceIdPrefix(track.title) || '', + artist: _stripSourceIdPrefix(track.artist) || '', + album: _stripSourceIdPrefix(track.album) || '', + duration: Math.round(audioPlayer?.duration || 0), + file_path: track.is_library ? track.filename : null, + }), + }); + const data = await resp.json(); + if (_npLyricsState.trackKey !== key) return; // track changed mid-fetch + if (data && data.success) { + if (data.synced) { + const parsed = _npLyricsParseLrc(data.synced); + if (parsed.length) { + _npLyricsState.synced = true; + _npLyricsState.lines = parsed; + _npLyricsRenderSynced(parsed); + if (status) status.textContent = 'Synced'; + return; + } + } + if (data.plain) { + _npLyricsState.synced = false; + _npLyricsState.lines = []; + _npLyricsRenderPlain(data.plain); + if (status) status.textContent = 'Plain'; + return; + } + } + const content = document.getElementById('np-lyrics-content'); + if (content) content.innerHTML = '
No lyrics found
'; + if (status) status.textContent = ''; + } catch (e) { + console.debug('lyrics fetch failed:', e); + const content = document.getElementById('np-lyrics-content'); + if (content) content.innerHTML = '
Lyrics unavailable
'; + if (status) status.textContent = ''; + } finally { + _npLyricsState.fetchInFlight = false; + } +} + +function _npLyricsTogglePanel(forceOpen = null) { + const panel = document.getElementById('np-lyrics-panel'); + const body = document.getElementById('np-lyrics-body'); + const toggle = document.getElementById('np-lyrics-toggle'); + if (!panel || !body || !toggle) return; + const willOpen = forceOpen === null ? body.classList.contains('hidden') : forceOpen; + if (willOpen) { + body.classList.remove('hidden'); + panel.classList.remove('collapsed'); + toggle.setAttribute('aria-expanded', 'true'); + } else { + body.classList.add('hidden'); + panel.classList.add('collapsed'); + toggle.setAttribute('aria-expanded', 'false'); + } +} + +function _npLyricsInit() { + const toggle = document.getElementById('np-lyrics-toggle'); + if (toggle && !toggle._lyricsBound) { + toggle.addEventListener('click', () => _npLyricsTogglePanel()); + toggle._lyricsBound = true; + } } function onAudioEnded() { @@ -1287,6 +1529,10 @@ function openNowPlayingModal() { overlay.classList.remove('hidden'); document.body.style.overflow = 'hidden'; syncExpandedPlayerUI(); + // Bind lyrics toggle (idempotent β€” only attaches once). Lyrics + // fetch fires from setTrackInfo so by the time the modal opens + // the panel is usually already populated. + _npLyricsInit(); // Start visualizer if already playing if (isPlaying) { npInitVisualizer(); npStartVisualizerLoop(); } } diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index 05ab4fa2..3fc8be27 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -1092,7 +1092,7 @@ async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlis onchange="updateTrackSelectionCount('${virtualPlaylistId}')"> ${index + 1} - ${escapeHtml(track.name)} + ${renderModalTrackPlayButton(virtualPlaylistId, index)}${escapeHtml(track.name)} ${escapeHtml(formatArtists(track.artists))} ${formatDuration(track.duration_ms)} πŸ” Pending diff --git a/webui/static/style.css b/webui/static/style.css index d6c531ed..24bf7425 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -7,6 +7,35 @@ --accent-neon-rgb: 34, 255, 107; } +.modal-track-play-btn { + width: 26px; + height: 26px; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 50%; + background: rgba(76, 175, 80, 0.14); + color: #65d67a; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 11px; + line-height: 1; + margin-right: 8px; + vertical-align: middle; + transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease; +} + +.modal-track-play-btn:hover { + background: rgba(76, 175, 80, 0.24); + border-color: rgba(101, 214, 122, 0.45); + transform: scale(1.05); +} + +.wishlist-track-play-btn { + flex: 0 0 auto; + margin-right: 4px; +} + /* Reset and Base Styles */ * { margin: 0; @@ -48029,6 +48058,91 @@ textarea.enhanced-meta-field-input { background: rgba(255, 255, 255, 0.18); } +/* Lyrics Panel (now-playing modal) */ +.np-lyrics-panel { + border-top: 1px solid rgba(255, 255, 255, 0.06); + padding: 0 40px; +} + +.np-lyrics-header { + display: flex; + align-items: center; + padding: 12px 0 8px; +} + +.np-lyrics-toggle { + display: flex; + align-items: center; + gap: 8px; + background: none; + border: none; + color: rgba(255, 255, 255, 0.85); + font-size: 14px; + font-weight: 600; + letter-spacing: 0.2px; + cursor: pointer; + padding: 4px 6px; + border-radius: 6px; + transition: background 0.15s ease; +} + +.np-lyrics-toggle:hover { + background: rgba(255, 255, 255, 0.05); +} + +.np-lyrics-status { + font-size: 11px; + font-weight: 500; + color: rgba(255, 255, 255, 0.4); + letter-spacing: 0.5px; + text-transform: uppercase; + margin-left: 4px; +} + +.np-lyrics-body { + max-height: 260px; + overflow-y: auto; + padding-bottom: 14px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.04); + margin-bottom: 10px; +} + +.np-lyrics-content { + padding: 14px 16px; + font-size: 14px; + line-height: 1.7; + color: rgba(255, 255, 255, 0.6); + white-space: pre-wrap; +} + +.np-lyrics-empty { + color: rgba(255, 255, 255, 0.35); + font-style: italic; + text-align: center; + padding: 12px 0; +} + +.np-lyrics-line { + padding: 2px 0; + transition: color 0.2s ease, transform 0.2s ease; +} + +.np-lyrics-line.active { + color: rgb(var(--accent-rgb)); + font-weight: 600; + transform: translateX(4px); +} + +.np-lyrics-line.passed { + color: rgba(255, 255, 255, 0.45); +} + +.np-lyrics-line.upcoming { + color: rgba(255, 255, 255, 0.55); +} + /* Queue Panel */ .np-queue-panel { border-top: 1px solid rgba(255, 255, 255, 0.06); diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js index 11b50933..c7fd1863 100644 --- a/webui/static/sync-services.js +++ b/webui/static/sync-services.js @@ -1441,7 +1441,7 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, onchange="updateTrackSelectionCount('${virtualPlaylistId}')"> ${index + 1} - ${escapeHtml(track.name)} + ${renderModalTrackPlayButton(virtualPlaylistId, index)}${escapeHtml(track.name)} ${escapeHtml(formatArtists(track.artists))} ${formatDuration(track.duration_ms)} πŸ” Pending diff --git a/webui/static/sync-spotify.js b/webui/static/sync-spotify.js index ad8ba041..e0dbceb2 100644 --- a/webui/static/sync-spotify.js +++ b/webui/static/sync-spotify.js @@ -2314,7 +2314,7 @@ async function openDownloadMissingModal(playlistId) { onchange="updateTrackSelectionCount('${playlistId}')"> ${index + 1} - ${escapeHtml(track.name)} + ${renderModalTrackPlayButton(playlistId, index)}${escapeHtml(track.name)} ${escapeHtml(formatArtists(track.artists))} ${formatDuration(track.duration_ms)} πŸ” Pending diff --git a/webui/static/wishlist-tools.js b/webui/static/wishlist-tools.js index fd8eee6b..a1fbc4b3 100644 --- a/webui/static/wishlist-tools.js +++ b/webui/static/wishlist-tools.js @@ -883,10 +883,16 @@ function generateWishlistTrackList(tracks, trackOwnership) { const badge = isOwned ? '
' : ''; + // Play button shows for every track. ``playWishlistModalTrack`` -> + // ``playTrackFromLibraryOrStream`` resolves a local file when one + // exists and falls back to the streaming source otherwise, matching + // the same pattern used by the download-missing modals elsewhere. + const playButton = ``; return `
${trackNumber}
+ ${playButton}
${trackName}
${artistsString}
@@ -1073,6 +1079,25 @@ async function lazyLoadTrackOwnership(artistName, tracks, sourceCard, albumName if (isOwned) { ownedCount++; item.classList.add('owned'); + // Play button is rendered up front for every track now. Upgrade + // the tooltip + click handler once ownership confirms so the + // local-file path is used directly without the resolve-track + // round trip. Falls back to creating a fresh button if the + // initial render somehow skipped it (defensive β€” should not + // happen post-refactor). + let playBtn = item.querySelector('.wishlist-track-play-btn'); + if (!playBtn) { + playBtn = document.createElement('button'); + playBtn.className = 'modal-track-play-btn wishlist-track-play-btn'; + playBtn.innerHTML = '▶'; + item.querySelector('.wishlist-track-number')?.after(playBtn); + } + playBtn.title = 'Play from library'; + playBtn.onclick = null; + playBtn.addEventListener('click', event => { + event.stopPropagation(); + playWishlistModalTrack(index, trackData); + }); // Add metadata line below track name const trackInfo = item.querySelector('.wishlist-track-info'); if (trackInfo && (trackData.format || trackData.bitrate)) { @@ -1162,6 +1187,35 @@ async function lazyLoadTrackOwnership(artistName, tracks, sourceCard, albumName } } +async function playWishlistModalTrack(index, ownershipData = null) { + if (!currentWishlistModalData || !currentWishlistModalData.tracks) { + showToast('Track is no longer available in this modal', 'error'); + return; + } + + const track = currentWishlistModalData.tracks[index]; + if (!track) { + showToast('Track is no longer available in this modal', 'error'); + return; + } + + const trackData = ownershipData || {}; + const playbackTrack = { + ...track, + id: trackData.track_id || track.id || null, + title: trackData.title || track.title || track.name, + file_path: trackData.file_path || track.file_path || null, + bitrate: trackData.bitrate || track.bitrate, + _stats_image: currentWishlistModalData.album?.image_url || currentWishlistModalData.album?.images?.[0]?.url || null + }; + + await playTrackFromLibraryOrStream( + playbackTrack, + trackData.album || currentWishlistModalData.album?.name || currentWishlistModalData.album?.title || '', + getModalTrackArtistName(track, currentWishlistModalData.artist?.name || '') + ); +} + /** * Close the Add to Wishlist modal */ From cadd78603c423b49df19ff23176ecefb84e1b1d1 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 23 May 2026 12:47:23 +0300 Subject: [PATCH 06/26] fix(webui): make sidebar nav SPA links - convert the sidebar nav to real links with URL-driven state - intercept left-clicks so internal navigation stays in-app while preserving native browser link behavior - keep artist-detail transitions param-aware and update route tests --- webui/index.html | 68 ++++++++++++++++---------------- webui/static/init.js | 12 ++---- webui/static/shell-bridge.js | 24 +++++++---- webui/static/style.css | 2 + webui/tests/issues.smoke.spec.ts | 14 +++++-- 5 files changed, 66 insertions(+), 54 deletions(-) diff --git a/webui/index.html b/webui/index.html index fac604ed..6df47a2a 100644 --- a/webui/index.html +++ b/webui/index.html @@ -194,78 +194,78 @@ diff --git a/webui/static/init.js b/webui/static/init.js index 0465f70d..2f7c1239 100644 --- a/webui/static/init.js +++ b/webui/static/init.js @@ -2136,15 +2136,9 @@ function initApp() { // =============================== function initializeNavigation() { - const navButtons = document.querySelectorAll('.nav-button'); - - navButtons.forEach(button => { - button.addEventListener('click', () => { - const page = button.getAttribute('data-page'); - navigateToPage(page); - }); - }); - + // Sidebar navigation is now driven by native link navigation. + // Page activation and active-state styling are synchronized from the + // current URL by the shell bridge and route controllers. } const _DEEPLINK_VALID_PAGES = new Set([ diff --git a/webui/static/shell-bridge.js b/webui/static/shell-bridge.js index 66127869..7db84a76 100644 --- a/webui/static/shell-bridge.js +++ b/webui/static/shell-bridge.js @@ -22,15 +22,18 @@ function showLegacyPage(pageId) { function setActivePageChrome(pageId) { document.querySelectorAll('.nav-button').forEach(btn => { btn.classList.remove('active'); + btn.removeAttribute('aria-current'); }); const navButton = document.querySelector(`[data-page="${pageId}"]`); if (navButton) { navButton.classList.add('active'); + navButton.setAttribute('aria-current', 'page'); } else if (pageId === 'artist-detail') { // Artist detail is a Library context, so keep the sidebar anchored there. const libraryBtn = document.querySelector('[data-page="library"]'); if (libraryBtn) { libraryBtn.classList.add('active'); + libraryBtn.setAttribute('aria-current', 'page'); } } currentPage = pageId; @@ -181,35 +184,40 @@ function _handleShellLinkClick(event) { const anchor = event.target?.closest?.('a[href]'); if (!anchor || (anchor.target && anchor.target !== '_self')) return; + if (anchor.hasAttribute('download')) return; const href = anchor.getAttribute('href'); if (!href || href === '#' || href.startsWith('javascript:')) return; - const router = getWebRouter(); - if (!router?.navigateToPage) return; - const pathname = anchor.pathname || new URL(anchor.href, window.location.href).pathname; + const navPageId = anchor.matches('.nav-button[data-page]') ? anchor.getAttribute('data-page') : null; + if (navPageId) { + event.preventDefault(); + void navigateToPage(navPageId); + return; + } if (pathname.startsWith('/artist-detail/')) { - _handleArtistDetailLinkClick(event, pathname, router); + _handleArtistDetailLinkClick(event, pathname); return; } } -function _handleArtistDetailLinkClick(event, pathname, router) { +function _handleArtistDetailLinkClick(event, pathname) { const parts = pathname.split('/').filter(Boolean); if (parts.length < 3) return; - // Keep the semantic link, but hand the click back to TanStack so artist - // detail navigations stay in the SPA when the router is available. + // Keep the semantic link, but hand the click back to the SPA router so + // artist detail navigations stay in-app when the link is left-clicked. const source = decodeURIComponent(parts[1] || ''); const artistId = decodeURIComponent(parts.slice(2).join('/')); if (!source || !artistId) return; event.preventDefault(); - void router.navigateToPage('artist-detail', { + void navigateToPage('artist-detail', { artistId, artistSource: source, + forceReload: true, }); } diff --git a/webui/static/style.css b/webui/static/style.css index 24bf7425..bbdc9371 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -380,11 +380,13 @@ body { width: 216px; background: transparent; border: 1px solid transparent; + color: inherit; border-radius: 12px; cursor: pointer; display: flex; align-items: center; gap: 14px; + text-decoration: none; padding: 0 16px; transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); font-family: 'SF Pro Text', -apple-system, sans-serif; diff --git a/webui/tests/issues.smoke.spec.ts b/webui/tests/issues.smoke.spec.ts index c816b8e6..ebac18f5 100644 --- a/webui/tests/issues.smoke.spec.ts +++ b/webui/tests/issues.smoke.spec.ts @@ -30,7 +30,7 @@ async function waitForShellRoute(page: Page, pageId: string) { function getExpectedNavPage(pageId: ShellPageId): string { if (pageId === 'artist-detail') { - return ''; + return 'library'; } return pageId; @@ -95,7 +95,15 @@ test('browser history restores top-level routes', async ({ page, baseURL }) => { await page.goto(new URL('/discover', baseURL).toString(), { waitUntil: 'domcontentloaded' }); await waitForShellRoute(page, 'discover'); - await page.getByRole('button', { name: 'Issues' }).click(); + await page.evaluate(() => { + (window as typeof window & { __spaNavMarker?: string }).__spaNavMarker = 'persist'; + }); + await page.getByRole('link', { name: 'Issues' }).click(); + await expect + .poll(async () => + page.evaluate(() => (window as typeof window & { __spaNavMarker?: string }).__spaNavMarker ?? null), + ) + .toBe('persist'); await waitForShellRoute(page, 'issues'); await expect(page).toHaveURL(/\/issues(?:\?status=open&category=all)?$/); @@ -125,7 +133,7 @@ test('browser history leaves artist detail when going back to library', async ({ await page.locator('.library-artist-card').first().click(); await waitForShellRoute(page, 'artist-detail'); - await expect(page).toHaveURL(/\/artist-detail$/); + await expect(page).toHaveURL(/\/artist-detail\/library\/[^/]+$/); await page.goBack(); await waitForShellRoute(page, 'library'); From f6c6fc8579cc25990af7860e2adf2d76d4707786 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Thu, 14 May 2026 11:25:44 +0300 Subject: [PATCH 07/26] docs(webui): group migration planning docs Keep WebUI migration plans next to the frontend code so route work has one predictable home. Standardize the set around one page migration overview plus per-route migration plan docs for future tasks. --- webui/README.md | 6 + webui/docs/migration/README.md | 46 +++ .../docs/migration/page-migration-overview.md | 0 webui/docs/migration/stats-migration-plan.md | 387 ++++++++++++++++++ 4 files changed, 439 insertions(+) create mode 100644 webui/docs/migration/README.md rename docs/webui-page-migration-analysis.md => webui/docs/migration/page-migration-overview.md (100%) create mode 100644 webui/docs/migration/stats-migration-plan.md diff --git a/webui/README.md b/webui/README.md index d32587c2..0074b850 100644 --- a/webui/README.md +++ b/webui/README.md @@ -79,6 +79,12 @@ webui/src/ test/ Shared test utilities and setup helpers ``` +Migration planning docs live under `webui/docs/migration/`. + +- keep the high-level route backlog there +- add one route-specific sketch per migration task +- keep migration notes close to the WebUI code rather than the repo root + ### Route Slices - Keep route-specific code inside `webui/src/routes//`. diff --git a/webui/docs/migration/README.md b/webui/docs/migration/README.md new file mode 100644 index 00000000..a7a9d5e3 --- /dev/null +++ b/webui/docs/migration/README.md @@ -0,0 +1,46 @@ +# WebUI Migration Docs + +This folder is the home for React migration planning work inside `webui`. + +## Purpose + +- Keep migration planning close to the code it describes. +- Separate WebUI migration docs from repo-level product or backend docs. +- Give each route migration a predictable place to live. + +## Current Docs + +- [page-migration-overview.md](./page-migration-overview.md) + - high-level route inventory + - migration waves + - cross-route risk assessment +- [stats-migration-plan.md](./stats-migration-plan.md) + - route-specific migration plan for `stats` + +## Naming Guidance + +- Keep one high-level backlog / sequencing doc: + - `page-migration-overview.md` +- Use one route-specific plan per migration task: + - `-migration-plan.md` + +Examples: + +- `search-migration-plan.md` +- `watchlist-migration-plan.md` +- `library-migration-plan.md` + +## Scope + +Use this folder for: + +- migration sequencing +- route-specific implementation sketches +- React ownership cutover notes +- shell handoff notes tied to WebUI page migrations + +Do not use this folder for: + +- generic product docs +- backend architecture notes unrelated to WebUI migration +- permanent user-facing documentation diff --git a/docs/webui-page-migration-analysis.md b/webui/docs/migration/page-migration-overview.md similarity index 100% rename from docs/webui-page-migration-analysis.md rename to webui/docs/migration/page-migration-overview.md diff --git a/webui/docs/migration/stats-migration-plan.md b/webui/docs/migration/stats-migration-plan.md new file mode 100644 index 00000000..d74215cc --- /dev/null +++ b/webui/docs/migration/stats-migration-plan.md @@ -0,0 +1,387 @@ +# WebUI Stats React Migration Sketch + +Snapshot date: 2026-05-14 + +## Goal + +- Migrate `stats` from the legacy shell to the React route host. +- Replace the global `Chart.js` CDN script with route-local React chart components. +- Use the `issues` route slice as the structural reference, but add a few stronger conventions for data-heavy read-only pages. + +## Why `stats` Is The Right Next Route + +- The route is shell-local today. +- The activation path is narrow. +- The page has real async data loading and interaction. +- The page is complex enough to validate query conventions, search-param state, and route-local chart components. +- The page does not currently drive broad shell-global workflows. + +## Current Legacy Shape + +Page surface in `webui/index.html`: + +- Header + - time range buttons + - last synced label + - manual sync action +- Overview cards +- Left column + - listening activity chart + - genre breakdown chart + - recently played list +- Right column + - top artists + - top albums + - top tracks +- Full-width sections + - library health + - library disk usage + - database storage +- Empty state + +Legacy JS responsibilities in `webui/static/stats-automations.js`: + +- page initialization +- range switch handling +- data fetch orchestration +- formatting helpers +- chart instantiation and teardown +- ranked list rendering +- cross-page deep links into library / artist detail +- playback handoff for recent and top tracks + +Backend endpoints already split cleanly: + +- `GET /api/stats/cached` +- `GET /api/stats/db-storage` +- `GET /api/stats/library-disk-usage` +- `POST /api/listening-stats/sync` +- `GET /api/listening-stats/status` + +There are also narrower stats endpoints in the backend, but the current page already gets most of its main payload from the cached route. + +## Library Choice + +Recommended charting library: + +- `recharts` + +Reasoning: + +- React-native component model +- good fit for bar + doughnut-style dashboards +- easy to split into small route-local components +- easier to theme from CSS variables than raw imperative chart setup +- easier to test than a canvas-first imperative path + +Not recommended for this migration: + +- `react-chartjs-2` + - better for parity-only migration + - still keeps the mental model close to Chart.js +- `visx` + - stronger for bespoke visualization systems + - more work than this page needs + +## Proposed Route Slice + +```text +webui/src/routes/stats/ + route.tsx + -stats.types.ts + -stats.api.ts + -stats.helpers.ts + -stats.api.test.ts + -stats.helpers.test.ts + -ui/ + stats-page.tsx + stats-page.module.css + stats-header.tsx + stats-overview-cards.tsx + stats-empty-state.tsx + stats-ranked-list.tsx + stats-recent-plays.tsx + stats-library-health.tsx + stats-disk-usage.tsx + stats-activity-chart.tsx + stats-genre-chart.tsx + stats-db-storage-chart.tsx +``` + +## Proposed Route Responsibilities + +`route.tsx` + +- declare `/stats` +- validate search params +- gate route through `bridge.isPageAllowed('stats')` +- preload the shell context +- load the main cached stats payload plus listening-status payload +- optionally preload disk usage and db storage if we want zero-layout-shift first render + +`-stats.types.ts` + +- search param schema +- response payload types +- normalized display shapes +- chart row types + +`-stats.api.ts` + +- query keys +- fetchers for: + - cached stats + - listening status + - db storage + - library disk usage +- mutation helper for manual sync +- invalidation helpers + +`-stats.helpers.ts` + +- range labels +- numeric and duration formatters +- disk size formatters +- chart data shaping +- legend shaping +- safe fallbacks for empty server responses + +`-ui/stats-page.tsx` + +- page composition +- search-param driven range selection +- section layout +- empty-state branching + +## Search Params + +Use search params for state that should survive reloads and linking: + +- `range` + +Recommended values: + +- `7d` +- `30d` +- `12m` +- `all` + +This is the one clear page-state value worth encoding in the URL. Everything else can remain derived from server data. + +## Query Model + +Recommended split: + +- primary query: + - `statsCachedQueryOptions(profileId, range)` +- secondary queries: + - `statsListeningStatusQueryOptions(profileId)` + - `statsDbStorageQueryOptions(profileId)` + - `statsLibraryDiskUsageQueryOptions(profileId)` + +Why this split: + +- `cached` is the real page backbone +- `db-storage` and `library-disk-usage` are already separate in the backend +- they can render as progressively enhanced cards without blocking the whole route +- `listening-stats/status` updates the sync label and complements the sync mutation + +Recommended route-loader behavior: + +- always ensure: + - cached stats + - listening status +- optional: + - db storage + - disk usage + +If we want a snappier first migration, we should keep the last two as client-side `useQuery` calls rather than route-loader requirements. + +## Component Sketch + +`StatsPage` + +- calls `useReactPageShell('stats')` +- reads `range` from route search +- renders: + - `StatsHeader` + - `StatsOverviewCards` + - `StatsEmptyState` or main sections + +`StatsHeader` + +- range segmented control +- last synced text +- sync button mutation + +`StatsOverviewCards` + +- five summary cards + +`StatsActivityChart` + +- Recharts `BarChart` +- responsive container +- route-local tooltip +- accepts already-shaped rows + +`StatsGenreChart` + +- Recharts `PieChart` +- legend rendered in React markup beside the chart +- top-10 clipping stays in helpers + +`StatsDbStorageChart` + +- Recharts `PieChart` +- custom center label rendered in React +- legend list rendered beside chart + +`StatsRankedList` + +- shared component for artists / albums / tracks +- variant props for: + - artwork + - subtitle/meta + - count label + - optional play action + - optional artist-detail deep link + +`StatsRecentPlays` + +- simple list component +- play action + +`StatsLibraryHealth` + +- overview metrics +- format breakdown bar +- enrichment coverage rows + +`StatsDiskUsage` + +- total bytes row +- pending/deep-scan message +- per-format horizontal bars + +## Recharts Mapping + +Legacy Chart.js to React mapping: + +- listening activity + - from imperative `new Chart(... type: 'bar')` + - to `ResponsiveContainer` + `BarChart` + `Bar` + `XAxis` + `YAxis` + `Tooltip` +- genre breakdown + - from doughnut chart + - to `PieChart` + `Pie` + custom legend +- database storage + - from doughnut chart with center total overlay + - to `PieChart` + `Pie` + React-rendered center label + +Suggested chart convention: + +- keep all chart data shaping outside the chart components +- chart components should receive already-normalized rows and colors +- never read directly from raw server payloads inside Recharts markup + +## CSS Strategy + +Recommended first pass: + +- create `stats-page.module.css` +- port stats-specific selectors from `webui/static/style.css` +- keep class names semantically similar to reduce migration risk + +Suggested approach: + +- move only the selectors needed by the React route +- leave legacy stats selectors in place until the route flip is complete +- after the React route owns `stats`, remove unused legacy selectors in a cleanup pass + +Do not try to redesign the page during the migration. + +## Shell And Routing Changes + +When the route is ready: + +1. Add `webui/src/routes/stats/route.tsx` +2. Regenerate the TanStack route tree if needed +3. Change `stats` from `legacy` to `react` in `webui/src/platform/shell/route-manifest.ts` +4. Keep the legacy `stats-page` DOM in `webui/index.html` during the initial cutover if that reduces risk +5. Remove legacy activation from `webui/static/init.js` once React ownership is confirmed +6. Remove the global Chart.js script from `webui/index.html` + +## Incremental Migration Order + +Recommended order: + +1. Add types, API layer, and helpers +2. Build the React route with plain markup and no charts yet +3. Port overview, ranked lists, recent plays, and empty state +4. Port library health and disk usage +5. Port Recharts activity, genre, and db storage charts +6. Flip route ownership from legacy to React +7. Remove global Chart.js import +8. Delete or shrink legacy `stats` logic from `stats-automations.js` + +This order gives us a working React page before charting becomes the critical path. + +## Testing Sketch + +Unit tests: + +- `-stats.helpers.test.ts` + - range formatting + - duration formatting + - db storage grouping into `Other` + - genre top-10 shaping + - disk usage empty-state shaping + +API tests: + +- `-stats.api.test.ts` + - cached stats success / error + - listening status success / error + - db storage success / error + - disk usage success / error + - sync mutation success / error + +Route / component tests: + +- initial render for default `range=7d` +- changing range updates the URL and query key +- empty state renders when `overview.total_plays === 0` +- ranked artist click deep-links to library / artist detail +- track play action triggers the expected handoff +- sync action shows pending state and invalidates relevant queries + +Playwright is optional for the first pass. + +## Decisions To Keep Simple + +- Keep the existing page structure. +- Keep the current backend endpoint split. +- Keep the current time-range set. +- Reuse the existing shell deep-link behavior for library and playback. +- Use Recharts only inside `stats` first. + +## Follow-Up Opportunities + +- Extract shared chart colors into route-local constants or a small shared viz helper. +- Consider a tiny `components/charts/` layer only after a second React page needs charts. +- Revisit whether `stats/cached` should remain the primary page payload or whether the route should fan out to narrower endpoints later. + +## Recommendation + +The first implementation should optimize for: + +- parity +- clear route-local boundaries +- removal of global `Chart.js` +- reusable data/query conventions + +It should not optimize for: + +- visual redesign +- a cross-app chart abstraction +- backend reshaping From b24152c74b33cfc23eed2552a9d4fb35b3c536c6 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Thu, 14 May 2026 16:45:19 +0300 Subject: [PATCH 08/26] feat(webui): migrate stats page to react - move the stats route onto the React shell with Recharts-based visualizations - remove the global Chart.js include and add a local stats seed script for easier testing - keep parity coverage with route, API, and helper tests while preserving the legacy page layout --- webui/index.html | 1 - webui/package-lock.json | 372 ++++++- webui/package.json | 1 + webui/src/platform/shell/globals.d.ts | 22 + webui/src/platform/shell/route-manifest.ts | 2 +- webui/src/routeTree.gen.ts | 30 +- webui/src/routes/stats/-route.test.tsx | 126 +++ webui/src/routes/stats/-stats.api.test.ts | 103 ++ webui/src/routes/stats/-stats.api.ts | 124 +++ webui/src/routes/stats/-stats.helpers.test.ts | 67 ++ webui/src/routes/stats/-stats.helpers.ts | 162 ++++ webui/src/routes/stats/-stats.types.ts | 148 +++ .../routes/stats/-ui/stats-page.module.css | 916 ++++++++++++++++++ webui/src/routes/stats/-ui/stats-page.tsx | 907 +++++++++++++++++ webui/src/routes/stats/route.tsx | 28 + 15 files changed, 3002 insertions(+), 7 deletions(-) create mode 100644 webui/src/routes/stats/-route.test.tsx create mode 100644 webui/src/routes/stats/-stats.api.test.ts create mode 100644 webui/src/routes/stats/-stats.api.ts create mode 100644 webui/src/routes/stats/-stats.helpers.test.ts create mode 100644 webui/src/routes/stats/-stats.helpers.ts create mode 100644 webui/src/routes/stats/-stats.types.ts create mode 100644 webui/src/routes/stats/-ui/stats-page.module.css create mode 100644 webui/src/routes/stats/-ui/stats-page.tsx create mode 100644 webui/src/routes/stats/route.tsx diff --git a/webui/index.html b/webui/index.html index 6df47a2a..3d4c6b63 100644 --- a/webui/index.html +++ b/webui/index.html @@ -8142,7 +8142,6 @@
- {{ vite_assets('body')|safe }} diff --git a/webui/package-lock.json b/webui/package-lock.json index da9dea53..23b81ab1 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -14,6 +14,7 @@ "ky": "^2.0.2", "react": "^19.2.5", "react-dom": "^19.2.5", + "recharts": "^3.8.1", "zod": "^4.4.2" }, "devDependencies": { @@ -1697,6 +1698,42 @@ "node": ">=18" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", @@ -1983,7 +2020,12 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, "node_modules/@tanstack/devtools-event-client": { @@ -2412,6 +2454,69 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2473,6 +2578,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -2954,6 +3065,127 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -2993,6 +3225,12 @@ "dev": true, "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3058,6 +3296,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3078,6 +3326,12 @@ "@types/estree": "^1.0.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -3228,6 +3482,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -3238,6 +3502,15 @@ "node": ">=8" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -4116,10 +4389,32 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, "license": "MIT", "peer": true }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -4146,6 +4441,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -4160,6 +4485,21 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4423,6 +4763,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -4656,6 +5002,28 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "8.0.10", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", diff --git a/webui/package.json b/webui/package.json index 2e076f38..56123f51 100644 --- a/webui/package.json +++ b/webui/package.json @@ -20,6 +20,7 @@ "ky": "^2.0.2", "react": "^19.2.5", "react-dom": "^19.2.5", + "recharts": "^3.8.1", "zod": "^4.4.2" }, "devDependencies": { diff --git a/webui/src/platform/shell/globals.d.ts b/webui/src/platform/shell/globals.d.ts index 65d5b471..2e254c24 100644 --- a/webui/src/platform/shell/globals.d.ts +++ b/webui/src/platform/shell/globals.d.ts @@ -46,6 +46,28 @@ declare global { cancelSimilarArtistsLoad: () => void; showReactHost: (pageId: ShellPageId) => void; }; + navigateToArtistDetail?: ( + artistId: string | number, + artistName: string, + sourceOverride?: string | null, + options?: Record, + ) => void; + playLibraryTrack?: ( + track: { + id: string | number; + title: string; + file_path: string; + bitrate?: string | number | null; + artist_id?: string | number | null; + album_id?: string | number | null; + _stats_image?: string | null; + }, + albumTitle: string, + artistName: string, + ) => void | Promise; + startStream?: (searchResult: Record) => void | Promise; + showLoadingOverlay?: (message?: string) => void; + hideLoadingOverlay?: () => void; } } diff --git a/webui/src/platform/shell/route-manifest.ts b/webui/src/platform/shell/route-manifest.ts index 469d46d1..39686325 100644 --- a/webui/src/platform/shell/route-manifest.ts +++ b/webui/src/platform/shell/route-manifest.ts @@ -42,7 +42,7 @@ export const shellRouteManifest: readonly ShellRouteDefinition[] = [ { pageId: 'library', path: '/library', kind: 'legacy' }, { pageId: 'tools', path: '/tools', kind: 'legacy' }, { pageId: 'artist-detail', path: '/artist-detail', kind: 'legacy' }, - { pageId: 'stats', path: '/stats', kind: 'legacy' }, + { pageId: 'stats', path: '/stats', kind: 'react' }, { pageId: 'settings', path: '/settings', kind: 'legacy' }, { pageId: 'issues', path: '/issues', kind: 'react' }, { pageId: 'help', path: '/help', kind: 'legacy' }, diff --git a/webui/src/routeTree.gen.ts b/webui/src/routeTree.gen.ts index ca94e35b..addc76fb 100644 --- a/webui/src/routeTree.gen.ts +++ b/webui/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SplatRouteImport } from './routes/$' +import { Route as StatsRouteRouteImport } from './routes/stats/route' import { Route as IssuesRouteRouteImport } from './routes/issues/route' import { Route as IndexRouteImport } from './routes/index' import { Route as ArtistDetailSourceIdRouteImport } from './routes/artist-detail/$source/$id' @@ -19,6 +20,11 @@ const SplatRoute = SplatRouteImport.update({ path: '/$', getParentRoute: () => rootRouteImport, } as any) +const StatsRouteRoute = StatsRouteRouteImport.update({ + id: '/stats', + path: '/stats', + getParentRoute: () => rootRouteImport, +} as any) const IssuesRouteRoute = IssuesRouteRouteImport.update({ id: '/issues', path: '/issues', @@ -38,12 +44,14 @@ const ArtistDetailSourceIdRoute = ArtistDetailSourceIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/issues': typeof IssuesRouteRoute + '/stats': typeof StatsRouteRoute '/$': typeof SplatRoute '/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/issues': typeof IssuesRouteRoute + '/stats': typeof StatsRouteRoute '/$': typeof SplatRoute '/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute } @@ -51,20 +59,28 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/issues': typeof IssuesRouteRoute + '/stats': typeof StatsRouteRoute '/$': typeof SplatRoute '/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/issues' | '/$' | '/artist-detail/$source/$id' + fullPaths: '/' | '/issues' | '/stats' | '/$' | '/artist-detail/$source/$id' fileRoutesByTo: FileRoutesByTo - to: '/' | '/issues' | '/$' | '/artist-detail/$source/$id' - id: '__root__' | '/' | '/issues' | '/$' | '/artist-detail/$source/$id' + to: '/' | '/issues' | '/stats' | '/$' | '/artist-detail/$source/$id' + id: + | '__root__' + | '/' + | '/issues' + | '/stats' + | '/$' + | '/artist-detail/$source/$id' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute IssuesRouteRoute: typeof IssuesRouteRoute + StatsRouteRoute: typeof StatsRouteRoute SplatRoute: typeof SplatRoute ArtistDetailSourceIdRoute: typeof ArtistDetailSourceIdRoute } @@ -78,6 +94,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SplatRouteImport parentRoute: typeof rootRouteImport } + '/stats': { + id: '/stats' + path: '/stats' + fullPath: '/stats' + preLoaderRoute: typeof StatsRouteRouteImport + parentRoute: typeof rootRouteImport + } '/issues': { id: '/issues' path: '/issues' @@ -105,6 +128,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, IssuesRouteRoute: IssuesRouteRoute, + StatsRouteRoute: StatsRouteRoute, SplatRoute: SplatRoute, ArtistDetailSourceIdRoute: ArtistDetailSourceIdRoute, } diff --git a/webui/src/routes/stats/-route.test.tsx b/webui/src/routes/stats/-route.test.tsx new file mode 100644 index 00000000..1b447aa7 --- /dev/null +++ b/webui/src/routes/stats/-route.test.tsx @@ -0,0 +1,126 @@ +import { createMemoryHistory } from '@tanstack/react-router'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge'; + +import { createAppQueryClient } from '@/app/query-client'; +import { AppRouterProvider, createAppRouter } from '@/app/router'; + +function createResponse(body: unknown, ok = true, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function createShellBridge(overrides: Partial = {}): ShellBridge { + return { + getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: true })), + isPageAllowed: vi.fn(() => true), + getProfileHomePage: vi.fn<() => ShellPageId>(() => 'discover'), + resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'search'), + setActivePageChrome: vi.fn(), + activateLegacyPath: vi.fn(), + showReactHost: vi.fn(), + ...overrides, + }; +} + +function renderStatsRoute(initialEntries = ['/stats']) { + const queryClient = createAppQueryClient(); + const history = createMemoryHistory({ initialEntries }); + const router = createAppRouter({ history, queryClient }); + + return { + history, + ...render(), + }; +} + +describe('stats route', () => { + beforeEach(() => { + window.SoulSyncWebShellBridge = createShellBridge(); + window.navigateToArtistDetail = vi.fn(); + window.playLibraryTrack = vi.fn(); + window.startStream = vi.fn(); + window.showLoadingOverlay = vi.fn(); + window.hideLoadingOverlay = vi.fn(); + window.showToast = vi.fn(); + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url.includes('/api/stats/cached')) { + return createResponse({ + success: true, + overview: { + total_plays: 24, + total_time_ms: 6_600_000, + unique_artists: 3, + unique_albums: 4, + unique_tracks: 12, + }, + top_artists: [{ id: 7, name: 'Artist A', play_count: 10 }], + top_albums: [], + top_tracks: [], + timeline: [{ date: 'May 10', plays: 4 }], + genres: [{ genre: 'House', play_count: 10, percentage: 80 }], + recent: [{ title: 'Track A', artist: 'Artist A', played_at: '2026-05-14T08:00:00Z' }], + health: { total_tracks: 12, format_breakdown: { FLAC: 12 } }, + }); + } + if (url.includes('/api/listening-stats/status')) { + return createResponse({ stats: { last_poll: '2026-05-14 10:00:00' } }); + } + if (url.includes('/api/stats/db-storage')) { + return createResponse({ + success: true, + tables: [{ name: 'tracks', size: 2048 }], + total_file_size: 4096, + method: 'dbstat', + }); + } + if (url.includes('/api/stats/library-disk-usage')) { + return createResponse({ + success: true, + has_data: true, + total_bytes: 2048, + tracks_with_size: 12, + tracks_without_size: 0, + by_format: { flac: 2048 }, + }); + } + return createResponse({ success: true }); + }) as unknown as typeof fetch, + ); + }); + + it('renders the stats page through the app router', async () => { + renderStatsRoute(); + + await waitFor(() => expect(screen.getByTestId('stats-page')).toBeInTheDocument()); + expect(await screen.findByText('Listening Stats')).toBeInTheDocument(); + expect(screen.getByText('24')).toBeInTheDocument(); + expect(window.SoulSyncWebShellBridge?.showReactHost).toHaveBeenCalledWith('stats'); + expect(window.SoulSyncWebShellBridge?.setActivePageChrome).toHaveBeenCalledWith('stats'); + }); + + it('stores the time range in route search state', async () => { + const { history } = renderStatsRoute(); + + fireEvent.click(await screen.findByRole('button', { name: '30 Days' })); + + await waitFor(() => expect(history.location.search).toContain('range=30d')); + }); + + it('redirects back home when the page is not allowed', async () => { + window.SoulSyncWebShellBridge = createShellBridge({ + isPageAllowed: vi.fn((pageId) => pageId !== 'stats'), + }); + + const { history } = renderStatsRoute(['/stats']); + + await waitFor(() => expect(history.location.pathname).toBe('/discover')); + }); +}); diff --git a/webui/src/routes/stats/-stats.api.test.ts b/webui/src/routes/stats/-stats.api.test.ts new file mode 100644 index 00000000..9a91b93f --- /dev/null +++ b/webui/src/routes/stats/-stats.api.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; + +import { HttpResponse, http, server } from '@/test/msw'; + +import { + fetchListeningStatsStatus, + fetchStatsCached, + fetchStatsDbStorage, + fetchStatsLibraryDiskUsage, + resolveStatsTrack, + streamStatsTrack, + triggerListeningStatsSync, +} from './-stats.api'; + +describe('stats api', () => { + it('fetches the cached stats payload for a range', async () => { + server.use( + http.get('/api/stats/cached', ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get('range')).toBe('30d'); + + return HttpResponse.json({ + success: true, + overview: { total_plays: 12 }, + top_artists: [], + top_albums: [], + top_tracks: [], + timeline: [], + genres: [], + recent: [], + health: {}, + }); + }), + ); + + await expect(fetchStatsCached('30d')).resolves.toMatchObject({ + overview: { total_plays: 12 }, + }); + }); + + it('surfaces db storage and disk usage errors', async () => { + server.use( + http.get('/api/stats/db-storage', () => + HttpResponse.json({ error: 'db unavailable' }, { status: 500 }), + ), + http.get('/api/stats/library-disk-usage', () => + HttpResponse.json({ error: 'disk unavailable' }, { status: 500 }), + ), + ); + + await expect(fetchStatsDbStorage()).rejects.toThrow('db unavailable'); + await expect(fetchStatsLibraryDiskUsage()).rejects.toThrow('disk unavailable'); + }); + + it('reads listening status and triggers manual sync', async () => { + server.use( + http.get('/api/listening-stats/status', () => + HttpResponse.json({ stats: { last_poll: '2026-05-14 10:00:00' } }), + ), + http.post('/api/listening-stats/sync', () => HttpResponse.json({ success: true })), + ); + + await expect(fetchListeningStatsStatus()).resolves.toEqual({ + stats: { last_poll: '2026-05-14 10:00:00' }, + }); + await expect(triggerListeningStatsSync()).resolves.toBeUndefined(); + }); + + it('resolves and streams tracks through the stats playback helpers', async () => { + server.use( + http.post('/api/stats/resolve-track', async ({ request }) => { + await expect(request.json()).resolves.toEqual({ + title: 'Track', + artist: 'Artist', + }); + return HttpResponse.json({ + success: true, + track: { id: 1, title: 'Track', file_path: '/music/track.flac' }, + }); + }), + http.post('/api/enhanced-search/stream-track', async ({ request }) => { + await expect(request.json()).resolves.toEqual({ + track_name: 'Track', + artist_name: 'Artist', + album_name: 'Album', + duration_ms: 0, + }); + return HttpResponse.json({ + success: true, + result: { stream_url: '/api/stream/1' }, + }); + }), + ); + + await expect(resolveStatsTrack('Track', 'Artist')).resolves.toMatchObject({ + id: 1, + title: 'Track', + }); + await expect(streamStatsTrack('Track', 'Artist', 'Album')).resolves.toEqual({ + stream_url: '/api/stream/1', + }); + }); +}); diff --git a/webui/src/routes/stats/-stats.api.ts b/webui/src/routes/stats/-stats.api.ts new file mode 100644 index 00000000..c8e69d9e --- /dev/null +++ b/webui/src/routes/stats/-stats.api.ts @@ -0,0 +1,124 @@ +import { queryOptions, type QueryClient } from '@tanstack/react-query'; + +import { apiClient, readJson } from '@/app/api-client'; + +import type { + ListeningStatsStatus, + StatsCachedPayload, + StatsDbStoragePayload, + StatsLibraryDiskUsagePayload, + StatsRange, + StatsResolveTrackPayload, + StatsStreamTrackPayload, +} from './-stats.types'; + +export const STATS_QUERY_KEY = ['stats'] as const; + +export async function fetchStatsCached(range: StatsRange): Promise { + const payload = await readJson( + apiClient.get('stats/cached', { + searchParams: { range }, + }), + ); + if (!payload.success) { + throw new Error(payload.error || 'Failed to load listening stats'); + } + return payload; +} + +export async function fetchListeningStatsStatus(): Promise { + return await readJson(apiClient.get('listening-stats/status')); +} + +export async function fetchStatsDbStorage(): Promise { + const payload = await readJson(apiClient.get('stats/db-storage')); + if (!payload.success) { + throw new Error(payload.error || 'Failed to load database storage'); + } + return payload; +} + +export async function fetchStatsLibraryDiskUsage(): Promise { + const payload = await readJson( + apiClient.get('stats/library-disk-usage'), + ); + if (!payload.success) { + throw new Error(payload.error || 'Failed to load library disk usage'); + } + return payload; +} + +export async function triggerListeningStatsSync(): Promise { + const payload = await readJson<{ success: boolean; error?: string }>( + apiClient.post('listening-stats/sync'), + ); + if (!payload.success) { + throw new Error(payload.error || 'Sync failed'); + } +} + +export async function resolveStatsTrack( + title: string, + artist: string, +): Promise { + const payload = await readJson( + apiClient.post('stats/resolve-track', { + json: { title, artist }, + }), + ); + if (!payload.success) return null; + return payload.track ?? null; +} + +export async function streamStatsTrack( + title: string, + artist: string, + album: string, +): Promise | null> { + const payload = await readJson( + apiClient.post('enhanced-search/stream-track', { + json: { + track_name: title, + artist_name: artist, + album_name: album, + duration_ms: 0, + }, + }), + ); + if (!payload.success) { + throw new Error(payload.error || 'Track not found in library or any source'); + } + return payload.result ?? null; +} + +export function statsCachedQueryOptions(range: StatsRange) { + return queryOptions({ + queryKey: [...STATS_QUERY_KEY, 'cached', range], + queryFn: () => fetchStatsCached(range), + }); +} + +export function listeningStatsStatusQueryOptions() { + return queryOptions({ + queryKey: [...STATS_QUERY_KEY, 'listening-status'], + queryFn: fetchListeningStatsStatus, + }); +} + +export function statsDbStorageQueryOptions() { + return queryOptions({ + queryKey: [...STATS_QUERY_KEY, 'db-storage'], + queryFn: fetchStatsDbStorage, + }); +} + +export function statsLibraryDiskUsageQueryOptions() { + return queryOptions({ + queryKey: [...STATS_QUERY_KEY, 'library-disk-usage'], + queryFn: fetchStatsLibraryDiskUsage, + }); +} + +export function invalidateStatsQueries(queryClient: QueryClient) { + return queryClient.invalidateQueries({ queryKey: STATS_QUERY_KEY }); +} diff --git a/webui/src/routes/stats/-stats.helpers.test.ts b/webui/src/routes/stats/-stats.helpers.test.ts new file mode 100644 index 00000000..0814546f --- /dev/null +++ b/webui/src/routes/stats/-stats.helpers.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { + formatBytes, + formatDbStorageValue, + formatListeningTime, + formatRelativePlayedAt, + getTopArtistBubbles, + groupDbStorageTables, + hasStatsData, +} from './-stats.helpers'; +import { statsSearchSchema } from './-stats.types'; + +describe('statsSearchSchema', () => { + it('falls back to 7d for unknown ranges', () => { + expect(statsSearchSchema.parse({ range: 'bad' })).toEqual({ range: '7d' }); + }); + + it('keeps known ranges', () => { + expect(statsSearchSchema.parse({ range: '12m' })).toEqual({ range: '12m' }); + }); +}); + +describe('stats helpers', () => { + it('detects whether the page has listening data', () => { + expect(hasStatsData({ total_plays: 0 })).toBe(false); + expect(hasStatsData({ total_plays: 4 })).toBe(true); + }); + + it('formats listening time and bytes', () => { + expect(formatListeningTime(3_900_000)).toBe('1h 5m'); + expect(formatBytes(2_097_152)).toBe('2.00 MB'); + }); + + it('formats relative recent-play times', () => { + const now = new Date('2026-05-14T12:00:00.000Z').getTime(); + expect(formatRelativePlayedAt('2026-05-14T11:15:00.000Z', now)).toBe('45m ago'); + expect(formatRelativePlayedAt('2026-05-14T08:00:00.000Z', now)).toBe('4h ago'); + }); + + it('groups db storage rows into Other after the top eight', () => { + const grouped = groupDbStorageTables( + Array.from({ length: 10 }, (_, index) => ({ + name: `table_${index + 1}`, + size: index + 1, + })), + ); + + expect(grouped).toHaveLength(9); + expect(grouped.at(-1)).toEqual({ name: 'Other', size: 19 }); + }); + + it('formats db storage by method', () => { + expect(formatDbStorageValue(2_097_152, 'dbstat')).toBe('2.0 MB'); + expect(formatDbStorageValue(1240, 'rowcount')).toBe('1,240 rows'); + }); + + it('shapes top artist bubbles from the highest-play artist', () => { + const bubbles = getTopArtistBubbles([ + { name: 'A', play_count: 20 }, + { name: 'B', play_count: 10 }, + ]); + + expect(bubbles[0]?.percent).toBe(100); + expect(bubbles[1]?.percent).toBe(50); + }); +}); diff --git a/webui/src/routes/stats/-stats.helpers.ts b/webui/src/routes/stats/-stats.helpers.ts new file mode 100644 index 00000000..d20d9742 --- /dev/null +++ b/webui/src/routes/stats/-stats.helpers.ts @@ -0,0 +1,162 @@ +import type { + StatsArtistRow, + StatsCachedPayload, + StatsDbStorageTable, + StatsHealth, + StatsOverview, + StatsRange, +} from './-stats.types'; + +export const EMPTY_STATS_OVERVIEW: StatsOverview = { + total_plays: 0, + total_time_ms: 0, + unique_artists: 0, + unique_albums: 0, + unique_tracks: 0, +}; + +export const EMPTY_STATS_PAYLOAD: Required< + Pick< + StatsCachedPayload, + 'overview' | 'top_artists' | 'top_albums' | 'top_tracks' | 'timeline' | 'genres' | 'recent' + > +> & { health: StatsHealth } = { + overview: EMPTY_STATS_OVERVIEW, + top_artists: [], + top_albums: [], + top_tracks: [], + timeline: [], + genres: [], + recent: [], + health: {}, +}; + +export const STATS_GENRE_COLORS = [ + '#1db954', + '#1ed760', + '#4ade80', + '#7c3aed', + '#a855f7', + '#ec4899', + '#f43f5e', + '#f97316', + '#eab308', + '#06b6d4', +] as const; + +export const STATS_DB_STORAGE_COLORS = [ + '#3b82f6', + '#f97316', + '#a855f7', + '#14b8a6', + '#eab308', + '#ec4899', + '#6366f1', + '#22c55e', + '#555555', +] as const; + +export const STATS_ENRICHMENT_SERVICES = [ + { key: 'spotify', label: 'Spotify', color: '#1db954' }, + { key: 'musicbrainz', label: 'MusicBrainz', color: '#ba55d3' }, + { key: 'deezer', label: 'Deezer', color: '#a238ff' }, + { key: 'lastfm', label: 'Last.fm', color: '#d51007' }, + { key: 'itunes', label: 'iTunes', color: '#fc3c44' }, + { key: 'audiodb', label: 'AudioDB', color: '#1a9fff' }, + { key: 'genius', label: 'Genius', color: '#ffff64' }, + { key: 'tidal', label: 'Tidal', color: '#00ffff' }, + { key: 'qobuz', label: 'Qobuz', color: '#4285f4' }, +] as const; + +export function getStatsRangeLabel(range: StatsRange): string { + switch (range) { + case '7d': + return '7 Days'; + case '30d': + return '30 Days'; + case '12m': + return '12 Months'; + case 'all': + return 'All Time'; + } +} + +export function hasStatsData(overview: Partial | undefined): boolean { + return (overview?.total_plays ?? 0) > 0; +} + +export function formatCompactNumber(value: number | null | undefined): string { + if (!value) return '0'; + if (value >= 1_000_000) return `${stripTrailingZero((value / 1_000_000).toFixed(1))}M`; + if (value >= 1_000) return `${stripTrailingZero((value / 1_000).toFixed(1))}K`; + return value.toLocaleString(); +} + +export function formatListeningTime(totalMs: number | null | undefined): string { + if (!totalMs) return '0h'; + const hours = Math.floor(totalMs / 3_600_000); + const minutes = Math.floor((totalMs % 3_600_000) / 60_000); + return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; +} + +export function formatTotalDuration(totalMs: number | null | undefined): string { + if (!totalMs) return '0h'; + return `${Math.floor(totalMs / 3_600_000)}h`; +} + +export function formatRelativePlayedAt( + dateStr: string | null | undefined, + now = Date.now(), +): string { + if (!dateStr) return ''; + const diff = now - new Date(dateStr).getTime(); + const minutes = Math.floor(diff / 60_000); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + return `${Math.floor(days / 30)}mo ago`; +} + +export function formatBytes(value: number | null | undefined): string { + if (!value || value <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let index = 0; + let next = value; + while (next >= 1024 && index < units.length - 1) { + next /= 1024; + index += 1; + } + return `${next.toFixed(next < 10 ? 2 : 1)} ${units[index]}`; +} + +export function groupDbStorageTables(tables: StatsDbStorageTable[]): StatsDbStorageTable[] { + const top = tables.slice(0, 8); + const rest = tables.slice(8); + const restSize = rest.reduce((sum, table) => sum + table.size, 0); + return restSize > 0 ? [...top, { name: 'Other', size: restSize }] : top; +} + +export function formatDbStorageValue(size: number, method: string | null | undefined): string { + if (method === 'dbstat') { + if (size > 1_048_576) return `${(size / 1_048_576).toFixed(1)} MB`; + return `${Math.round(size / 1024)} KB`; + } + return `${size.toLocaleString()} rows`; +} + +export function getTopArtistBubbles(artists: StatsArtistRow[]) { + const top = artists.slice(0, 5); + const maxPlays = top[0]?.play_count || 1; + + return top.map((artist, index) => ({ + artist, + percent: Math.round((artist.play_count / maxPlays) * 100), + size: 44 + (4 - index) * 6, + })); +} + +function stripTrailingZero(value: string): string { + return value.replace(/\.0$/, ''); +} diff --git a/webui/src/routes/stats/-stats.types.ts b/webui/src/routes/stats/-stats.types.ts new file mode 100644 index 00000000..c00cd25a --- /dev/null +++ b/webui/src/routes/stats/-stats.types.ts @@ -0,0 +1,148 @@ +import { z } from 'zod'; + +export const STATS_RANGE_VALUES = ['7d', '30d', '12m', 'all'] as const; +export type StatsRange = (typeof STATS_RANGE_VALUES)[number]; + +export const statsSearchSchema = z.object({ + range: z.enum(STATS_RANGE_VALUES).default('7d').catch('7d'), +}); + +export type StatsSearch = z.infer; + +export interface StatsOverview { + total_plays: number; + total_time_ms: number; + unique_artists: number; + unique_albums: number; + unique_tracks: number; +} + +export interface StatsArtistRow { + id?: string | number | null; + name: string; + image_url?: string | null; + play_count: number; + global_listeners?: number | null; + soul_id?: string | null; +} + +export interface StatsAlbumRow { + name: string; + artist?: string | null; + artist_id?: string | number | null; + image_url?: string | null; + play_count: number; +} + +export interface StatsTrackRow { + name: string; + artist?: string | null; + artist_id?: string | number | null; + album?: string | null; + image_url?: string | null; + play_count: number; +} + +export interface StatsTimelineRow { + date: string; + plays: number; +} + +export interface StatsGenreRow { + genre: string; + play_count: number; + percentage: number; +} + +export interface StatsEnrichmentCoverage { + spotify?: number; + musicbrainz?: number; + deezer?: number; + lastfm?: number; + itunes?: number; + audiodb?: number; + genius?: number; + tidal?: number; + qobuz?: number; +} + +export interface StatsHealth { + total_tracks?: number; + unplayed_count?: number; + unplayed_percentage?: number; + total_duration_ms?: number; + format_breakdown?: Record; + enrichment_coverage?: StatsEnrichmentCoverage; +} + +export interface StatsRecentTrack { + title: string; + artist?: string | null; + album?: string | null; + played_at?: string | null; +} + +export interface StatsCachedPayload { + success: boolean; + overview?: Partial; + top_artists?: StatsArtistRow[]; + top_albums?: StatsAlbumRow[]; + top_tracks?: StatsTrackRow[]; + timeline?: StatsTimelineRow[]; + genres?: StatsGenreRow[]; + recent?: StatsRecentTrack[]; + health?: StatsHealth; + error?: string; +} + +export interface ListeningStatsStatus { + stats?: { + last_poll?: string | null; + }; + error?: string; +} + +export interface StatsDbStorageTable { + name: string; + size: number; +} + +export interface StatsDbStoragePayload { + success: boolean; + tables?: StatsDbStorageTable[]; + total_file_size?: number; + method?: string; + error?: string; +} + +export interface StatsLibraryDiskUsagePayload { + success: boolean; + has_data?: boolean; + total_bytes?: number; + tracks_with_size?: number; + tracks_without_size?: number; + by_format?: Record; + error?: string; +} + +export interface StatsResolveTrackPayload { + success: boolean; + error?: string; + track?: { + id: string | number; + title: string; + file_path: string; + bitrate?: string | number | null; + artist_id?: string | number | null; + album_id?: string | number | null; + image_url?: string | null; + album_title?: string | null; + artist_name?: string | null; + }; +} + +export interface StatsStreamTrackPayload { + success: boolean; + error?: string; + result?: Record; +} diff --git a/webui/src/routes/stats/-ui/stats-page.module.css b/webui/src/routes/stats/-ui/stats-page.module.css new file mode 100644 index 00000000..051fbc03 --- /dev/null +++ b/webui/src/routes/stats/-ui/stats-page.module.css @@ -0,0 +1,916 @@ +.statsContainer { + margin: 20px; + padding: 28px 24px 30px; + overflow: hidden; + background: linear-gradient(135deg, rgba(20, 20, 20, 0.55) 0%, rgba(12, 12, 12, 0.62) 100%); + border-radius: 24px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-top: 1px solid rgba(255, 255, 255, 0.12); + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.3), + 0 4px 16px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.statsHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin: -28px -24px 20px; + padding: 20px 24px; + min-height: 176px; + background: linear-gradient( + 180deg, + rgba(var(--accent-rgb), 0.1) 0%, + rgba(var(--accent-rgb), 0.04) 40%, + transparent 100% + ); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + border-top-left-radius: 24px; + border-top-right-radius: 24px; + position: relative; + overflow: hidden; + flex-wrap: wrap; +} + +.statsHeader::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(var(--accent-rgb), 0.08) 50%, + transparent 100% + ); + transform: translateX(-100%); + animation: headerSweep 12s ease-in-out infinite; +} + +@keyframes headerSweep { + 50% { + transform: translateX(100%); + } + 100% { + transform: translateX(100%); + } +} + +.statsHeaderTitle { + display: flex; + align-items: center; + gap: 14px; + position: relative; + z-index: 1; +} + +.headerIcon { + width: 176px; + height: 176px; + object-fit: contain; + flex-shrink: 0; + filter: drop-shadow(0 2px 8px rgba(var(--accent-rgb), 0.3)); + transition: + transform 0.3s ease, + filter 0.3s ease; +} + +.headerIcon:hover { + transform: scale(1.08) rotate(-3deg); + filter: drop-shadow(0 4px 14px rgba(var(--accent-rgb), 0.5)); +} + +.headerTitle { + margin: 0; + font-size: 28px; + font-weight: 700; + color: #fff; +} + +.statsHeaderControls { + display: flex; + align-items: center; + gap: 16px; + position: relative; + z-index: 1; +} + +.statsTimeRange { + display: flex; + gap: 4px; + padding: 3px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.statsRangeButton { + padding: 7px 16px; + border: none; + border-radius: 8px; + background: transparent; + color: rgba(255, 255, 255, 0.5); + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + font-family: inherit; +} + +.statsRangeButton:hover { + color: rgba(255, 255, 255, 0.8); + background: rgba(255, 255, 255, 0.04); +} + +.statsRangeButtonActive { + background: rgb(var(--accent-rgb)); + color: #fff; + box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.3); +} + +.statsSyncControls { + display: flex; + align-items: center; + gap: 8px; +} + +.statsLastSynced { + font-size: 0.72rem; + color: rgba(255, 255, 255, 0.3); +} + +.statsSyncButton { + width: 32px; + height: 32px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.5); + font-size: 16px; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.statsSyncButton:hover { + background: rgba(255, 255, 255, 0.08); + color: #fff; + border-color: rgba(255, 255, 255, 0.15); +} + +.statsSyncButtonSyncing { + pointer-events: none; + color: transparent; + position: relative; +} + +.statsSyncButtonSyncing::after { + content: ''; + position: absolute; + width: 14px; + height: 14px; + border: 2px solid rgba(var(--accent-rgb), 0.2); + border-top-color: rgba(var(--accent-rgb), 0.8); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.statsOverview { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 14px; + margin-bottom: 20px; +} + +.statsCard { + background: linear-gradient(135deg, rgba(20, 20, 20, 0.95) 0%, rgba(12, 12, 12, 0.98) 100%); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + padding: 20px; + text-align: center; + position: relative; + overflow: hidden; + transition: all 0.3s ease; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); +} + +.statsCard::before { + content: ''; + position: absolute; + top: 0; + left: 20%; + right: 20%; + height: 2px; + background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.5), transparent); +} + +.statsCard:hover { + transform: translateY(-3px); + border-color: rgba(var(--accent-rgb), 0.2); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} + +.statsCardValue { + font-size: 2rem; + font-weight: 700; + color: #fff; + line-height: 1.2; + margin-bottom: 6px; +} + +.statsCardLabel { + font-size: 0.78rem; + color: rgba(255, 255, 255, 0.45); + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 600; +} + +.statsMainGrid { + display: grid; + grid-template-columns: 1fr 360px; + gap: 20px; + min-width: 0; + margin-bottom: 20px; +} + +.statsLeftCol, +.statsRightCol { + display: flex; + flex-direction: column; + gap: 20px; + min-width: 0; +} + +.statsSectionCard { + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 14px; + padding: 20px; + transition: border-color 0.2s ease; + min-width: 0; + overflow: hidden; + margin-bottom: 20px; +} + +.statsSectionCard:hover { + border-color: rgba(255, 255, 255, 0.1); +} + +.statsSectionTitle { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: rgba(255, 255, 255, 0.4); + margin-bottom: 16px; + padding-bottom: 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.chartContainer { + position: relative; + height: 220px; +} + +.statsGenreChartContainer { + display: flex; + align-items: center; + gap: 24px; +} + +.statsGenreChartWrap { + width: 180px; + height: 180px; + flex-shrink: 0; +} + +.statsGenreLegend { + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; +} + +.statsGenreLegendItem { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.82rem; + color: rgba(255, 255, 255, 0.7); +} + +.statsGenreDot, +.statsDbLegendDot { + width: 10px; + height: 10px; + border-radius: 3px; + flex-shrink: 0; +} + +.statsGenrePct { + margin-left: auto; + color: rgba(255, 255, 255, 0.4); + font-variant-numeric: tabular-nums; +} + +.statsTopArtistsVisual { + margin-bottom: 16px; + padding-bottom: 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); +} + +.statsArtistBubbles { + display: flex; + justify-content: space-around; + align-items: flex-end; + gap: 8px; +} + +.statsArtistBubble { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + min-width: 0; + flex: 1; + background: transparent; + border: none; + padding: 0; + cursor: pointer; + transition: transform 0.2s ease; +} + +.statsArtistBubble:disabled { + cursor: default; +} + +.statsArtistBubble:hover { + transform: translateY(-3px); +} + +.statsBubbleImage { + border-radius: 50%; + background-size: cover; + background-position: center; + background-color: rgba(255, 255, 255, 0.06); + border: 2px solid rgba(var(--accent-rgb), 0.2); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.statsBubbleInitial { + font-size: 1.2rem; + font-weight: 700; + color: rgba(255, 255, 255, 0.4); +} + +.statsBubbleBarContainer { + width: 100%; + height: 3px; + background: rgba(255, 255, 255, 0.06); + border-radius: 2px; + overflow: hidden; +} + +.statsBubbleBar { + height: 100%; + background: linear-gradient(90deg, rgb(var(--accent-rgb)), rgba(var(--accent-rgb), 0.4)); + border-radius: 2px; +} + +.statsBubbleName { + font-size: 0.7rem; + color: rgba(255, 255, 255, 0.7); + font-weight: 500; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.statsBubbleCount { + font-size: 0.65rem; + color: rgba(var(--accent-rgb), 0.7); + font-weight: 600; +} + +.statsRankedList, +.statsRecentList { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 300px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.1) transparent; +} + +.statsRankedItem, +.statsRecentItem { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 8px; + transition: background 0.15s ease; +} + +.statsRankedItem:hover, +.statsRecentItem:hover { + background: rgba(255, 255, 255, 0.04); +} + +.statsRankedNum { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.25); + font-weight: 700; + width: 18px; + text-align: right; + flex-shrink: 0; +} + +.statsRankedImage, +.statsRankedImageFallback { + width: 36px; + height: 36px; + border-radius: 6px; + flex-shrink: 0; + background: rgba(255, 255, 255, 0.05); + object-fit: cover; +} + +.statsRankedInfo { + flex: 1; + min-width: 0; +} + +.statsRankedName { + font-size: 0.88rem; + color: rgba(255, 255, 255, 0.85); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + align-items: center; + gap: 6px; +} + +.statsRankedMeta { + font-size: 0.72rem; + color: rgba(255, 255, 255, 0.4); +} + +.statsRankedCount { + font-size: 0.78rem; + color: rgba(var(--accent-rgb), 0.8); + font-weight: 600; + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} + +.statsArtistLink { + color: inherit; + text-decoration: none; + background: transparent; + border: none; + padding: 0; + cursor: pointer; + font: inherit; +} + +.statsArtistLink:hover { + color: rgb(var(--accent-rgb)); +} + +.statsSoulIdBadge { + width: 12px; + height: 12px; + opacity: 0.5; +} + +.statsPlayButton { + width: 28px; + height: 28px; + border-radius: 50%; + border: none; + background: rgba(var(--accent-rgb), 0.15); + color: rgb(var(--accent-rgb)); + font-size: 10px; + cursor: pointer; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + opacity: 0; +} + +.statsRankedItem:hover .statsPlayButton, +.statsRecentItem:hover .statsPlayButton { + opacity: 1; +} + +.statsPlayButton:hover { + background: rgb(var(--accent-rgb)); + color: #fff; + transform: scale(1.1); +} + +.statsPlayButtonSmall { + width: 22px; + height: 22px; + font-size: 8px; +} + +.statsRecentTitle { + flex: 1; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.8); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.statsRecentArtist { + font-size: 0.78rem; + color: rgba(255, 255, 255, 0.4); + flex-shrink: 0; +} + +.statsRecentTime { + font-size: 0.72rem; + color: rgba(255, 255, 255, 0.25); + flex-shrink: 0; + min-width: 65px; + text-align: right; +} + +.statsHealthGrid { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + gap: 16px; + align-items: start; +} + +.statsHealthItem { + text-align: center; +} + +.statsHealthItemWide { + text-align: left; +} + +.statsHealthValue { + font-size: 1.6rem; + font-weight: 700; + color: #fff; + line-height: 1.2; + margin-bottom: 4px; +} + +.statsHealthLabel { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.4); + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 600; +} + +.statsFormatBar { + display: flex; + height: 28px; + border-radius: 8px; + overflow: hidden; + margin-top: 8px; + background: rgba(255, 255, 255, 0.04); +} + +.statsFormatSegment { + display: flex; + align-items: center; + justify-content: center; + font-size: 0.68rem; + font-weight: 600; + color: #fff; + white-space: nowrap; + min-width: 30px; +} + +.statsEnrichment { + display: flex; + gap: 16px; + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid rgba(255, 255, 255, 0.04); + flex-wrap: wrap; +} + +.statsEnrichItem { + flex: 1; + min-width: 120px; + display: flex; + align-items: center; + gap: 8px; +} + +.statsEnrichName { + font-size: 0.72rem; + color: rgba(255, 255, 255, 0.45); + min-width: 70px; + font-weight: 500; +} + +.statsEnrichBar { + flex: 1; + height: 4px; + background: rgba(255, 255, 255, 0.06); + border-radius: 2px; + overflow: hidden; +} + +.statsEnrichFill { + height: 100%; + border-radius: 2px; +} + +.statsEnrichPct { + font-size: 0.72rem; + color: rgba(255, 255, 255, 0.4); + font-variant-numeric: tabular-nums; + min-width: 30px; + text-align: right; +} + +.statsDiskUsageWrap { + display: flex; + flex-direction: column; + gap: 14px; + margin-top: 8px; +} + +.statsDiskTotalRow { + display: flex; + align-items: baseline; + gap: 16px; + flex-wrap: wrap; +} + +.statsDiskTotalValue { + font-size: 28px; + font-weight: 700; + color: rgb(var(--accent-rgb)); +} + +.statsDiskTotalMeta { + font-size: 12px; + color: rgba(255, 255, 255, 0.55); +} + +.statsDiskFormats { + display: flex; + flex-direction: column; + gap: 6px; +} + +.statsDiskFormatRow { + display: grid; + grid-template-columns: 60px 1fr 80px; + align-items: center; + gap: 10px; + font-size: 12px; +} + +.statsDiskFormatName { + font-weight: 600; + color: rgba(255, 255, 255, 0.8); +} + +.statsDiskFormatBar { + height: 8px; + background: rgba(255, 255, 255, 0.05); + border-radius: 4px; + overflow: hidden; +} + +.statsDiskFormatFill { + height: 100%; + background: linear-gradient(90deg, rgb(var(--accent-rgb)) 0%, rgba(var(--accent-rgb), 0.6) 100%); + border-radius: 4px; +} + +.statsDiskFormatSize { + text-align: right; + color: rgba(255, 255, 255, 0.55); + font-variant-numeric: tabular-nums; +} + +.statsDbStorageWrap { + display: flex; + align-items: center; + gap: 24px; + margin-top: 8px; +} + +.statsDbChartContainer { + position: relative; + width: 180px; + height: 180px; + flex-shrink: 0; + isolation: isolate; +} + +.statsDbTotal { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + pointer-events: none; + z-index: 1; +} + +.statsDbTotalValue { + font-size: 20px; + font-weight: 700; + color: rgba(255, 255, 255, 0.85); + text-align: center; +} + +.statsDbTotalLabel { + font-size: 10px; + color: rgba(255, 255, 255, 0.4); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.statsDbLegend { + flex: 1; + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; +} + +.statsDbLegendItem { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: rgba(255, 255, 255, 0.6); +} + +.statsDbLegendName { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.statsDbLegendSize { + font-variant-numeric: tabular-nums; + color: rgba(255, 255, 255, 0.4); + font-size: 11px; +} + +.statsEmpty, +.statsLoading { + text-align: center; + padding: 80px 20px; + color: rgba(255, 255, 255, 0.5); +} + +.statsEmptyIcon { + font-size: 48px; + margin-bottom: 16px; +} + +.statsEmpty h3 { + font-size: 1.2rem; + color: rgba(255, 255, 255, 0.7); + margin-bottom: 8px; +} + +.statsEmpty p, +.statsSubtleError, +.emptyListState { + font-size: 0.88rem; + line-height: 1.5; + color: rgba(255, 255, 255, 0.5); +} + +.emptyListState { + padding: 12px; +} + +.statsSubtleError { + padding: 12px 0; +} + +@media (max-width: 768px) { + .statsContainer { + margin: 8px; + padding: 12px; + border-radius: 16px; + } + + .statsHeader { + flex-direction: column; + align-items: flex-start; + padding: 12px; + margin: -12px -12px 20px; + min-height: 0; + gap: 10px; + border-top-left-radius: 16px; + border-top-right-radius: 16px; + } + + .statsHeaderControls { + width: 100%; + flex-direction: column; + align-items: stretch; + } + + .statsTimeRange { + width: 100%; + } + + .statsRangeButton { + padding: 6px 12px; + font-size: 11px; + flex: 1; + text-align: center; + } + + .statsSyncControls { + width: 100%; + justify-content: space-between; + } + + .headerIcon { + width: 100px; + height: 100px; + } + + .statsOverview { + grid-template-columns: repeat(2, 1fr); + gap: 8px; + } + + .statsCard { + padding: 12px 10px; + border-radius: 10px; + } + + .statsCardValue { + font-size: 18px; + } + + .statsCardLabel { + font-size: 9px; + } + + .statsMainGrid { + grid-template-columns: 1fr; + gap: 14px; + } + + .statsLeftCol, + .statsRightCol { + gap: 14px; + } + + .statsSectionCard { + padding: 12px; + } + + .chartContainer { + height: 160px; + } + + .statsGenreChartContainer, + .statsDbStorageWrap { + flex-direction: column; + align-items: center; + } + + .statsHealthGrid { + grid-template-columns: 1fr 1fr; + } +} diff --git a/webui/src/routes/stats/-ui/stats-page.tsx b/webui/src/routes/stats/-ui/stats-page.tsx new file mode 100644 index 00000000..1b162df6 --- /dev/null +++ b/webui/src/routes/stats/-ui/stats-page.tsx @@ -0,0 +1,907 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import { type ReactNode, useEffect, useRef, useState } from 'react'; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import { useReactPageShell } from '@/platform/shell/route-controllers'; + +import type { + StatsAlbumRow, + StatsArtistRow, + StatsDbStoragePayload, + StatsHealth, + StatsLibraryDiskUsagePayload, + StatsRange, + StatsRecentTrack, + StatsTrackRow, +} from '../-stats.types'; + +import { + invalidateStatsQueries, + listeningStatsStatusQueryOptions, + resolveStatsTrack, + statsCachedQueryOptions, + statsDbStorageQueryOptions, + statsLibraryDiskUsageQueryOptions, + streamStatsTrack, + triggerListeningStatsSync, +} from '../-stats.api'; +import { + EMPTY_STATS_OVERVIEW, + formatBytes, + formatCompactNumber, + formatDbStorageValue, + formatListeningTime, + formatRelativePlayedAt, + formatTotalDuration, + getStatsRangeLabel, + getTopArtistBubbles, + groupDbStorageTables, + hasStatsData, + STATS_DB_STORAGE_COLORS, + STATS_ENRICHMENT_SERVICES, + STATS_GENRE_COLORS, +} from '../-stats.helpers'; +import { Route } from '../route'; +import styles from './stats-page.module.css'; + +const STATS_TOOLTIP_STYLE = { + background: 'rgba(12, 12, 12, 0.96)', + border: '1px solid rgba(255,255,255,0.08)', + borderRadius: 10, + color: '#fff', +} as const; + +const STATS_TOOLTIP_WRAPPER_STYLE = { + zIndex: 3, +} as const; + +const STATS_CHART_CURSOR = { + fill: 'rgba(var(--accent-rgb), 0.12)', +} as const; + +export function StatsPage() { + useReactPageShell('stats'); + + const navigate = useNavigate({ from: Route.fullPath }); + const queryClient = useQueryClient(); + const { range } = Route.useSearch(); + const syncTimeoutRef = useRef(null); + const [syncing, setSyncing] = useState(false); + + const cachedStatsQuery = useQuery({ + ...statsCachedQueryOptions(range), + }); + const listeningStatusQuery = useQuery({ + ...listeningStatsStatusQueryOptions(), + }); + const dbStorageQuery = useQuery({ + ...statsDbStorageQueryOptions(), + }); + const diskUsageQuery = useQuery({ + ...statsLibraryDiskUsageQueryOptions(), + }); + + useEffect(() => { + return () => { + if (syncTimeoutRef.current) { + window.clearTimeout(syncTimeoutRef.current); + } + }; + }, []); + + const syncMutation = useMutation({ + mutationFn: triggerListeningStatsSync, + onMutate: () => { + setSyncing(true); + }, + onSuccess: () => { + window.showToast?.('Syncing listening data...', 'info'); + syncTimeoutRef.current = window.setTimeout(() => { + void invalidateStatsQueries(queryClient); + setSyncing(false); + window.showToast?.('Listening stats updated', 'success'); + }, 5000); + }, + onError: (error) => { + setSyncing(false); + window.showToast?.(error instanceof Error ? error.message : 'Sync failed', 'error'); + }, + }); + + const cachedStats = cachedStatsQuery.data; + const overview = cachedStats?.overview ?? EMPTY_STATS_OVERVIEW; + const hasData = hasStatsData(overview); + const lastSynced = listeningStatusQuery.data?.stats?.last_poll ?? null; + + const onRangeChange = (nextRange: StatsRange) => { + void navigate({ + to: Route.fullPath, + search: { range: nextRange }, + replace: true, + }); + }; + + return ( +
+
+
+ Stats +

Listening Stats

+
+
+
+ {(['7d', '30d', '12m', 'all'] as const).map((option) => ( + + ))} +
+
+ + {lastSynced ? `Last synced: ${lastSynced}` : 'Not synced yet'} + + +
+
+
+ + {cachedStatsQuery.isPending ? ( + + ) : cachedStatsQuery.error ? ( + + ) : hasData ? ( + <> + +
+
+ +
+ +
+
+ +
+
+ +
+ +
+
+ + + +
+
+ + + + + + + + + + +
+
+ + + + + + + + + + + + + + ) : ( + + )} +
+ ); +} + +function OverviewCards({ + overview, +}: { + overview: Partial<{ + total_plays: number; + total_time_ms: number; + unique_artists: number; + unique_albums: number; + unique_tracks: number; + }>; +}) { + const cards = [ + { label: 'Total Plays', value: formatCompactNumber(overview.total_plays) }, + { label: 'Listening Time', value: formatListeningTime(overview.total_time_ms) }, + { label: 'Artists', value: formatCompactNumber(overview.unique_artists) }, + { label: 'Albums', value: formatCompactNumber(overview.unique_albums) }, + { label: 'Tracks', value: formatCompactNumber(overview.unique_tracks) }, + ]; + + return ( +
+ {cards.map((card) => ( +
+
{card.value}
+
{card.label}
+
+ ))} +
+ ); +} + +function StatsSectionCard({ + children, + fullWidth = false, + title, +}: { + children: ReactNode; + fullWidth?: boolean; + title: string; +}) { + return ( +
+
{title}
+ {children} +
+ ); +} + +function StatsActivityChart({ timeline }: { timeline: Array<{ date: string; plays: number }> }) { + return ( + + + + + + + + + + ); +} + +function StatsGenreChart({ + genres, +}: { + genres: Array<{ genre: string; play_count: number; percentage: number }>; +}) { + const topGenres = genres.slice(0, 10); + return ( + + + + {topGenres.map((genre, index) => ( + + ))} + + + + + ); +} + +function StatsGenreLegend({ + genres, +}: { + genres: Array<{ genre: string; play_count: number; percentage: number }>; +}) { + const topGenres = genres.slice(0, 10); + + return ( +
+ {topGenres.map((genre, index) => ( +
+ + {genre.genre} + {genre.percentage}% +
+ ))} +
+ ); +} + +function TopArtistsVisual({ + artists, + onArtistSelect, +}: { + artists: StatsArtistRow[]; + onArtistSelect: (artistId: string | number, artistName: string) => void; +}) { + const topArtists = getTopArtistBubbles(artists); + if (topArtists.length === 0) return null; + + return ( +
+
+ {topArtists.map(({ artist, percent, size }) => { + const isClickable = artist.id !== null && artist.id !== undefined; + return ( + + ); + })} +
+
+ ); +} + +function StatsRankedArtists({ + artists, + onArtistSelect, +}: { + artists: StatsArtistRow[]; + onArtistSelect: (artistId: string | number, artistName: string) => void; +}) { + return ( +
+ {artists.length === 0 ? : null} + {artists.map((artist, index) => ( +
+ {index + 1} + {artist.image_url ? ( + + ) : ( +
+ )} +
+
+ {artist.id ? ( + + ) : ( + artist.name + )} + {artist.soul_id && !String(artist.soul_id).startsWith('soul_unnamed_') ? ( + SoulID + ) : null} +
+
+ {artist.global_listeners + ? `${formatCompactNumber(artist.global_listeners)} global listeners` + : ''} +
+
+ + {formatCompactNumber(artist.play_count)} plays + +
+ ))} +
+ ); +} + +function StatsRankedAlbums({ + albums, + onArtistSelect, +}: { + albums: StatsAlbumRow[]; + onArtistSelect: (artistId: string | number, artistName: string) => void; +}) { + return ( +
+ {albums.length === 0 ? : null} + {albums.map((album, index) => ( +
+ {index + 1} + {album.image_url ? ( + + ) : ( +
+ )} +
+
{album.name}
+
+ {album.artist_id ? ( + + ) : ( + album.artist || '' + )} +
+
+ + {formatCompactNumber(album.play_count)} plays + +
+ ))} +
+ ); +} + +function StatsRankedTracks({ + tracks, + onArtistSelect, + onPlay, +}: { + tracks: StatsTrackRow[]; + onArtistSelect: (artistId: string | number, artistName: string) => void; + onPlay: (track: { title: string; artist: string; album: string }) => Promise; +}) { + return ( +
+ {tracks.length === 0 ? : null} + {tracks.map((track, index) => ( +
+ {index + 1} + {track.image_url ? ( + + ) : ( +
+ )} +
+
{track.name}
+
+ {track.artist_id ? ( + + ) : ( + track.artist || '' + )} + {track.album ? ` Β· ${track.album}` : ''} +
+
+ + + {formatCompactNumber(track.play_count)} plays + +
+ ))} +
+ ); +} + +function StatsRecentPlays({ + tracks, + onPlay, +}: { + tracks: StatsRecentTrack[]; + onPlay: (track: { title: string; artist: string; album: string }) => Promise; +}) { + return ( +
+ {tracks.length === 0 ? : null} + {tracks.map((track, index) => ( +
+ + {track.title} + {track.artist || ''} + {formatRelativePlayedAt(track.played_at)} +
+ ))} +
+ ); +} + +function StatsLibraryHealth({ health }: { health: StatsHealth }) { + const totalTracks = health.total_tracks ?? 0; + const formatEntries = Object.entries(health.format_breakdown ?? {}); + const formatTotal = formatEntries.reduce((sum, [, count]) => sum + count, 0) || 1; + const formatColors: Record = { + FLAC: '#3b82f6', + MP3: '#f97316', + Opus: '#a855f7', + AAC: '#14b8a6', + OGG: '#eab308', + WAV: '#ec4899', + Other: '#555555', + }; + + return ( + <> +
+
+
Format Breakdown
+
+ {formatEntries.map(([format, count]) => { + const percentage = ((count / formatTotal) * 100).toFixed(1); + return ( +
+ {Number(percentage) > 8 ? format : ''} +
+ ); + })} +
+
+
+
+ {formatCompactNumber(health.unplayed_count)} ({health.unplayed_percentage || 0}%) +
+
Unplayed Tracks
+
+
+
+ {formatTotalDuration(health.total_duration_ms)} +
+
Total Duration
+
+
+
{formatCompactNumber(totalTracks)}
+
Total Tracks
+
+
+
+ {STATS_ENRICHMENT_SERVICES.map((service) => { + const percent = health.enrichment_coverage?.[service.key] || 0; + return ( +
+ {service.label} +
+
+
+ {percent}% +
+ ); + })} +
+ + ); +} + +function StatsDiskUsage({ + error, + payload, +}: { + error: unknown; + payload: StatsLibraryDiskUsagePayload | undefined; +}) { + if (error) { + return ; + } + + const hasData = payload?.has_data && !!payload.total_bytes; + const formats = Object.entries(payload?.by_format ?? {}).sort((a, b) => b[1] - a[1]); + const max = formats[0]?.[1] || 1; + const tracksWithSize = payload?.tracks_with_size || 0; + const tracksWithoutSize = payload?.tracks_without_size || 0; + + return ( +
+
+
+ {hasData ? formatBytes(payload?.total_bytes) : 'β€”'} +
+
+ {hasData + ? `${tracksWithSize.toLocaleString()} tracks measured${ + tracksWithoutSize > 0 + ? ` (+${tracksWithoutSize.toLocaleString()} pending next Deep Scan)` + : '' + }` + : tracksWithoutSize > 0 + ? `Run a Deep Scan to populate (${tracksWithoutSize.toLocaleString()} tracks pending)` + : 'No tracks in library yet'} +
+
+
+ {formats.map(([format, bytes]) => { + const width = Math.max(2, Math.round((bytes / max) * 100)); + return ( +
+ {format.toUpperCase()} +
+
+
+ {formatBytes(bytes)} +
+ ); + })} +
+
+ ); +} + +function StatsDbStorage({ + error, + payload, +}: { + error: unknown; + payload: StatsDbStoragePayload | undefined; +}) { + if (error) { + return ; + } + + const tables = groupDbStorageTables(payload?.tables ?? []); + const method = payload?.method; + + return ( +
+
+ + + + {tables.map((table, index) => ( + + ))} + + + + +
+
+ {formatDbStorageValue(payload?.total_file_size || 0, method)} +
+
Total Size
+
+
+
+ {tables.map((table, index) => ( +
+ + {table.name} + + {formatDbStorageValue(table.size, method)} + +
+ ))} +
+
+ ); +} + +function StatsEmptyState() { + return ( +
+
πŸ“Š
+

No Listening Data Yet

+

+ Enable "Listening Stats" in Settings to start tracking your listening activity + from your media server. +

+
+ ); +} + +function SectionLoadingState() { + return
Loading listening stats...
; +} + +function SectionErrorState({ message }: { message: string }) { + return ( +
+

Failed to load listening stats

+

{message}

+
+ ); +} + +function SectionSubtleError({ message }: { message: string }) { + return
{message}
; +} + +function EmptyListState({ message }: { message: string }) { + return
{message}
; +} + +async function openArtistDetail(artistId: string | number, artistName: string) { + await window.SoulSyncWebRouter?.navigateToPage('library'); + window.setTimeout(() => { + window.navigateToArtistDetail?.(artistId, artistName); + }, 300); +} + +async function playStatsTrack(track: { title: string; artist: string; album: string }) { + const resolvedTrack = await resolveStatsTrack(track.title, track.artist); + if (resolvedTrack) { + void window.playLibraryTrack?.( + { + id: resolvedTrack.id, + title: resolvedTrack.title, + file_path: resolvedTrack.file_path, + bitrate: resolvedTrack.bitrate, + artist_id: resolvedTrack.artist_id, + album_id: resolvedTrack.album_id, + _stats_image: resolvedTrack.image_url || null, + }, + resolvedTrack.album_title || track.album, + resolvedTrack.artist_name || track.artist, + ); + return; + } + + window.showLoadingOverlay?.(`Searching for ${track.title}...`); + try { + const streamResult = await streamStatsTrack(track.title, track.artist, track.album); + window.hideLoadingOverlay?.(); + + if (streamResult) { + if (typeof window.startStream === 'function') { + await window.startStream(streamResult); + } else { + window.showToast?.('Streaming not available', 'error'); + } + return; + } + + window.showToast?.('Track not found in library or any source', 'error'); + } catch (error) { + window.hideLoadingOverlay?.(); + window.showToast?.(getErrorMessage(error), 'error'); + } +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Unknown error'; +} diff --git a/webui/src/routes/stats/route.tsx b/webui/src/routes/stats/route.tsx new file mode 100644 index 00000000..364eb651 --- /dev/null +++ b/webui/src/routes/stats/route.tsx @@ -0,0 +1,28 @@ +import { createFileRoute, redirect } from '@tanstack/react-router'; + +import { getProfileHomePath } from '@/platform/shell/bridge'; + +import { listeningStatsStatusQueryOptions, statsCachedQueryOptions } from './-stats.api'; +import { statsSearchSchema } from './-stats.types'; +import { StatsPage } from './-ui/stats-page'; + +export const Route = createFileRoute('/stats')({ + validateSearch: statsSearchSchema, + beforeLoad: ({ context }) => { + const { bridge } = context.shell; + + if (!bridge.isPageAllowed('stats')) { + throw redirect({ href: getProfileHomePath(bridge), replace: true }); + } + }, + loaderDeps: ({ search }) => ({ + range: search.range, + }), + loader: async ({ context, deps }) => { + await Promise.all([ + context.queryClient.ensureQueryData(statsCachedQueryOptions(deps.range)), + context.queryClient.ensureQueryData(listeningStatsStatusQueryOptions()), + ]); + }, + component: StatsPage, +}); From 5b82e6c1ba7383f10ebdb4cf0ff403cb24c0c23d Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Thu, 14 May 2026 19:40:59 +0300 Subject: [PATCH 09/26] refactor(webui): remove legacy stats page assets - delete the old stats page HTML, JS, and CSS now that the React route owns the experience - preserve helper/tour selectors by exposing the legacy stats ids from the React page - move shared track playback fallback into library code --- webui/index.html | 141 ---- webui/src/routes/stats/-ui/stats-page.tsx | 32 +- webui/static/helper.js | 2 +- webui/static/init.js | 3 - webui/static/library.js | 68 +- webui/static/mobile.css | 317 -------- webui/static/stats-automations.js | 608 --------------- webui/static/style.css | 881 ---------------------- 8 files changed, 86 insertions(+), 1966 deletions(-) diff --git a/webui/index.html b/webui/index.html index 3d4c6b63..7ff29dc5 100644 --- a/webui/index.html +++ b/webui/index.html @@ -6184,147 +6184,6 @@
- -
-
-
-
- Stats -

Listening Stats

-
-
-
- - - - -
-
- - -
-
-
- - -
-
-
0
-
Total Plays
-
-
-
0h
-
Listening Time
-
-
-
0
-
Artists
-
-
-
0
-
Albums
-
-
-
0
-
Tracks
-
-
- - -
-
-
-
Listening Activity
-
- -
-
-
-
Genre Breakdown
-
- -
-
-
-
-
Recently Played
-
-
-
-
-
-
Top Artists
-
-
-
-
-
Top Albums
-
-
-
-
Top Tracks
-
-
-
-
- - -
-
Library Health
-
-
-
Format Breakdown
-
-
-
-
0
-
Unplayed Tracks
-
-
-
0h
-
Total Duration
-
-
-
0
-
Total Tracks
-
-
-
-
- - -
-
Library Disk Usage
-
-
-
β€”
-
Run a Deep Scan to populate
-
-
-
-
- - -
-
Database Storage
-
-
- -
-
-
-
-
- - - -
-
-
diff --git a/webui/src/routes/stats/-ui/stats-page.tsx b/webui/src/routes/stats/-ui/stats-page.tsx index 1b162df6..1ab9654a 100644 --- a/webui/src/routes/stats/-ui/stats-page.tsx +++ b/webui/src/routes/stats/-ui/stats-page.tsx @@ -134,14 +134,19 @@ export function StatsPage() { }; return ( -
+
Stats

Listening Stats

-
+
{(['7d', '30d', '12m', 'all'] as const).map((option) => ( - ${_fmt(item.play_count)} plays -
- `); - - // Timeline chart - _renderTimelineChart(data.timeline || []); - - // Genre chart - _renderGenreChart(data.genres || []); - - // Library health - _renderLibraryHealth(data.health || {}); - - // DB storage chart (separate fetch β€” not part of cached stats) - _loadDbStorageChart(); - // Library disk usage (separate fetch β€” populated by deep scan) - _loadLibraryDiskUsage(); - - // Recent plays - _renderRecentPlays(data.recent || []); -} - -function _renderTopArtistsVisual(artists) { - const el = document.getElementById('stats-top-artists-visual'); - if (!el || !artists.length) { if (el) el.innerHTML = ''; return; } - - const top5 = artists.slice(0, 5); - const maxPlays = top5[0]?.play_count || 1; - const _fmt = (n) => { - if (!n) return '0'; - if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; - if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K'; - return n.toString(); - }; - - el.innerHTML = `
- ${top5.map((a, i) => { - const pct = Math.round((a.play_count / maxPlays) * 100); - const size = 44 + (4 - i) * 6; // Largest first: 68, 62, 56, 50, 44 - return ` -
- ${!a.image_url ? `${(a.name || '?')[0]}` : ''} -
-
-
-
-
${_esc(a.name)}
-
${_fmt(a.play_count)}
-
`; - }).join('')} -
`; -} - -function _setText(id, text) { - const el = document.getElementById(id); - if (el) el.textContent = text; -} - -function _renderRankedList(containerId, items, template) { - const el = document.getElementById(containerId); - if (!el) return; - el.innerHTML = items.length - ? items.map((item, i) => template(item, i)).join('') - : '
No data yet
'; -} - -function _renderTimelineChart(data) { - const canvas = document.getElementById('stats-timeline-chart'); - if (!canvas || typeof Chart === 'undefined') return; - - if (_statsTimelineChart) _statsTimelineChart.destroy(); - - _statsTimelineChart = new Chart(canvas, { - type: 'bar', - data: { - labels: data.map(d => d.date), - datasets: [{ - label: 'Plays', - data: data.map(d => d.plays), - backgroundColor: `rgba(${getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '29,185,84'}, 0.5)`, - borderColor: `rgba(${getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '29,185,84'}, 0.8)`, - borderWidth: 1, - borderRadius: 4, - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { legend: { display: false } }, - scales: { - x: { grid: { display: false }, ticks: { color: 'rgba(255,255,255,0.3)', font: { size: 10 }, maxTicksLimit: 12 } }, - y: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: 'rgba(255,255,255,0.3)', font: { size: 10 } }, beginAtZero: true }, - } - } - }); -} - -function _renderGenreChart(data) { - const canvas = document.getElementById('stats-genre-chart'); - const legend = document.getElementById('stats-genre-legend'); - if (!canvas || typeof Chart === 'undefined') return; - - if (_statsGenreChart) _statsGenreChart.destroy(); - - const colors = [ - '#1db954', '#1ed760', '#4ade80', '#7c3aed', '#a855f7', - '#ec4899', '#f43f5e', '#f97316', '#eab308', '#06b6d4', - '#3b82f6', '#6366f1', '#14b8a6', '#84cc16', '#f59e0b', - ]; - - const top = data.slice(0, 10); - - _statsGenreChart = new Chart(canvas, { - type: 'doughnut', - data: { - labels: top.map(g => g.genre), - datasets: [{ - data: top.map(g => g.play_count), - backgroundColor: colors.slice(0, top.length), - borderWidth: 0, - hoverOffset: 6, - }] - }, - options: { - responsive: true, - maintainAspectRatio: true, - cutout: '65%', - plugins: { legend: { display: false } }, - } - }); - - if (legend) { - legend.innerHTML = top.map((g, i) => ` -
- - ${g.genre} - ${g.percentage}% -
- `).join(''); - } -} - -function _renderLibraryHealth(data) { - if (!data || !data.total_tracks) return; - - const _fmt = (n) => { - if (!n) return '0'; - if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'; - if (n >= 1000) return (n / 1000).toFixed(1) + 'K'; - return n.toLocaleString(); - }; - - _setText('stats-unplayed', `${_fmt(data.unplayed_count)} (${data.unplayed_percentage || 0}%)`); - _setText('stats-total-duration', data.total_duration_ms ? `${Math.floor(data.total_duration_ms / 3600000)}h` : '0h'); - _setText('stats-total-tracks-count', _fmt(data.total_tracks)); - - // Format bar - const bar = document.getElementById('stats-format-bar'); - if (bar && data.format_breakdown) { - const total = Object.values(data.format_breakdown).reduce((s, v) => s + v, 0) || 1; - const fmtColors = { FLAC: '#3b82f6', MP3: '#f97316', Opus: '#a855f7', AAC: '#14b8a6', OGG: '#eab308', WAV: '#ec4899', Other: '#555' }; - - bar.innerHTML = Object.entries(data.format_breakdown).map(([fmt, count]) => { - const pct = (count / total * 100).toFixed(1); - return `
${pct > 8 ? fmt : ''}
`; - }).join(''); - } - - // Enrichment coverage - const enrichEl = document.getElementById('stats-enrichment-coverage'); - if (enrichEl && data.enrichment_coverage) { - const ec = data.enrichment_coverage; - const services = [ - { name: 'Spotify', pct: ec.spotify || 0, color: '#1db954' }, - { name: 'MusicBrainz', pct: ec.musicbrainz || 0, color: '#ba55d3' }, - { name: 'Deezer', pct: ec.deezer || 0, color: '#a238ff' }, - { name: 'Last.fm', pct: ec.lastfm || 0, color: '#d51007' }, - { name: 'iTunes', pct: ec.itunes || 0, color: '#fc3c44' }, - { name: 'AudioDB', pct: ec.audiodb || 0, color: '#1a9fff' }, - { name: 'Genius', pct: ec.genius || 0, color: '#ffff64' }, - { name: 'Tidal', pct: ec.tidal || 0, color: '#00ffff' }, - { name: 'Qobuz', pct: ec.qobuz || 0, color: '#4285f4' }, - ]; - enrichEl.innerHTML = services.map(s => ` -
- ${s.name} -
- ${s.pct}% -
- `).join(''); - } -} - -async function _loadDbStorageChart() { - try { - const resp = await fetch('/api/stats/db-storage'); - const data = await resp.json(); - if (!data.success || !data.tables || !data.tables.length) return; - _renderDbStorageChart(data.tables, data.total_file_size, data.method); - } catch (e) { - console.debug('DB storage chart load failed:', e); - } -} - -async function _loadLibraryDiskUsage() { - try { - const resp = await fetch('/api/stats/library-disk-usage'); - const data = await resp.json(); - if (!data.success) return; - _renderLibraryDiskUsage(data); - } catch (e) { - console.debug('Library disk usage load failed:', e); - } -} - -function _formatBytes(n) { - if (!n || n <= 0) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB', 'TB']; - let i = 0; - let v = n; - while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } - return `${v.toFixed(v < 10 ? 2 : 1)} ${units[i]}`; -} - -function _renderLibraryDiskUsage(data) { - const totalEl = document.getElementById('stats-disk-total-value'); - const metaEl = document.getElementById('stats-disk-total-meta'); - const formatsEl = document.getElementById('stats-disk-formats'); - if (!totalEl || !metaEl || !formatsEl) return; - - if (!data.has_data || !data.total_bytes) { - totalEl.textContent = 'β€”'; - metaEl.textContent = data.tracks_without_size > 0 - ? `Run a Deep Scan to populate (${data.tracks_without_size.toLocaleString()} tracks pending)` - : 'No tracks in library yet'; - formatsEl.innerHTML = ''; - return; - } - - totalEl.textContent = _formatBytes(data.total_bytes); - - const withSize = data.tracks_with_size || 0; - const withoutSize = data.tracks_without_size || 0; - const trackBits = `${withSize.toLocaleString()} tracks measured`; - const pendingBits = withoutSize > 0 - ? ` (+${withoutSize.toLocaleString()} pending next Deep Scan)` - : ''; - metaEl.textContent = trackBits + pendingBits; - - // Per-format bars sorted by size descending. Skip if no breakdown. - const formats = Object.entries(data.by_format || {}).sort((a, b) => b[1] - a[1]); - if (!formats.length) { formatsEl.innerHTML = ''; return; } - - const max = formats[0][1] || 1; - formatsEl.innerHTML = formats.map(([ext, bytes]) => { - const pct = Math.max(2, Math.round((bytes / max) * 100)); - return ` -
- ${ext.toUpperCase()} -
-
-
- ${_formatBytes(bytes)} -
- `; - }).join(''); -} - -function _renderDbStorageChart(tables, totalFileSize, method) { - const canvas = document.getElementById('stats-db-storage-chart'); - if (!canvas || typeof Chart === 'undefined') return; - - if (_statsDbStorageChart) _statsDbStorageChart.destroy(); - - // Top 8 tables, group rest as "Other" - const top = tables.slice(0, 8); - const rest = tables.slice(8); - const restSize = rest.reduce((s, t) => s + t.size, 0); - if (restSize > 0) top.push({ name: 'Other', size: restSize }); - - const colors = ['#3b82f6', '#f97316', '#a855f7', '#14b8a6', '#eab308', '#ec4899', '#6366f1', '#22c55e', '#555']; - - _statsDbStorageChart = new Chart(canvas, { - type: 'doughnut', - data: { - labels: top.map(t => t.name), - datasets: [{ - data: top.map(t => t.size), - backgroundColor: colors.slice(0, top.length), - borderWidth: 0, - hoverOffset: 4, - }], - }, - options: { - responsive: false, - cutout: '65%', - plugins: { - legend: { display: false }, - tooltip: { - callbacks: { - label: (ctx) => { - const val = ctx.parsed; - if (method === 'dbstat') { - if (val > 1048576) return ` ${(val / 1048576).toFixed(1)} MB`; - return ` ${(val / 1024).toFixed(0)} KB`; - } - return ` ${val.toLocaleString()} rows`; - } - } - } - }, - }, - }); - - // Center label β€” total file size - const totalEl = document.getElementById('stats-db-total'); - if (totalEl) { - let sizeStr; - if (totalFileSize > 1073741824) sizeStr = (totalFileSize / 1073741824).toFixed(2) + ' GB'; - else if (totalFileSize > 1048576) sizeStr = (totalFileSize / 1048576).toFixed(1) + ' MB'; - else sizeStr = (totalFileSize / 1024).toFixed(0) + ' KB'; - totalEl.innerHTML = `
${sizeStr}
Total Size
`; - } - - // Legend - const legendEl = document.getElementById('stats-db-legend'); - if (legendEl) { - legendEl.innerHTML = top.map((t, i) => { - let sizeLabel; - if (method === 'dbstat') { - if (t.size > 1048576) sizeLabel = (t.size / 1048576).toFixed(1) + ' MB'; - else sizeLabel = (t.size / 1024).toFixed(0) + ' KB'; - } else { - sizeLabel = t.size.toLocaleString() + ' rows'; - } - return `
- - ${t.name} - ${sizeLabel} -
`; - }).join(''); - } -} - -async function playStatsTrack(title, artist, album) { - // 1. Try the library first β€” fastest and best quality if owned. - try { - const resp = await fetch('/api/stats/resolve-track', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title, artist }), - }); - const data = await resp.json(); - if (data.success && data.track) { - const t = data.track; - playLibraryTrack({ - id: t.id, - title: t.title, - file_path: t.file_path, - bitrate: t.bitrate, - artist_id: t.artist_id, - album_id: t.album_id, - _stats_image: t.image_url || null, - }, t.album_title || album || '', t.artist_name || artist || ''); - return; - } - } catch (e) { - console.debug('Library resolve failed, will try streaming fallback:', e); - } - - // 2. Library miss β€” fall back to streaming via the enhanced-search streamer - // (Soulseek β†’ YouTube β†’ other configured sources, same pipeline used by - // the search results' play button). - if (typeof showLoadingOverlay === 'function') { - showLoadingOverlay(`Searching for ${title}...`); - } - try { - const streamResp = await fetch('/api/enhanced-search/stream-track', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - track_name: title, - artist_name: artist, - album_name: album || '', - duration_ms: 0, - }), - }); - const streamData = await streamResp.json(); - if (typeof hideLoadingOverlay === 'function') hideLoadingOverlay(); - - if (streamData.success && streamData.result) { - if (typeof startStream === 'function') { - await startStream(streamData.result); - } else { - showToast('Streaming not available', 'error'); - } - } else { - showToast(streamData.error || 'Track not found in library or any source', 'error'); - } - } catch (e) { - if (typeof hideLoadingOverlay === 'function') hideLoadingOverlay(); - showToast('Failed to play track', 'error'); - console.error('Stream fallback failed:', e); - } -} - -function _renderRecentPlays(tracks) { - const el = document.getElementById('stats-recent-plays'); - if (!el) return; - - if (!tracks.length) { - el.innerHTML = '
No recent plays
'; - return; - } - - const _ago = (dateStr) => { - if (!dateStr) return ''; - const diff = Date.now() - new Date(dateStr).getTime(); - const mins = Math.floor(diff / 60000); - if (mins < 60) return `${mins}m ago`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - if (days < 30) return `${days}d ago`; - return `${Math.floor(days / 30)}mo ago`; - }; - - el.innerHTML = tracks.map(t => ` -
- - ${_esc(t.title)} - ${_esc(t.artist || '')} - ${_ago(t.played_at)} -
- `).join(''); -} - // --- Initialization --- function initializeImportPage() { diff --git a/webui/static/style.css b/webui/static/style.css index bbdc9371..a6ef10e9 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -40072,887 +40072,6 @@ div.artist-hero-badge { IMPORT PAGE (full page, replaces modal) ======================================== */ -/* ============================================================================ - STATS PAGE - ============================================================================ */ - -/* Stats page uses dashboard-container pattern for consistency */ -.stats-container { - display: flex; - flex-direction: column; - gap: 20px; - padding: 28px 24px 30px; - overflow: hidden; - background: linear-gradient(135deg, - rgba(20, 20, 20, 0.55) 0%, - rgba(12, 12, 12, 0.62) 100%); - border-radius: 24px; - border: 1px solid rgba(255, 255, 255, 0.08); - border-top: 1px solid rgba(255, 255, 255, 0.12); - margin: 20px; - box-shadow: - 0 8px 32px rgba(0, 0, 0, 0.3), - 0 4px 16px rgba(0, 0, 0, 0.2), - inset 0 1px 0 rgba(255, 255, 255, 0.08); -} - -/* Header uses same pattern as .dashboard-header */ -.stats-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 20px 24px; - margin: -28px -24px 0 -24px; - position: relative; - overflow: hidden; - flex-wrap: wrap; - gap: 16px; - background: linear-gradient(180deg, - rgba(var(--accent-rgb), 0.10) 0%, - rgba(var(--accent-rgb), 0.04) 40%, - transparent 100%); - border-bottom: 1px solid rgba(255, 255, 255, 0.06); - border-top-left-radius: 24px; - border-top-right-radius: 24px; -} - -.stats-header::before { - content: ''; - position: absolute; - top: 0; - left: -100%; - width: 50%; - height: 100%; - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.03), transparent); - animation: stats-header-sweep 12s ease-in-out infinite; -} - -@keyframes stats-header-sweep { - 0%, 100% { left: -100%; } - 50% { left: 150%; } -} - -.stats-header-title { - display: flex; - align-items: center; - gap: 14px; -} - -.stats-header-title h1 { - font-size: 28px; - font-weight: 700; - color: #fff; - margin: 0; - font-family: 'SF Pro Display', -apple-system, sans-serif; -} - -/* Time range pills */ -.stats-time-range { - display: flex; - gap: 4px; - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 10px; - padding: 3px; -} - -.stats-range-btn { - padding: 7px 16px; - border: none; - border-radius: 8px; - background: transparent; - color: rgba(255, 255, 255, 0.5); - font-size: 0.82em; - font-weight: 600; - cursor: pointer; - transition: all 0.2s; - font-family: inherit; -} - -.stats-range-btn:hover { - color: rgba(255, 255, 255, 0.8); - background: rgba(255, 255, 255, 0.04); -} - -.stats-range-btn.active { - background: rgb(var(--accent-rgb)); - color: #fff; - box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.3); -} - -.stats-header-controls { - display: flex; - align-items: center; - gap: 16px; -} - -.stats-sync-controls { - display: flex; - align-items: center; - gap: 8px; -} - -.stats-last-synced { - font-size: 0.72em; - color: rgba(255, 255, 255, 0.3); -} - -.stats-sync-btn { - width: 32px; - height: 32px; - border-radius: 8px; - border: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(255, 255, 255, 0.04); - color: rgba(255, 255, 255, 0.5); - font-size: 16px; - cursor: pointer; - transition: all 0.2s; - font-family: inherit; - display: flex; - align-items: center; - justify-content: center; -} - -.stats-sync-btn:hover { - background: rgba(255, 255, 255, 0.08); - color: #fff; - border-color: rgba(255, 255, 255, 0.15); -} - -.stats-sync-btn.syncing { - pointer-events: none; - color: transparent; - position: relative; -} - -.stats-sync-btn.syncing::after { - content: ''; - position: absolute; - width: 14px; - height: 14px; - border: 2px solid rgba(var(--accent-rgb), 0.2); - border-top-color: rgba(var(--accent-rgb), 0.8); - border-radius: 50%; - animation: stats-spin 0.8s linear infinite; -} - -@keyframes stats-spin { - to { transform: rotate(360deg); } -} - -/* Overview cards */ -.stats-overview { - display: grid; - grid-template-columns: repeat(5, 1fr); - gap: 14px; -} - -.stats-card { - background: linear-gradient(135deg, rgba(20, 20, 20, 0.95) 0%, rgba(12, 12, 12, 0.98) 100%); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 14px; - padding: 20px; - text-align: center; - position: relative; - overflow: hidden; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.06); -} - -.stats-card::before { - content: ''; - position: absolute; - top: 0; - left: 20%; - right: 20%; - height: 2px; - background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.5), transparent); - border-radius: 2px; - transition: all 0.3s; -} - -.stats-card:hover { - transform: translateY(-3px); - border-color: rgba(var(--accent-rgb), 0.2); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4), 0 0 20px rgba(var(--accent-rgb), 0.08); -} - -.stats-card:hover::before { - left: 10%; - right: 10%; - height: 3px; - box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.4); -} - -.stats-card-value { - font-size: 2em; - font-weight: 700; - color: #fff; - line-height: 1.2; - margin-bottom: 6px; - font-family: 'SF Pro Display', -apple-system, sans-serif; -} - -.stats-card-label { - font-size: 0.78em; - color: rgba(255, 255, 255, 0.45); - text-transform: uppercase; - letter-spacing: 0.06em; - font-weight: 600; -} - -/* Main grid */ -.stats-main-grid { - display: grid; - grid-template-columns: 1fr 360px; - gap: 20px; - min-width: 0; -} - -.stats-left-col, .stats-right-col { - display: flex; - flex-direction: column; - gap: 20px; - min-width: 0; -} - -.stats-two-col { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 20px; -} - -/* Section cards */ -.stats-section-card { - background: rgba(255, 255, 255, 0.02); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 14px; - padding: 20px; - transition: border-color 0.2s; - min-width: 0; - overflow: hidden; -} - -.stats-section-card:hover { - border-color: rgba(255, 255, 255, 0.1); -} - -.stats-full-width { - /* No extra margin β€” container handles it */ -} - -.stats-section-title { - font-size: 0.78em; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - color: rgba(255, 255, 255, 0.4); - margin-bottom: 16px; - padding-bottom: 10px; - border-bottom: 1px solid rgba(255, 255, 255, 0.05); -} - -/* Genre chart */ -.stats-genre-chart-container { - display: flex; - align-items: center; - gap: 24px; -} - -.stats-genre-chart-container canvas { - width: 180px !important; - height: 180px !important; - flex-shrink: 0; -} - -.stats-genre-legend { - flex: 1; - display: flex; - flex-direction: column; - gap: 6px; -} - -.stats-genre-legend-item { - display: flex; - align-items: center; - gap: 8px; - font-size: 0.82em; - color: rgba(255, 255, 255, 0.7); -} - -.stats-genre-dot { - width: 10px; - height: 10px; - border-radius: 3px; - flex-shrink: 0; -} - -.stats-genre-pct { - margin-left: auto; - color: rgba(255, 255, 255, 0.4); - font-variant-numeric: tabular-nums; -} - -/* Top artists visual bubbles */ -.stats-top-artists-visual { - margin-bottom: 16px; - padding-bottom: 14px; - border-bottom: 1px solid rgba(255, 255, 255, 0.04); -} - -.stats-artist-bubbles { - display: flex; - justify-content: space-around; - align-items: flex-end; - gap: 8px; -} - -.stats-artist-bubble { - display: flex; - flex-direction: column; - align-items: center; - gap: 6px; - min-width: 0; - flex: 1; - transition: transform 0.2s; -} - -.stats-artist-bubble:hover { - transform: translateY(-3px); -} - -.stats-bubble-img { - border-radius: 50%; - background-size: cover; - background-position: center; - background-color: rgba(255, 255, 255, 0.06); - border: 2px solid rgba(var(--accent-rgb), 0.2); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - transition: border-color 0.2s, box-shadow 0.2s; -} - -.stats-artist-bubble:hover .stats-bubble-img { - border-color: rgba(var(--accent-rgb), 0.5); - box-shadow: 0 4px 20px rgba(var(--accent-rgb), 0.2); -} - -.stats-bubble-img span { - font-size: 1.2em; - font-weight: 700; - color: rgba(255, 255, 255, 0.4); -} - -.stats-bubble-bar-container { - width: 100%; - height: 3px; - background: rgba(255, 255, 255, 0.06); - border-radius: 2px; - overflow: hidden; -} - -.stats-bubble-bar { - height: 100%; - background: linear-gradient(90deg, rgb(var(--accent-rgb)), rgba(var(--accent-rgb), 0.4)); - border-radius: 2px; - transition: width 0.5s ease; -} - -.stats-bubble-name { - font-size: 0.7em; - color: rgba(255, 255, 255, 0.7); - font-weight: 500; - text-align: center; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 100%; -} - -.stats-bubble-count { - font-size: 0.65em; - color: rgba(var(--accent-rgb), 0.7); - font-weight: 600; -} - -/* Ranked lists */ -.stats-ranked-list { - display: flex; - flex-direction: column; - gap: 4px; - max-height: 280px; - overflow-y: auto; - scrollbar-width: thin; - scrollbar-color: rgba(255, 255, 255, 0.1) transparent; -} - -.stats-ranked-item { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 10px; - border-radius: 8px; - transition: background 0.15s; - cursor: default; -} - -.stats-ranked-item:hover { - background: rgba(255, 255, 255, 0.04); -} - -.stats-ranked-num { - font-size: 0.75em; - color: rgba(255, 255, 255, 0.25); - font-weight: 700; - width: 18px; - text-align: right; - flex-shrink: 0; -} - -.stats-ranked-img { - width: 36px; - height: 36px; - border-radius: 6px; - object-fit: cover; - flex-shrink: 0; - background: rgba(255, 255, 255, 0.05); -} - -.stats-ranked-info { - flex: 1; - min-width: 0; -} - -.stats-ranked-name { - font-size: 0.88em; - color: rgba(255, 255, 255, 0.85); - font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.stats-ranked-meta { - font-size: 0.72em; - color: rgba(255, 255, 255, 0.4); -} - -.stats-ranked-count { - font-size: 0.78em; - color: rgba(var(--accent-rgb), 0.8); - font-weight: 600; - flex-shrink: 0; - font-variant-numeric: tabular-nums; -} - -.stats-artist-link { - color: inherit; - text-decoration: none; - cursor: pointer; - transition: color 0.15s; -} - -.stats-artist-link:hover { - color: rgb(var(--accent-rgb)); -} - -/* Play buttons */ -.stats-play-btn { - width: 28px; - height: 28px; - border-radius: 50%; - border: none; - background: rgba(var(--accent-rgb), 0.15); - color: rgb(var(--accent-rgb)); - font-size: 10px; - cursor: pointer; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; - opacity: 0; -} - -.stats-ranked-item:hover .stats-play-btn, -.stats-recent-item:hover .stats-play-btn { - opacity: 1; -} - -.stats-play-btn:hover { - background: rgb(var(--accent-rgb)); - color: #fff; - transform: scale(1.1); -} - -.stats-play-btn-sm { - width: 22px; - height: 22px; - font-size: 8px; -} - -/* Library health */ -.stats-health-grid { - display: grid; - grid-template-columns: 2fr 1fr 1fr 1fr; - gap: 16px; - align-items: start; -} - -.stats-health-item { - text-align: center; -} - -.stats-health-item:first-child { - text-align: left; -} - -.stats-health-value { - font-size: 1.6em; - font-weight: 700; - color: #fff; - line-height: 1.2; - margin-bottom: 4px; -} - -.stats-health-label { - font-size: 0.75em; - color: rgba(255, 255, 255, 0.4); - text-transform: uppercase; - letter-spacing: 0.04em; - font-weight: 600; -} - -/* Format breakdown bar */ -.stats-format-bar { - display: flex; - height: 28px; - border-radius: 8px; - overflow: hidden; - margin-top: 8px; - background: rgba(255, 255, 255, 0.04); -} - -.stats-format-segment { - display: flex; - align-items: center; - justify-content: center; - font-size: 0.68em; - font-weight: 600; - color: #fff; - white-space: nowrap; - min-width: 30px; - transition: flex 0.5s ease; -} - -/* Recent plays */ -.stats-recent-list { - display: flex; - flex-direction: column; - gap: 4px; - max-height: 300px; - overflow-y: auto; - scrollbar-width: thin; - scrollbar-color: rgba(255, 255, 255, 0.1) transparent; -} - -.stats-recent-item { - display: flex; - align-items: center; - gap: 10px; - padding: 6px 8px; - border-radius: 6px; -} - -.stats-recent-item:hover { - background: rgba(255, 255, 255, 0.03); -} - -.stats-recent-title { - flex: 1; - font-size: 0.85em; - color: rgba(255, 255, 255, 0.8); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.stats-recent-artist { - font-size: 0.78em; - color: rgba(255, 255, 255, 0.4); - flex-shrink: 0; -} - -.stats-recent-time { - font-size: 0.72em; - color: rgba(255, 255, 255, 0.25); - flex-shrink: 0; - min-width: 65px; - text-align: right; -} - -/* Enrichment coverage */ -.stats-enrichment { - display: flex; - gap: 16px; - margin-top: 16px; - padding-top: 14px; - border-top: 1px solid rgba(255, 255, 255, 0.04); - flex-wrap: wrap; -} - -.stats-enrich-item { - flex: 1; - min-width: 120px; - display: flex; - align-items: center; - gap: 8px; -} - -.stats-enrich-name { - font-size: 0.72em; - color: rgba(255, 255, 255, 0.45); - min-width: 70px; - font-weight: 500; -} - -.stats-enrich-bar { - flex: 1; - height: 4px; - background: rgba(255, 255, 255, 0.06); - border-radius: 2px; - overflow: hidden; -} - -.stats-enrich-fill { - height: 100%; - border-radius: 2px; - transition: width 0.5s ease; -} - -.stats-enrich-pct { - font-size: 0.72em; - color: rgba(255, 255, 255, 0.4); - font-variant-numeric: tabular-nums; - min-width: 30px; - text-align: right; -} - -/* Library Disk Usage */ -.stats-disk-usage-wrap { - display: flex; - flex-direction: column; - gap: 14px; - margin-top: 8px; -} - -.stats-disk-total-row { - display: flex; - align-items: baseline; - gap: 16px; - flex-wrap: wrap; -} - -.stats-disk-total-value { - font-size: 28px; - font-weight: 700; - color: rgb(var(--accent-rgb)); -} - -.stats-disk-total-meta { - font-size: 12px; - color: rgba(255, 255, 255, 0.55); -} - -.stats-disk-formats { - display: flex; - flex-direction: column; - gap: 6px; -} - -.stats-disk-format-row { - display: grid; - grid-template-columns: 60px 1fr 80px; - align-items: center; - gap: 10px; - font-size: 12px; -} - -.stats-disk-format-name { - font-weight: 600; - color: rgba(255, 255, 255, 0.8); -} - -.stats-disk-format-bar { - height: 8px; - background: rgba(255, 255, 255, 0.05); - border-radius: 4px; - overflow: hidden; -} - -.stats-disk-format-fill { - height: 100%; - background: linear-gradient(90deg, - rgb(var(--accent-rgb)) 0%, - rgba(var(--accent-rgb), 0.6) 100%); - border-radius: 4px; -} - -.stats-disk-format-size { - text-align: right; - color: rgba(255, 255, 255, 0.55); - font-variant-numeric: tabular-nums; -} - -/* Database Storage Chart */ -.stats-db-storage-wrap { - display: flex; - align-items: center; - gap: 24px; - margin-top: 8px; -} - -.stats-db-chart-container { - position: relative; - width: 180px; - height: 180px; - flex-shrink: 0; -} - -.stats-db-chart-container canvas { - width: 180px !important; - height: 180px !important; -} - -.stats-db-total { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - pointer-events: none; -} - -.stats-db-total-value { - font-size: 20px; - font-weight: 700; - color: rgba(255, 255, 255, 0.85); -} - -.stats-db-total-label { - font-size: 10px; - color: rgba(255, 255, 255, 0.4); - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.stats-db-legend { - flex: 1; - display: flex; - flex-direction: column; - gap: 5px; - min-width: 0; -} - -.stats-db-legend-item { - display: flex; - align-items: center; - gap: 8px; - font-size: 12px; - color: rgba(255, 255, 255, 0.6); -} - -.stats-db-legend-dot { - width: 10px; - height: 10px; - border-radius: 3px; - flex-shrink: 0; -} - -.stats-db-legend-name { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.stats-db-legend-size { - font-variant-numeric: tabular-nums; - color: rgba(255, 255, 255, 0.4); - font-size: 11px; -} - -/* Stats empty state */ -.stats-empty { - text-align: center; - padding: 80px 20px; - color: rgba(255, 255, 255, 0.5); -} - -.stats-empty-icon { - font-size: 48px; - margin-bottom: 16px; -} - -.stats-empty h3 { - font-size: 1.2em; - color: rgba(255, 255, 255, 0.7); - margin-bottom: 8px; -} - -.stats-empty p { - font-size: 0.88em; - max-width: 400px; - margin: 0 auto; - line-height: 1.5; -} - -/* Mobile responsive */ -@media (max-width: 768px) { - .stats-container { - margin: 10px; - padding: 16px; - } - .stats-overview { - grid-template-columns: repeat(2, 1fr); - } - .stats-main-grid { - grid-template-columns: 1fr; - } - .stats-health-grid { - grid-template-columns: 1fr 1fr; - } - .stats-genre-chart-container { - flex-direction: column; - } - .stats-two-col { - grid-template-columns: 1fr; - } - .stats-genre-chart-container canvas { - width: 150px !important; - height: 150px !important; - } - .stats-header { - flex-direction: column; - align-items: flex-start; - padding: 16px; - } - .stats-header-controls { - flex-direction: column; - align-items: flex-start; - gap: 10px; - width: 100%; - } - .stats-card-value { - font-size: 1.5em; - } -} - /* ============================================================================ IMPORT PAGE ============================================================================ */ From 1e052373a41be81a21dc57499ec3d322fae6926d Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Thu, 14 May 2026 17:15:25 +0300 Subject: [PATCH 10/26] refactor(webui): route stats interop through shell bridge - move stats route legacy handoffs onto explicit SoulSyncWebShellBridge methods\n- stop relying on ad hoc window globals from React code for artist navigation and playback\n- update shell bridge tests and route test doubles to enforce the expanded bridge contract --- webui/src/app/router.test.tsx | 5 ++ webui/src/platform/shell/bridge.test.ts | 10 ++++ webui/src/platform/shell/globals.d.ts | 44 ++++++++--------- webui/src/routes/issues/-route.test.tsx | 5 ++ webui/src/routes/stats/-route.test.tsx | 10 ++-- webui/src/routes/stats/-ui/stats-page.tsx | 59 +++++++++++++++-------- webui/static/shell-bridge.js | 15 ++++++ 7 files changed, 101 insertions(+), 47 deletions(-) diff --git a/webui/src/app/router.test.tsx b/webui/src/app/router.test.tsx index 63295b4b..95f90e97 100644 --- a/webui/src/app/router.test.tsx +++ b/webui/src/app/router.test.tsx @@ -63,6 +63,11 @@ function createShellBridge(overrides: Partial = {}): ShellBridge { navigateToArtistDetail: vi.fn(), cancelSimilarArtistsLoad: vi.fn(), showReactHost: vi.fn(), + navigateToArtistDetail: vi.fn(), + playLibraryTrack: vi.fn(), + startStream: vi.fn(), + showLoadingOverlay: vi.fn(), + hideLoadingOverlay: vi.fn(), ...overrides, }; } diff --git a/webui/src/platform/shell/bridge.test.ts b/webui/src/platform/shell/bridge.test.ts index 9682bbe7..29546e29 100644 --- a/webui/src/platform/shell/bridge.test.ts +++ b/webui/src/platform/shell/bridge.test.ts @@ -19,6 +19,11 @@ describe('waitForShellContext', () => { setActivePageChrome: vi.fn(), cancelSimilarArtistsLoad: vi.fn(), showReactHost: vi.fn(), + navigateToArtistDetail: vi.fn(), + playLibraryTrack: vi.fn(), + startStream: vi.fn(), + showLoadingOverlay: vi.fn(), + hideLoadingOverlay: vi.fn(), } as NonNullable; await expect(waitForShellContext()).resolves.toEqual({ @@ -41,6 +46,11 @@ describe('waitForShellContext', () => { setActivePageChrome: vi.fn(), cancelSimilarArtistsLoad: vi.fn(), showReactHost: vi.fn(), + navigateToArtistDetail: vi.fn(), + playLibraryTrack: vi.fn(), + startStream: vi.fn(), + showLoadingOverlay: vi.fn(), + hideLoadingOverlay: vi.fn(), } as NonNullable; const contextPromise = waitForShellContext(); diff --git a/webui/src/platform/shell/globals.d.ts b/webui/src/platform/shell/globals.d.ts index 2e254c24..5ee321c2 100644 --- a/webui/src/platform/shell/globals.d.ts +++ b/webui/src/platform/shell/globals.d.ts @@ -45,29 +45,29 @@ declare global { ) => void; cancelSimilarArtistsLoad: () => void; showReactHost: (pageId: ShellPageId) => void; + navigateToArtistDetail: ( + artistId: string | number, + artistName: string, + sourceOverride?: string | null, + options?: Record, + ) => void; + playLibraryTrack: ( + track: { + id: string | number; + title: string; + file_path: string; + bitrate?: string | number | null; + artist_id?: string | number | null; + album_id?: string | number | null; + _stats_image?: string | null; + }, + albumTitle: string, + artistName: string, + ) => void | Promise; + startStream: (searchResult: Record) => void | Promise; + showLoadingOverlay: (message?: string) => void; + hideLoadingOverlay: () => void; }; - navigateToArtistDetail?: ( - artistId: string | number, - artistName: string, - sourceOverride?: string | null, - options?: Record, - ) => void; - playLibraryTrack?: ( - track: { - id: string | number; - title: string; - file_path: string; - bitrate?: string | number | null; - artist_id?: string | number | null; - album_id?: string | number | null; - _stats_image?: string | null; - }, - albumTitle: string, - artistName: string, - ) => void | Promise; - startStream?: (searchResult: Record) => void | Promise; - showLoadingOverlay?: (message?: string) => void; - hideLoadingOverlay?: () => void; } } diff --git a/webui/src/routes/issues/-route.test.tsx b/webui/src/routes/issues/-route.test.tsx index 7bbc0353..023e24b8 100644 --- a/webui/src/routes/issues/-route.test.tsx +++ b/webui/src/routes/issues/-route.test.tsx @@ -25,6 +25,11 @@ function createShellBridge(overrides: Partial = {}): ShellBridge { navigateToArtistDetail: vi.fn(), cancelSimilarArtistsLoad: vi.fn(), showReactHost: vi.fn(), + navigateToArtistDetail: vi.fn(), + playLibraryTrack: vi.fn(), + startStream: vi.fn(), + showLoadingOverlay: vi.fn(), + hideLoadingOverlay: vi.fn(), ...overrides, }; } diff --git a/webui/src/routes/stats/-route.test.tsx b/webui/src/routes/stats/-route.test.tsx index 1b447aa7..604e8493 100644 --- a/webui/src/routes/stats/-route.test.tsx +++ b/webui/src/routes/stats/-route.test.tsx @@ -23,6 +23,11 @@ function createShellBridge(overrides: Partial = {}): ShellBridge { setActivePageChrome: vi.fn(), activateLegacyPath: vi.fn(), showReactHost: vi.fn(), + navigateToArtistDetail: vi.fn(), + playLibraryTrack: vi.fn(), + startStream: vi.fn(), + showLoadingOverlay: vi.fn(), + hideLoadingOverlay: vi.fn(), ...overrides, }; } @@ -41,11 +46,6 @@ function renderStatsRoute(initialEntries = ['/stats']) { describe('stats route', () => { beforeEach(() => { window.SoulSyncWebShellBridge = createShellBridge(); - window.navigateToArtistDetail = vi.fn(); - window.playLibraryTrack = vi.fn(); - window.startStream = vi.fn(); - window.showLoadingOverlay = vi.fn(); - window.hideLoadingOverlay = vi.fn(); window.showToast = vi.fn(); vi.stubGlobal( 'fetch', diff --git a/webui/src/routes/stats/-ui/stats-page.tsx b/webui/src/routes/stats/-ui/stats-page.tsx index 1ab9654a..a16c48de 100644 --- a/webui/src/routes/stats/-ui/stats-page.tsx +++ b/webui/src/routes/stats/-ui/stats-page.tsx @@ -14,6 +14,8 @@ import { YAxis, } from 'recharts'; +import type { ShellBridge } from '@/platform/shell/bridge'; + import { useReactPageShell } from '@/platform/shell/route-controllers'; import type { @@ -72,7 +74,7 @@ const STATS_CHART_CURSOR = { } as const; export function StatsPage() { - useReactPageShell('stats'); + const bridge = useReactPageShell('stats'); const navigate = useNavigate({ from: Route.fullPath }); const queryClient = useQueryClient(); @@ -202,31 +204,42 @@ export function StatsPage() {
- + playStatsTrack(bridge, track)} + />
+ void openArtistDetail(bridge, artistId, artistName) + } /> + void openArtistDetail(bridge, artistId, artistName) + } /> + void openArtistDetail(bridge, artistId, artistName) + } /> + void openArtistDetail(bridge, artistId, artistName) + } + onPlay={(track) => playStatsTrack(bridge, track)} />
@@ -606,7 +619,10 @@ function StatsRecentPlays({
{tracks.length === 0 ? : null} {tracks.map((track, index) => ( -
+
; } -async function openArtistDetail(artistId: string | number, artistName: string) { +async function openArtistDetail( + bridge: ShellBridge, + artistId: string | number, + artistName: string, +) { await window.SoulSyncWebRouter?.navigateToPage('library'); window.setTimeout(() => { - window.navigateToArtistDetail?.(artistId, artistName); + bridge.navigateToArtistDetail(artistId, artistName); }, 300); } -async function playStatsTrack(track: { title: string; artist: string; album: string }) { +async function playStatsTrack( + bridge: ShellBridge, + track: { title: string; artist: string; album: string }, +) { const resolvedTrack = await resolveStatsTrack(track.title, track.artist); if (resolvedTrack) { - void window.playLibraryTrack?.( + await bridge.playLibraryTrack( { id: resolvedTrack.id, title: resolvedTrack.title, @@ -887,23 +910,19 @@ async function playStatsTrack(track: { title: string; artist: string; album: str return; } - window.showLoadingOverlay?.(`Searching for ${track.title}...`); + bridge.showLoadingOverlay(`Searching for ${track.title}...`); try { const streamResult = await streamStatsTrack(track.title, track.artist, track.album); - window.hideLoadingOverlay?.(); + bridge.hideLoadingOverlay(); if (streamResult) { - if (typeof window.startStream === 'function') { - await window.startStream(streamResult); - } else { - window.showToast?.('Streaming not available', 'error'); - } + await bridge.startStream(streamResult); return; } window.showToast?.('Track not found in library or any source', 'error'); } catch (error) { - window.hideLoadingOverlay?.(); + bridge.hideLoadingOverlay(); window.showToast?.(getErrorMessage(error), 'error'); } } diff --git a/webui/static/shell-bridge.js b/webui/static/shell-bridge.js index 7db84a76..c1ac1d1b 100644 --- a/webui/static/shell-bridge.js +++ b/webui/static/shell-bridge.js @@ -177,6 +177,21 @@ window.SoulSyncWebShellBridge = { showReactHost(pageId) { showReactHost(pageId); }, + navigateToArtistDetail(artistId, artistName, sourceOverride, options) { + return navigateToArtistDetail(artistId, artistName, sourceOverride, options); + }, + playLibraryTrack(track, albumTitle, artistName) { + return playLibraryTrack(track, albumTitle, artistName); + }, + startStream(searchResult) { + return startStream(searchResult); + }, + showLoadingOverlay(message) { + return showLoadingOverlay(message); + }, + hideLoadingOverlay() { + return hideLoadingOverlay(); + }, }; function _handleShellLinkClick(event) { From 92e86527c31c1fe6f697754e5174d2494c9f5fa8 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Thu, 14 May 2026 17:27:48 +0300 Subject: [PATCH 11/26] refactor(webui): simplify stats artist detail handoff - remove the stats page timeout and library pre-navigation hack - hand artist detail opening directly to the legacy shell bridge - add a route test covering the direct artist navigation bridge call --- webui/src/routes/stats/-route.test.tsx | 11 ++++++++ webui/src/routes/stats/-ui/stats-page.tsx | 31 ++++++----------------- 2 files changed, 19 insertions(+), 23 deletions(-) diff --git a/webui/src/routes/stats/-route.test.tsx b/webui/src/routes/stats/-route.test.tsx index 604e8493..189ca154 100644 --- a/webui/src/routes/stats/-route.test.tsx +++ b/webui/src/routes/stats/-route.test.tsx @@ -114,6 +114,17 @@ describe('stats route', () => { await waitFor(() => expect(history.location.search).toContain('range=30d')); }); + it('hands artist detail navigation directly to the shell bridge', async () => { + renderStatsRoute(); + + fireEvent.click(await screen.findByRole('button', { name: 'Artist A' })); + + expect(window.SoulSyncWebShellBridge?.navigateToArtistDetail).toHaveBeenCalledWith( + 7, + 'Artist A', + ); + }); + it('redirects back home when the page is not allowed', async () => { window.SoulSyncWebShellBridge = createShellBridge({ isPageAllowed: vi.fn((pageId) => pageId !== 'stats'), diff --git a/webui/src/routes/stats/-ui/stats-page.tsx b/webui/src/routes/stats/-ui/stats-page.tsx index a16c48de..5a9f4c94 100644 --- a/webui/src/routes/stats/-ui/stats-page.tsx +++ b/webui/src/routes/stats/-ui/stats-page.tsx @@ -135,6 +135,10 @@ export function StatsPage() { }); }; + const openArtistDetail = (artistId: string | number, artistName: string) => { + bridge.navigateToArtistDetail(artistId, artistName); + }; + return (
@@ -214,31 +218,23 @@ export function StatsPage() { - void openArtistDetail(bridge, artistId, artistName) - } + onArtistSelect={(artistId, artistName) => openArtistDetail(artistId, artistName)} /> - void openArtistDetail(bridge, artistId, artistName) - } + onArtistSelect={(artistId, artistName) => openArtistDetail(artistId, artistName)} /> - void openArtistDetail(bridge, artistId, artistName) - } + onArtistSelect={(artistId, artistName) => openArtistDetail(artistId, artistName)} /> - void openArtistDetail(bridge, artistId, artistName) - } + onArtistSelect={(artistId, artistName) => openArtistDetail(artistId, artistName)} onPlay={(track) => playStatsTrack(bridge, track)} /> @@ -877,17 +873,6 @@ function EmptyListState({ message }: { message: string }) { return
{message}
; } -async function openArtistDetail( - bridge: ShellBridge, - artistId: string | number, - artistName: string, -) { - await window.SoulSyncWebRouter?.navigateToPage('library'); - window.setTimeout(() => { - bridge.navigateToArtistDetail(artistId, artistName); - }, 300); -} - async function playStatsTrack( bridge: ShellBridge, track: { title: string; artist: string; album: string }, From 23de70958a1942346e826f0546900b7c67d653fe Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Thu, 14 May 2026 17:36:46 +0300 Subject: [PATCH 12/26] test(webui): generalize shell route smoke coverage - rename the Playwright smoke suite to reflect shell-wide route coverage - share nav highlight expectations through the route manifest helpers - cover React route activation and canonical stats URL behavior --- .../src/platform/shell/route-manifest.test.ts | 9 +++- webui/src/platform/shell/route-manifest.ts | 5 +++ ...oke.spec.ts => shell-routes.smoke.spec.ts} | 44 +++++++++++-------- 3 files changed, 39 insertions(+), 19 deletions(-) rename webui/tests/{issues.smoke.spec.ts => shell-routes.smoke.spec.ts} (80%) diff --git a/webui/src/platform/shell/route-manifest.test.ts b/webui/src/platform/shell/route-manifest.test.ts index 369f7b3d..2727c255 100644 --- a/webui/src/platform/shell/route-manifest.test.ts +++ b/webui/src/platform/shell/route-manifest.test.ts @@ -6,6 +6,7 @@ import { normalizeShellPath, reactShellRoutes, resolveLegacyShellPageFromPath, + resolveShellNavPage, resolveShellPageFromPath, shellRouteManifest, } from './route-manifest'; @@ -41,8 +42,9 @@ describe('shellRouteManifest', () => { it('tracks whether a route is rendered by React or the legacy shell', () => { expect(getShellRouteByPageId('issues')?.kind).toBe('react'); + expect(getShellRouteByPageId('stats')?.kind).toBe('react'); expect(getShellRouteByPageId('discover')?.kind).toBe('legacy'); - expect(reactShellRoutes.map((route) => route.pageId)).toEqual(['issues']); + expect(reactShellRoutes.map((route) => route.pageId)).toEqual(['stats', 'issues']); expect(legacyShellRoutes.some((route) => route.pageId === 'dashboard')).toBe(true); }); @@ -55,4 +57,9 @@ describe('shellRouteManifest', () => { expect(resolveLegacyShellPageFromPath('/issues')).toBeNull(); expect(resolveLegacyShellPageFromPath('/does-not-exist')).toBeNull(); }); + + it('maps contextual pages to the nav chrome they belong under', () => { + expect(resolveShellNavPage('artist-detail')).toBe('library'); + expect(resolveShellNavPage('stats')).toBe('stats'); + }); }); diff --git a/webui/src/platform/shell/route-manifest.ts b/webui/src/platform/shell/route-manifest.ts index 39686325..bbcd7d09 100644 --- a/webui/src/platform/shell/route-manifest.ts +++ b/webui/src/platform/shell/route-manifest.ts @@ -92,3 +92,8 @@ export function resolveLegacyShellPageFromPath(pathname: string): ShellPageId | const route = getShellRouteByPath(pathname); return route?.kind === 'legacy' ? route.pageId : null; } + +export function resolveShellNavPage(pageId: ShellPageId): ShellPageId | '' { + if (pageId === 'artist-detail') return 'library'; + return pageId; +} diff --git a/webui/tests/issues.smoke.spec.ts b/webui/tests/shell-routes.smoke.spec.ts similarity index 80% rename from webui/tests/issues.smoke.spec.ts rename to webui/tests/shell-routes.smoke.spec.ts index ebac18f5..f2854c0c 100644 --- a/webui/tests/issues.smoke.spec.ts +++ b/webui/tests/shell-routes.smoke.spec.ts @@ -1,6 +1,11 @@ import { expect, test, type Page } from '@playwright/test'; -import { shellRouteManifest, type ShellPageId } from '../src/platform/shell/route-manifest'; +import { + getShellRouteByPageId, + resolveShellNavPage, + shellRouteManifest, + type ShellPageId, +} from '../src/platform/shell/route-manifest'; async function selectProfile(page: Page, baseURL: string, profileId = 1) { const response = await page.request.post(new URL('/api/profiles/select', baseURL).toString(), { @@ -11,29 +16,24 @@ async function selectProfile(page: Page, baseURL: string, profileId = 1) { } async function waitForShellRoute(page: Page, pageId: string) { - if (pageId === 'issues') { + const route = getShellRouteByPageId(pageId as ShellPageId); + + if (route?.kind === 'react') { await expect .poll(async () => page.evaluate(() => document.querySelector('.page.active')?.id ?? ''), { timeout: 15000, }) .toBe('webui-react-root'); - await expect(page.getByTestId('issues-board')).toBeVisible({ timeout: 15000 }); return; } await expect - .poll(async () => - page.evaluate(() => document.querySelector('.page.active')?.id ?? ''), - ) + .poll(async () => page.evaluate(() => document.querySelector('.page.active')?.id ?? '')) .toBe(`${pageId}-page`); } function getExpectedNavPage(pageId: ShellPageId): string { - if (pageId === 'artist-detail') { - return 'library'; - } - - return pageId; + return resolveShellNavPage(pageId); } async function expectNavHighlight(page: Page, pageId: ShellPageId) { @@ -51,15 +51,19 @@ async function verifyIssuesRoute(page: Page) { await expect(page.getByTestId('issues-board')).toContainText('Issues'); } -function expectedUrlPattern(path: string): RegExp { - if (path === '/issues') { +function expectedUrlPattern(path: string, pageId: ShellPageId): RegExp { + if (pageId === 'issues') { return /\/issues(?:\?status=open&category=all)?$/; } + if (pageId === 'stats') { + return /\/stats(?:\?range=7d)?$/; + } + return new RegExp(`${path.replace('/', '\\/')}$`); } -test('direct load activates all known top-level routes', async ({ page, baseURL }) => { +test('direct load activates all known shell routes', async ({ page, baseURL }) => { if (!baseURL) { test.skip(); return; @@ -70,9 +74,11 @@ test('direct load activates all known top-level routes', async ({ page, baseURL for (const route of shellRouteManifest) { const routePage = await page.context().newPage(); try { - await routePage.goto(new URL(route.path, baseURL).toString(), { waitUntil: 'domcontentloaded' }); + await routePage.goto(new URL(route.path, baseURL).toString(), { + waitUntil: 'domcontentloaded', + }); await waitForShellRoute(routePage, route.pageId); - await expect(routePage).toHaveURL(expectedUrlPattern(route.path)); + await expect(routePage).toHaveURL(expectedUrlPattern(route.path, route.pageId)); await expectNavHighlight(routePage, route.pageId); if (route.pageId === 'issues') { @@ -84,7 +90,7 @@ test('direct load activates all known top-level routes', async ({ page, baseURL } }); -test('browser history restores top-level routes', async ({ page, baseURL }) => { +test('browser history restores shell routes', async ({ page, baseURL }) => { if (!baseURL) { test.skip(); return; @@ -101,7 +107,9 @@ test('browser history restores top-level routes', async ({ page, baseURL }) => { await page.getByRole('link', { name: 'Issues' }).click(); await expect .poll(async () => - page.evaluate(() => (window as typeof window & { __spaNavMarker?: string }).__spaNavMarker ?? null), + page.evaluate( + () => (window as typeof window & { __spaNavMarker?: string }).__spaNavMarker ?? null, + ), ) .toBe('persist'); await waitForShellRoute(page, 'issues'); From c9ba5e36a409665e51bf0554860bacbb6e630fe1 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Thu, 14 May 2026 17:46:52 +0300 Subject: [PATCH 13/26] refactor(webui): drop deprecated recharts cell usage - replace Cell-based pie slice coloring with data-driven fill props - keep the stats genre and database charts visually unchanged - remove the deprecation warning from the stats route --- webui/src/routes/stats/-ui/stats-page.tsx | 26 +++++++++-------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/webui/src/routes/stats/-ui/stats-page.tsx b/webui/src/routes/stats/-ui/stats-page.tsx index 5a9f4c94..988cf8c9 100644 --- a/webui/src/routes/stats/-ui/stats-page.tsx +++ b/webui/src/routes/stats/-ui/stats-page.tsx @@ -5,7 +5,6 @@ import { Bar, BarChart, CartesianGrid, - Cell, Pie, PieChart, ResponsiveContainer, @@ -347,7 +346,10 @@ function StatsGenreChart({ }: { genres: Array<{ genre: string; play_count: number; percentage: number }>; }) { - const topGenres = genres.slice(0, 10); + const topGenres = genres.slice(0, 10).map((genre, index) => ({ + ...genre, + fill: STATS_GENRE_COLORS[index % STATS_GENRE_COLORS.length], + })); return ( @@ -359,11 +361,7 @@ function StatsGenreChart({ outerRadius={84} paddingAngle={2} stroke="transparent" - > - {topGenres.map((genre, index) => ( - - ))} - + /> @@ -782,7 +780,10 @@ function StatsDbStorage({ return ; } - const tables = groupDbStorageTables(payload?.tables ?? []); + const tables = groupDbStorageTables(payload?.tables ?? []).map((table, index) => ({ + ...table, + fill: STATS_DB_STORAGE_COLORS[index % STATS_DB_STORAGE_COLORS.length], + })); const method = payload?.method; return ( @@ -798,14 +799,7 @@ function StatsDbStorage({ outerRadius={84} paddingAngle={2} stroke="transparent" - > - {tables.map((table, index) => ( - - ))} - + /> Date: Thu, 14 May 2026 17:53:03 +0300 Subject: [PATCH 14/26] docs(webui): sync stats migration status - mark stats as a completed React-owned route in the migration overview - capture the stats migration outcome and cleanup status in its route plan - add guidance for future migrations to watch for shared UI reuse opportunities --- .../docs/migration/page-migration-overview.md | 37 +++++++++++-------- webui/docs/migration/stats-migration-plan.md | 23 +++++++++++- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/webui/docs/migration/page-migration-overview.md b/webui/docs/migration/page-migration-overview.md index 0d123551..01c96a57 100644 --- a/webui/docs/migration/page-migration-overview.md +++ b/webui/docs/migration/page-migration-overview.md @@ -1,16 +1,17 @@ -# WebUI Page Migration Analysis +# WebUI Page Migration Overview -Snapshot date: 2026-05-02 +Snapshot date: 2026-05-14 ## Summary - The shell route manifest now has 18 page ids. -- `issues` is still the only React-owned route. +- `issues` and `stats` are now React-owned routes. - Since the last snapshot, the biggest changes are: - `downloads` was renamed into `search`. - The live queue became `active-downloads`. - `watchlist` and `wishlist` became full sidebar pages. - `tools` was split off from `dashboard`. - `artists` is no longer a route id. +- Since the last migration review, `stats` has been fully moved to React, the legacy stats HTML/JS/CSS path has been removed, and the global `Chart.js` import has been dropped in favor of route-local `Recharts`. - The shell is also more modular now. The old monolithic `script.js` has been split across `core.js`, `init.js`, `shared-helpers.js`, and feature modules such as `search.js`, `api-monitor.js`, `pages-extra.js`, `stats-automations.js`, and `wishlist-tools.js`. - Current profile compatibility still normalizes old `downloads` and `artists` references to `search`, so the docs and the route ids are not always using the same historical language. @@ -28,7 +29,7 @@ Snapshot date: 2026-05-02 - `webui/static/core.js` now holds a lot of the shared global state that used to live in the old monolith. - `webui/static/init.js` still owns page activation, permission gating, nav highlighting, legacy routing, and the `window.SoulSyncWebRouter` bridge. - `webui/static/shell-bridge.js` and the TanStack Router adapter still decide whether a route is handled by the React host or handed back to the legacy shell. -- `issues` remains the reference pattern for React-owned pages: route manifest ownership, shell bridge integration, route-local data loading, and detail-modal behavior all live in the React subtree. +- `issues` remains the reference pattern for interactive React-owned pages, while `stats` now complements it as the reference for data-heavy read-only routes with route-local charts and explicit shell handoffs. - The legacy shell is now spread across feature modules rather than one giant coordinator file, which makes the migration seams a little clearer than they were a month ago. ### Route and Compatibility Notes @@ -48,6 +49,7 @@ Snapshot date: 2026-05-02 - Socket/WebSocket and polling behavior remain the biggest migration risks for live pages. - The help system, tours, and helper annotations still reference some historical route names, so route-migration work should use the manifest as the source of truth. - Visual effects such as `particles.js` and `worker-orbs.js` remain shell-global. +- Route migrations should actively scan for emerging shared UI or shell patterns. Do not force abstractions on the first occurrence, but do document overlap and prefer extracting a shared primitive once a second route clearly needs the same behavior. ## Scoring Rubric Each page is scored from 1 to 5 on five axes: @@ -74,10 +76,10 @@ Rollups: | Page | Owner | Scores (R/S/A/C/T) | Effort | Risk | Recommended Wave | | --- | --- | --- | --- | --- | --- | -| `issues` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Wave 0 | +| `issues` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Completed | +| `stats` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Completed | | `help` | Legacy | 3 / 2 / 1 / 1 / 2 | Low | Low | Wave 1 | | `hydrabase` | Legacy | 2 / 2 / 2 / 2 / 2 | Low | Low | Wave 1 | -| `stats` | Legacy | 2 / 2 / 2 / 2 / 2 | Low | Low | Wave 1 | | `import` | Legacy | 3 / 3 / 3 / 2 / 3 | Medium | Medium | Wave 1 | | `search` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 2 | | `watchlist` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 3 | @@ -104,6 +106,13 @@ Rollups: - Key coupling: shell page gating, shell nav badge refresh, bridge-controlled chrome, React Query cache. - Why it stays first: it is already the canonical React route pattern and the migration baseline. +#### `stats` +- Current owner: React. +- Primary files: `webui/src/routes/stats/*`, `webui/src/platform/shell/*`. +- Main surface: listening stats, charts, ranked lists, disk usage, database storage visualization. +- Key coupling: query invalidation, shell handoffs for playback and artist navigation, route-local chart composition. +- Why it matters now: it is the first completed data-heavy read-only migration and the current reference for route-local charting, explicit shell bridge usage, and legacy cleanup after cutover. + ### Wave 1: Safest wins #### `help` @@ -120,19 +129,12 @@ Rollups: - Key coupling: profile gating and a small amount of shell state. - Recommendation: low-risk route with a narrow surface. -#### `stats` -- Current owner: Legacy. -- Primary files: `webui/index.html`, `webui/static/stats-automations.js`. -- Main surface: listening stats, charts, ranked lists, database storage visualization. -- Key coupling: chart rendering, some deep links back into library routes. -- Recommendation: early migration candidate with good parity-test potential. - #### `import` - Current owner: Legacy. - Primary files: `webui/index.html`, `webui/static/stats-automations.js`, `webui/static/helper.js`. - Main surface: staging files, album and singles matching, suggestion cards, processing queue. - Key coupling: settings-derived staging path assumptions and downstream library state. -- Recommendation: still bounded enough for an early wave, though more workflow-heavy than `help` or `stats`. +- Recommendation: still bounded enough for an early wave, though more workflow-heavy than `help` or `hydrabase`. ### Wave 2: Search split @@ -244,6 +246,7 @@ Rollups: - Recommendation: save this for the final wave with the other complex authoring surfaces. ## Platform Unlocks +- `stats` now provides the baseline for route-local charting, explicit shell-bridge interop from React, and the pattern of cleaning out legacy assets once parity is confirmed. - `search` likely unlocks reusable search-controller and download-launch primitives for the global search widget and other search entry points. - `watchlist` likely unlocks artist-card, per-artist config, and scan-status primitives for `discover` and `wishlist`. - `wishlist` likely unlocks queue/cycle visualization, live polling, and retry-state handling for `active-downloads` and sync-driven download flows. @@ -252,6 +255,7 @@ Rollups: - `library` + `artist-detail` still unlock entity-detail patterns, bulk actions, and file-management workflows. ## Why Earlier Waves Are Safer +- `stats` validated that bounded data pages can move early without needing broad shell rewrites, while also surfacing a healthy reminder to watch for emerging shared primitives during migration work. - Wave 1 routes are either mostly static or bounded data UIs with limited cross-route side effects. - Wave 2 adds the renamed search surface without dragging in the full queue history. - Wave 3 introduces the new watchlist/wishlist split, which is important but still narrower than discovery or library management. @@ -260,8 +264,9 @@ Rollups: - Waves 6-10 defer the broadest, most coupled, or most orchestration-heavy surfaces until the team has the most leverage. ## Final Recommendation -- Keep `issues` as the reference implementation and preserve the existing bridge contract. +- Keep `issues` and `stats` as the current React reference implementations, and preserve the explicit bridge contract between React routes and legacy shell behavior. - Treat `search`, `watchlist`, `wishlist`, `active-downloads`, and `tools` as the current route ids, and keep `downloads` and `artists` only as compatibility history. -- Migrate the safe routes first: `help`, `hydrabase`, `stats`, and `import`. +- Migrate the remaining safe legacy routes first: `help`, `hydrabase`, and `import`. +- During each migration, actively look for small reuse opportunities across route slices and shared UI primitives, but only extract once the overlap is clearly real. - Use `search` as the next meaningful proving ground now that the download queue has been split out. - Avoid pulling `settings`, `sync`, `library`, `artist-detail`, or `automations` forward unless there is a separate product priority strong enough to justify the added regression risk. diff --git a/webui/docs/migration/stats-migration-plan.md b/webui/docs/migration/stats-migration-plan.md index d74215cc..bd582c2e 100644 --- a/webui/docs/migration/stats-migration-plan.md +++ b/webui/docs/migration/stats-migration-plan.md @@ -1,7 +1,16 @@ -# WebUI Stats React Migration Sketch +# WebUI Stats Migration Plan Snapshot date: 2026-05-14 +## Status + +- Completed on 2026-05-14. +- `stats` is now React-owned in the shell route manifest. +- The legacy stats HTML, JS, and CSS path has been removed. +- The global `Chart.js` import was removed and replaced with route-local `Recharts`. +- Legacy playback and artist-detail handoffs now go through the explicit shell bridge. +- A local seed script exists for realistic UI testing without production listening history: `tools/seed_stats_ui_scenarios.py`. + ## Goal - Migrate `stats` from the legacy shell to the React route host. @@ -16,6 +25,8 @@ Snapshot date: 2026-05-14 - The page is complex enough to validate query conventions, search-param state, and route-local chart components. - The page does not currently drive broad shell-global workflows. +This route has now validated those assumptions successfully. + ## Current Legacy Shape Page surface in `webui/index.html`: @@ -370,6 +381,7 @@ Playwright is optional for the first pass. - Extract shared chart colors into route-local constants or a small shared viz helper. - Consider a tiny `components/charts/` layer only after a second React page needs charts. - Revisit whether `stats/cached` should remain the primary page payload or whether the route should fan out to narrower endpoints later. +- Keep watching for overlap between route-local controls and shared UI primitives. The stats range selector is a good example of a pattern that should stay local for now, but should be reconsidered if another migrated route needs the same segmented-control behavior. ## Recommendation @@ -385,3 +397,12 @@ It should not optimize for: - visual redesign - a cross-app chart abstraction - backend reshaping + +## Outcome + +- The route now serves as the reference for data-heavy read-only React pages. +- The migration proved out route-local charts, route-search state, explicit shell-bridge interop, and post-cutover legacy cleanup. +- The work also reinforced a migration guideline for future routes: + - prefer local implementation on first use + - actively note overlap with shared primitives + - extract only once the second clear consumer appears From ca84aa2e6516f1577c0eb3bdb88bef49f8813747 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Thu, 14 May 2026 19:57:52 +0300 Subject: [PATCH 15/26] fix(webui): harden stats route fallback flows - make listening status prefetch optional so /stats still renders on status failures - normalize no-stats-yet cache responses into the existing empty stats state - restore streaming fallback when library track resolution errors - add route and API regression coverage for the review fixes --- webui/src/routes/stats/-route.test.tsx | 130 ++++++++++++++++++++++ webui/src/routes/stats/-stats.api.test.ts | 40 +++++++ webui/src/routes/stats/-stats.api.ts | 49 ++++++-- webui/src/routes/stats/-ui/stats-page.tsx | 36 +++--- webui/src/routes/stats/route.tsx | 7 +- 5 files changed, 237 insertions(+), 25 deletions(-) diff --git a/webui/src/routes/stats/-route.test.tsx b/webui/src/routes/stats/-route.test.tsx index 189ca154..bfc44343 100644 --- a/webui/src/routes/stats/-route.test.tsx +++ b/webui/src/routes/stats/-route.test.tsx @@ -106,6 +106,62 @@ describe('stats route', () => { expect(window.SoulSyncWebShellBridge?.setActivePageChrome).toHaveBeenCalledWith('stats'); }); + it('still renders when listening stats status prefetch fails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url.includes('/api/stats/cached')) { + return createResponse({ + success: true, + overview: { + total_plays: 24, + total_time_ms: 6_600_000, + unique_artists: 3, + unique_albums: 4, + unique_tracks: 12, + }, + top_artists: [{ id: 7, name: 'Artist A', play_count: 10 }], + top_albums: [], + top_tracks: [], + timeline: [{ date: 'May 10', plays: 4 }], + genres: [{ genre: 'House', play_count: 10, percentage: 80 }], + recent: [{ title: 'Track A', artist: 'Artist A', played_at: '2026-05-14T08:00:00Z' }], + health: { total_tracks: 12, format_breakdown: { FLAC: 12 } }, + }); + } + if (url.includes('/api/listening-stats/status')) { + return createResponse({ error: 'status unavailable' }, false, 500); + } + if (url.includes('/api/stats/db-storage')) { + return createResponse({ + success: true, + tables: [{ name: 'tracks', size: 2048 }], + total_file_size: 4096, + method: 'dbstat', + }); + } + if (url.includes('/api/stats/library-disk-usage')) { + return createResponse({ + success: true, + has_data: true, + total_bytes: 2048, + tracks_with_size: 12, + tracks_without_size: 0, + by_format: { flac: 2048 }, + }); + } + return createResponse({ success: true }); + }) as unknown as typeof fetch, + ); + + renderStatsRoute(); + + await waitFor(() => expect(screen.getByTestId('stats-page')).toBeInTheDocument()); + expect(await screen.findByText('Listening Stats')).toBeInTheDocument(); + expect(screen.getByText('Not synced yet')).toBeInTheDocument(); + }); + it('stores the time range in route search state', async () => { const { history } = renderStatsRoute(); @@ -125,6 +181,80 @@ describe('stats route', () => { ); }); + it('falls back to streaming when track resolution fails', async () => { + window.SoulSyncWebShellBridge = createShellBridge({ + startStream: vi.fn(), + }); + + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url.includes('/api/stats/cached')) { + return createResponse({ + success: true, + overview: { + total_plays: 24, + total_time_ms: 6_600_000, + unique_artists: 3, + unique_albums: 4, + unique_tracks: 12, + }, + top_artists: [{ id: 7, name: 'Artist A', play_count: 10 }], + top_albums: [], + top_tracks: [{ name: 'Track A', artist: 'Artist A', album: 'Album A', play_count: 3 }], + timeline: [{ date: 'May 10', plays: 4 }], + genres: [{ genre: 'House', play_count: 10, percentage: 80 }], + recent: [{ title: 'Track A', artist: 'Artist A', played_at: '2026-05-14T08:00:00Z' }], + health: { total_tracks: 12, format_breakdown: { FLAC: 12 } }, + }); + } + if (url.includes('/api/listening-stats/status')) { + return createResponse({ stats: { last_poll: '2026-05-14 10:00:00' } }); + } + if (url.includes('/api/stats/resolve-track')) { + return createResponse({ error: 'resolve unavailable' }, false, 500); + } + if (url.includes('/api/enhanced-search/stream-track')) { + return createResponse({ + success: true, + result: { stream_url: '/api/stream/1' }, + }); + } + if (url.includes('/api/stats/db-storage')) { + return createResponse({ + success: true, + tables: [{ name: 'tracks', size: 2048 }], + total_file_size: 4096, + method: 'dbstat', + }); + } + if (url.includes('/api/stats/library-disk-usage')) { + return createResponse({ + success: true, + has_data: true, + total_bytes: 2048, + tracks_with_size: 12, + tracks_without_size: 0, + by_format: { flac: 2048 }, + }); + } + return createResponse({ success: true }); + }) as unknown as typeof fetch, + ); + + renderStatsRoute(); + + fireEvent.click((await screen.findAllByTitle('Play'))[0]); + + await waitFor(() => + expect(window.SoulSyncWebShellBridge?.startStream).toHaveBeenCalledWith({ + stream_url: '/api/stream/1', + }), + ); + expect(window.SoulSyncWebShellBridge?.playLibraryTrack).not.toHaveBeenCalled(); + }); + it('redirects back home when the page is not allowed', async () => { window.SoulSyncWebShellBridge = createShellBridge({ isPageAllowed: vi.fn((pageId) => pageId !== 'stats'), diff --git a/webui/src/routes/stats/-stats.api.test.ts b/webui/src/routes/stats/-stats.api.test.ts index 9a91b93f..ab85853e 100644 --- a/webui/src/routes/stats/-stats.api.test.ts +++ b/webui/src/routes/stats/-stats.api.test.ts @@ -38,6 +38,46 @@ describe('stats api', () => { }); }); + it('returns an empty payload when cached stats are not available yet', async () => { + server.use( + http.get('/api/stats/cached', () => + HttpResponse.json({ success: false, error: 'Listening stats not synced yet' }), + ), + ); + + await expect(fetchStatsCached('7d')).resolves.toMatchObject({ + success: true, + overview: { total_plays: 0 }, + top_artists: [], + top_albums: [], + top_tracks: [], + timeline: [], + genres: [], + recent: [], + health: {}, + }); + }); + + it('returns an empty payload when the server reports a cache miss as an HTTP error', async () => { + server.use( + http.get('/api/stats/cached', () => + HttpResponse.json({ error: 'No cached stats available yet' }, { status: 500 }), + ), + ); + + await expect(fetchStatsCached('7d')).resolves.toMatchObject({ + success: true, + overview: { total_plays: 0 }, + top_artists: [], + top_albums: [], + top_tracks: [], + timeline: [], + genres: [], + recent: [], + health: {}, + }); + }); + it('surfaces db storage and disk usage errors', async () => { server.use( http.get('/api/stats/db-storage', () => diff --git a/webui/src/routes/stats/-stats.api.ts b/webui/src/routes/stats/-stats.api.ts index c8e69d9e..e0514233 100644 --- a/webui/src/routes/stats/-stats.api.ts +++ b/webui/src/routes/stats/-stats.api.ts @@ -1,4 +1,5 @@ import { queryOptions, type QueryClient } from '@tanstack/react-query'; +import { HTTPError } from 'ky'; import { apiClient, readJson } from '@/app/api-client'; @@ -12,18 +13,50 @@ import type { StatsStreamTrackPayload, } from './-stats.types'; +import { EMPTY_STATS_PAYLOAD } from './-stats.helpers'; + export const STATS_QUERY_KEY = ['stats'] as const; +const NO_STATS_YET_PATTERNS = [ + /not synced/i, + /no listening stats/i, + /no cached stats/i, + /cache miss/i, + /stats cache.*(missing|empty|not found)/i, +] as const; + +function isNoStatsYetMessage(message: string | undefined): boolean { + if (!message) return false; + return NO_STATS_YET_PATTERNS.some((pattern) => pattern.test(message)); +} + +function getEmptyStatsPayload(): StatsCachedPayload { + return { + success: true, + ...EMPTY_STATS_PAYLOAD, + }; +} + export async function fetchStatsCached(range: StatsRange): Promise { - const payload = await readJson( - apiClient.get('stats/cached', { - searchParams: { range }, - }), - ); - if (!payload.success) { - throw new Error(payload.error || 'Failed to load listening stats'); + try { + const payload = await readJson( + apiClient.get('stats/cached', { + searchParams: { range }, + }), + ); + if (!payload.success) { + if (isNoStatsYetMessage(payload.error)) { + return getEmptyStatsPayload(); + } + throw new Error(payload.error || 'Failed to load listening stats'); + } + return payload; + } catch (error) { + if (error instanceof HTTPError && isNoStatsYetMessage(error.message)) { + return getEmptyStatsPayload(); + } + throw error; } - return payload; } export async function fetchListeningStatsStatus(): Promise { diff --git a/webui/src/routes/stats/-ui/stats-page.tsx b/webui/src/routes/stats/-ui/stats-page.tsx index 988cf8c9..cf19f02e 100644 --- a/webui/src/routes/stats/-ui/stats-page.tsx +++ b/webui/src/routes/stats/-ui/stats-page.tsx @@ -871,22 +871,26 @@ async function playStatsTrack( bridge: ShellBridge, track: { title: string; artist: string; album: string }, ) { - const resolvedTrack = await resolveStatsTrack(track.title, track.artist); - if (resolvedTrack) { - await bridge.playLibraryTrack( - { - id: resolvedTrack.id, - title: resolvedTrack.title, - file_path: resolvedTrack.file_path, - bitrate: resolvedTrack.bitrate, - artist_id: resolvedTrack.artist_id, - album_id: resolvedTrack.album_id, - _stats_image: resolvedTrack.image_url || null, - }, - resolvedTrack.album_title || track.album, - resolvedTrack.artist_name || track.artist, - ); - return; + try { + const resolvedTrack = await resolveStatsTrack(track.title, track.artist); + if (resolvedTrack) { + await bridge.playLibraryTrack( + { + id: resolvedTrack.id, + title: resolvedTrack.title, + file_path: resolvedTrack.file_path, + bitrate: resolvedTrack.bitrate, + artist_id: resolvedTrack.artist_id, + album_id: resolvedTrack.album_id, + _stats_image: resolvedTrack.image_url || null, + }, + resolvedTrack.album_title || track.album, + resolvedTrack.artist_name || track.artist, + ); + return; + } + } catch { + // Library resolve is best-effort; fall through to stream lookup on failure. } bridge.showLoadingOverlay(`Searching for ${track.title}...`); diff --git a/webui/src/routes/stats/route.tsx b/webui/src/routes/stats/route.tsx index 364eb651..358d6f36 100644 --- a/webui/src/routes/stats/route.tsx +++ b/webui/src/routes/stats/route.tsx @@ -21,7 +21,12 @@ export const Route = createFileRoute('/stats')({ loader: async ({ context, deps }) => { await Promise.all([ context.queryClient.ensureQueryData(statsCachedQueryOptions(deps.range)), - context.queryClient.ensureQueryData(listeningStatsStatusQueryOptions()), + context.queryClient + .fetchQuery({ + ...listeningStatsStatusQueryOptions(), + retry: false, + }) + .catch(() => undefined), ]); }, component: StatsPage, From fec66e4de85bfa96bc39b4deead4ceb7dc5cf020 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Wed, 20 May 2026 20:38:19 +0300 Subject: [PATCH 16/26] feat(webui): expose shell status in root context - add a shared shell client and root /status query - attach shell status to the TanStack root context for React routes - keep the shell bridge types and test setup aligned with the new status data --- webui/src/app/api-client.ts | 9 +++++++ webui/src/platform/shell/bridge.ts | 6 ++++- .../src/platform/shell/route-controllers.tsx | 4 +++ webui/src/platform/shell/status.test.ts | 19 ++++++++++++++ webui/src/platform/shell/status.ts | 25 +++++++++++++++++++ webui/src/routes/__root.tsx | 11 +++++--- webui/vitest.setup.ts | 12 +++++++-- 7 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 webui/src/platform/shell/status.test.ts create mode 100644 webui/src/platform/shell/status.ts diff --git a/webui/src/app/api-client.ts b/webui/src/app/api-client.ts index fa54158e..73541181 100644 --- a/webui/src/app/api-client.ts +++ b/webui/src/app/api-client.ts @@ -6,12 +6,21 @@ const apiBaseUrl = typeof globalThis.location === 'object' ? new URL('/api/', globalThis.location.origin).toString() : 'http://localhost/api/'; +const shellBaseUrl = + typeof globalThis.location === 'object' + ? new URL('/', globalThis.location.origin).toString() + : 'http://localhost/'; export const apiClient = ky.create({ baseUrl: apiBaseUrl, retry: 0, }); +export const shellClient = ky.create({ + baseUrl: shellBaseUrl, + retry: 0, +}); + export async function readJson(promise: ResponsePromise): Promise { try { return await promise.json(); diff --git a/webui/src/platform/shell/bridge.ts b/webui/src/platform/shell/bridge.ts index 1c9683f5..cd56f64a 100644 --- a/webui/src/platform/shell/bridge.ts +++ b/webui/src/platform/shell/bridge.ts @@ -1,5 +1,7 @@ import type { AnyRouter } from '@tanstack/react-router'; +import type { ShellStatusPayload } from './status'; + import { getShellRouteByPageId, normalizeShellPath, @@ -17,6 +19,7 @@ export interface ShellProfileContext { export interface ShellContext { bridge: ShellBridge; profile: ShellProfileContext; + status?: ShellStatusPayload | null; } export type ShellBridge = NonNullable; @@ -95,7 +98,8 @@ export function bindWindowWebRouter(router: AnyRouter) { let href: `/${string}` = route.path; if (pageId === 'artist-detail' && options?.artistId) { const source = options.artistSource ? String(options.artistSource) : 'library'; - href = `/artist-detail/${encodeURIComponent(source)}/${encodeURIComponent(String(options.artistId))}` as `/${string}`; + href = + `/artist-detail/${encodeURIComponent(source)}/${encodeURIComponent(String(options.artistId))}` as `/${string}`; } await router.navigate({ href, replace: options?.replace === true }); diff --git a/webui/src/platform/shell/route-controllers.tsx b/webui/src/platform/shell/route-controllers.tsx index a08d3c66..309ed1d1 100644 --- a/webui/src/platform/shell/route-controllers.tsx +++ b/webui/src/platform/shell/route-controllers.tsx @@ -21,6 +21,10 @@ export function useProfile() { return useShellContext().profile; } +export function useShellStatus() { + return useShellContext().status ?? null; +} + export function LegacyRouteController({ pathname }: { pathname: string }) { const bridge = useShellBridge(); diff --git a/webui/src/platform/shell/status.test.ts b/webui/src/platform/shell/status.test.ts new file mode 100644 index 00000000..68c1bf6f --- /dev/null +++ b/webui/src/platform/shell/status.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { HttpResponse, http, server } from '@/test/msw'; + +import { fetchShellStatus } from './status'; + +describe('shell status', () => { + it('fetches the shell status payload', async () => { + server.use( + http.get('/status', () => + HttpResponse.json({ media_server: { type: 'plex', connected: true } }), + ), + ); + + await expect(fetchShellStatus()).resolves.toEqual({ + media_server: { type: 'plex', connected: true }, + }); + }); +}); diff --git a/webui/src/platform/shell/status.ts b/webui/src/platform/shell/status.ts new file mode 100644 index 00000000..47b53321 --- /dev/null +++ b/webui/src/platform/shell/status.ts @@ -0,0 +1,25 @@ +import { queryOptions } from '@tanstack/react-query'; + +import { readJson, shellClient } from '@/app/api-client'; + +export interface ShellStatusMediaServer { + type?: string | null; + connected?: boolean | null; +} + +export interface ShellStatusPayload { + media_server?: ShellStatusMediaServer | null; +} + +export async function fetchShellStatus(): Promise { + return await readJson(shellClient.get('status')); +} + +export function shellStatusQueryOptions() { + return queryOptions({ + queryKey: ['shell', 'status'] as const, + queryFn: fetchShellStatus, + staleTime: 30_000, + retry: false, + }); +} diff --git a/webui/src/routes/__root.tsx b/webui/src/routes/__root.tsx index d46454e1..44575f1c 100644 --- a/webui/src/routes/__root.tsx +++ b/webui/src/routes/__root.tsx @@ -3,13 +3,18 @@ import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'; import type { AppRouterContext } from '@/app/router'; import { waitForShellContext } from '@/platform/shell/bridge'; +import { shellStatusQueryOptions } from '@/platform/shell/status'; import { IssueDomainHost } from './issues/-ui/issue-domain-host'; export const Route = createRootRouteWithContext()({ - beforeLoad: async () => { - const shell = await waitForShellContext(); - return { shell }; + beforeLoad: async ({ context }) => { + const [shell, status] = await Promise.all([ + waitForShellContext(), + context.queryClient.fetchQuery(shellStatusQueryOptions()).catch(() => undefined), + ]); + + return { shell: { ...shell, status } }; }, component: () => ( <> diff --git a/webui/vitest.setup.ts b/webui/vitest.setup.ts index 2b86553f..c99c9e8d 100644 --- a/webui/vitest.setup.ts +++ b/webui/vitest.setup.ts @@ -1,12 +1,20 @@ import '@testing-library/jest-dom/vitest'; -import { afterAll, afterEach, beforeAll, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, vi } from 'vitest'; -import { server } from './src/test/msw'; +import { HttpResponse, http, server } from './src/test/msw'; beforeAll(() => { server.listen({ onUnhandledRequest: 'error' }); }); +beforeEach(() => { + server.use( + http.get('/status', () => + HttpResponse.json({ media_server: { type: 'plex', connected: true } }), + ), + ); +}); + afterEach(() => { server.resetHandlers(); }); From d0245e3e16f94a108304a45c544aa0de30eb0405 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Wed, 20 May 2026 20:38:29 +0300 Subject: [PATCH 17/26] test(webui): share shell bridge test helper - add a reusable shell bridge factory for legacy shell-backed tests - trim route and bridge fixtures down to only the overrides they need --- webui/src/app/router.test.tsx | 58 ++++--------------- webui/src/platform/shell/bridge.test.ts | 44 +++++--------- .../src/routes/artist-detail/-route.test.tsx | 21 +------ webui/src/routes/issues/-route.test.tsx | 23 +------- webui/src/test/shell-bridge.ts | 24 ++++++++ 5 files changed, 53 insertions(+), 117 deletions(-) create mode 100644 webui/src/test/shell-bridge.ts diff --git a/webui/src/app/router.test.tsx b/webui/src/app/router.test.tsx index 95f90e97..f2d1933c 100644 --- a/webui/src/app/router.test.tsx +++ b/webui/src/app/router.test.tsx @@ -4,31 +4,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SHELL_PROFILE_CONTEXT_CHANGED_EVENT, - type ShellBridge, type ShellProfileContext, type ShellPageId, } from '@/platform/shell/bridge'; +import { HttpResponse, http, server } from '@/test/msw'; +import { createShellBridge } from '@/test/shell-bridge'; import { createAppQueryClient } from './query-client'; import { AppRouterProvider, createAppRouter } from './router'; -function mockIssuesFetch() { - return vi.fn(async (input: RequestInfo | URL) => { - const url = input instanceof Request ? input.url : String(input); - - if (url.includes('/api/issues/counts')) { - return new Response( - JSON.stringify({ +describe('createAppRouter', () => { + beforeEach(() => { + server.use( + http.get('/api/issues/counts', () => + HttpResponse.json({ success: true, counts: { open: 2, in_progress: 1, resolved: 0, dismissed: 0, total: 3 }, }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); - } - - if (url.includes('/api/issues?')) { - return new Response( - JSON.stringify({ + ), + http.get('/api/issues', () => + HttpResponse.json({ success: true, total: 1, issues: [ @@ -44,37 +39,8 @@ function mockIssuesFetch() { }, ], }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); - } - - throw new Error(`Unexpected fetch request: ${url}`); - }); -} - -function createShellBridge(overrides: Partial = {}): ShellBridge { - return { - getCurrentProfileContext: vi.fn(() => ({ profileId: 1, isAdmin: false })), - isPageAllowed: vi.fn(() => true), - getProfileHomePage: vi.fn<() => ShellPageId>(() => 'discover'), - resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'search'), - setActivePageChrome: vi.fn(), - activateLegacyPath: vi.fn(), - navigateToArtistDetail: vi.fn(), - cancelSimilarArtistsLoad: vi.fn(), - showReactHost: vi.fn(), - navigateToArtistDetail: vi.fn(), - playLibraryTrack: vi.fn(), - startStream: vi.fn(), - showLoadingOverlay: vi.fn(), - hideLoadingOverlay: vi.fn(), - ...overrides, - }; -} - -describe('createAppRouter', () => { - beforeEach(() => { - vi.stubGlobal('fetch', mockIssuesFetch()); + ), + ); }); afterEach(() => { diff --git a/webui/src/platform/shell/bridge.test.ts b/webui/src/platform/shell/bridge.test.ts index 29546e29..80b363c2 100644 --- a/webui/src/platform/shell/bridge.test.ts +++ b/webui/src/platform/shell/bridge.test.ts @@ -1,8 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createShellBridge } from '@/test/shell-bridge'; + import type { ShellProfileContext } from './bridge'; -import { SHELL_PROFILE_CONTEXT_CHANGED_EVENT, bindWindowWebRouter, waitForShellContext } from './bridge'; +import { + SHELL_PROFILE_CONTEXT_CHANGED_EVENT, + bindWindowWebRouter, + waitForShellContext, +} from './bridge'; describe('waitForShellContext', () => { beforeEach(() => { @@ -10,21 +16,7 @@ describe('waitForShellContext', () => { }); it('resolves immediately when the shell already has a profile', async () => { - window.SoulSyncWebShellBridge = { - getProfileHomePage: vi.fn(() => 'discover'), - isPageAllowed: vi.fn(() => true), - activateLegacyPath: vi.fn(), - getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: true })), - resolveLegacyPath: vi.fn(() => 'issues'), - setActivePageChrome: vi.fn(), - cancelSimilarArtistsLoad: vi.fn(), - showReactHost: vi.fn(), - navigateToArtistDetail: vi.fn(), - playLibraryTrack: vi.fn(), - startStream: vi.fn(), - showLoadingOverlay: vi.fn(), - hideLoadingOverlay: vi.fn(), - } as NonNullable; + window.SoulSyncWebShellBridge = createShellBridge(); await expect(waitForShellContext()).resolves.toEqual({ bridge: window.SoulSyncWebShellBridge, @@ -37,21 +29,9 @@ describe('waitForShellContext', () => { it('waits for the legacy shell to publish profile context', async () => { const getCurrentProfileContext = vi.fn<() => ShellProfileContext | null>(() => null); - window.SoulSyncWebShellBridge = { - getProfileHomePage: vi.fn(() => 'discover'), - isPageAllowed: vi.fn(() => true), - activateLegacyPath: vi.fn(), + window.SoulSyncWebShellBridge = createShellBridge({ getCurrentProfileContext, - resolveLegacyPath: vi.fn(() => 'issues'), - setActivePageChrome: vi.fn(), - cancelSimilarArtistsLoad: vi.fn(), - showReactHost: vi.fn(), - navigateToArtistDetail: vi.fn(), - playLibraryTrack: vi.fn(), - startStream: vi.fn(), - showLoadingOverlay: vi.fn(), - hideLoadingOverlay: vi.fn(), - } as NonNullable; + }); const contextPromise = waitForShellContext(); @@ -106,7 +86,9 @@ describe('bindWindowWebRouter', () => { bindWindowWebRouter({ navigate } as never); - await expect(window.SoulSyncWebRouter?.navigateToPage('artist-detail', {} as never)).resolves.toBe(false); + await expect( + window.SoulSyncWebRouter?.navigateToPage('artist-detail', {} as never), + ).resolves.toBe(false); expect(navigate).not.toHaveBeenCalled(); }); }); diff --git a/webui/src/routes/artist-detail/-route.test.tsx b/webui/src/routes/artist-detail/-route.test.tsx index 232ff944..c1426ba9 100644 --- a/webui/src/routes/artist-detail/-route.test.tsx +++ b/webui/src/routes/artist-detail/-route.test.tsx @@ -2,25 +2,9 @@ import { createMemoryHistory } from '@tanstack/react-router'; import { render, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge'; - import { createAppQueryClient } from '@/app/query-client'; import { AppRouterProvider, createAppRouter } from '@/app/router'; - -function createShellBridge(overrides: Partial = {}): ShellBridge { - return { - getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: false })), - isPageAllowed: vi.fn(() => true), - getProfileHomePage: vi.fn<() => ShellPageId>(() => 'discover'), - resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'artist-detail'), - setActivePageChrome: vi.fn(), - activateLegacyPath: vi.fn(), - navigateToArtistDetail: vi.fn(), - cancelSimilarArtistsLoad: vi.fn(), - showReactHost: vi.fn(), - ...overrides, - }; -} +import { createShellBridge } from '@/test/shell-bridge'; function renderArtistDetailRoute(initialEntries = ['/artist-detail/library/42']) { const queryClient = createAppQueryClient(); @@ -87,7 +71,8 @@ describe('artist-detail route', () => { ); }); - const cancelSimilarArtistsLoad = window.SoulSyncWebShellBridge?.cancelSimilarArtistsLoad as ReturnType; + const cancelSimilarArtistsLoad = window.SoulSyncWebShellBridge + ?.cancelSimilarArtistsLoad as ReturnType; cancelSimilarArtistsLoad.mockClear(); unmount(); diff --git a/webui/src/routes/issues/-route.test.tsx b/webui/src/routes/issues/-route.test.tsx index 023e24b8..7a0abdcd 100644 --- a/webui/src/routes/issues/-route.test.tsx +++ b/webui/src/routes/issues/-route.test.tsx @@ -2,10 +2,9 @@ import { createMemoryHistory } from '@tanstack/react-router'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge'; - import { createAppQueryClient } from '@/app/query-client'; import { AppRouterProvider, createAppRouter } from '@/app/router'; +import { createShellBridge } from '@/test/shell-bridge'; function createResponse(body: unknown, ok = true, status = 200) { return new Response(JSON.stringify(body), { @@ -14,26 +13,6 @@ function createResponse(body: unknown, ok = true, status = 200) { }); } -function createShellBridge(overrides: Partial = {}): ShellBridge { - return { - getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: true })), - isPageAllowed: vi.fn(() => true), - getProfileHomePage: vi.fn<() => ShellPageId>(() => 'discover'), - resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'search'), - setActivePageChrome: vi.fn(), - activateLegacyPath: vi.fn(), - navigateToArtistDetail: vi.fn(), - cancelSimilarArtistsLoad: vi.fn(), - showReactHost: vi.fn(), - navigateToArtistDetail: vi.fn(), - playLibraryTrack: vi.fn(), - startStream: vi.fn(), - showLoadingOverlay: vi.fn(), - hideLoadingOverlay: vi.fn(), - ...overrides, - }; -} - function renderIssuesRoute(initialEntries = ['/issues']) { const queryClient = createAppQueryClient(); const history = createMemoryHistory({ initialEntries }); diff --git a/webui/src/test/shell-bridge.ts b/webui/src/test/shell-bridge.ts new file mode 100644 index 00000000..0c0cbe4b --- /dev/null +++ b/webui/src/test/shell-bridge.ts @@ -0,0 +1,24 @@ +import { vi } from 'vitest'; + +import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge'; + +export function createShellBridge(overrides: Partial = {}): ShellBridge { + const bridge: ShellBridge = { + getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: true })), + isPageAllowed: vi.fn(() => true), + getProfileHomePage: vi.fn<() => ShellPageId>(() => 'discover'), + resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'search'), + setActivePageChrome: vi.fn(), + activateLegacyPath: vi.fn(), + cancelSimilarArtistsLoad: vi.fn(), + navigateToArtistDetail: vi.fn(), + playLibraryTrack: vi.fn(), + showReactHost: vi.fn(), + startStream: vi.fn(), + showLoadingOverlay: vi.fn(), + hideLoadingOverlay: vi.fn(), + }; + + Object.assign(bridge, overrides); + return bridge; +} From 824b11875920aafead1d10b73e73f8a2c1705a0e Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 23 May 2026 23:00:32 +0300 Subject: [PATCH 18/26] refactor(webui): finish stats standalone handling in react - render the standalone notice directly in the React stats header - keep the legacy standalone sweep from hiding the stats control incorrectly - update the stats route test and header layout to match the new behavior --- webui/src/platform/shell/globals.d.ts | 6 - webui/src/routes/stats/-route.test.tsx | 250 ++++++------------ .../routes/stats/-ui/stats-page.module.css | 22 ++ webui/src/routes/stats/-ui/stats-page.tsx | 44 +-- webui/static/core.js | 1 + webui/static/shared-helpers.js | 1 + webui/static/shell-bridge.js | 1 - 7 files changed, 129 insertions(+), 196 deletions(-) diff --git a/webui/src/platform/shell/globals.d.ts b/webui/src/platform/shell/globals.d.ts index 5ee321c2..8b043665 100644 --- a/webui/src/platform/shell/globals.d.ts +++ b/webui/src/platform/shell/globals.d.ts @@ -45,12 +45,6 @@ declare global { ) => void; cancelSimilarArtistsLoad: () => void; showReactHost: (pageId: ShellPageId) => void; - navigateToArtistDetail: ( - artistId: string | number, - artistName: string, - sourceOverride?: string | null, - options?: Record, - ) => void; playLibraryTrack: ( track: { id: string | number; diff --git a/webui/src/routes/stats/-route.test.tsx b/webui/src/routes/stats/-route.test.tsx index bfc44343..ec251ae9 100644 --- a/webui/src/routes/stats/-route.test.tsx +++ b/webui/src/routes/stats/-route.test.tsx @@ -2,35 +2,10 @@ import { createMemoryHistory } from '@tanstack/react-router'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge'; - import { createAppQueryClient } from '@/app/query-client'; import { AppRouterProvider, createAppRouter } from '@/app/router'; - -function createResponse(body: unknown, ok = true, status = 200) { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }); -} - -function createShellBridge(overrides: Partial = {}): ShellBridge { - return { - getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: true })), - isPageAllowed: vi.fn(() => true), - getProfileHomePage: vi.fn<() => ShellPageId>(() => 'discover'), - resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'search'), - setActivePageChrome: vi.fn(), - activateLegacyPath: vi.fn(), - showReactHost: vi.fn(), - navigateToArtistDetail: vi.fn(), - playLibraryTrack: vi.fn(), - startStream: vi.fn(), - showLoadingOverlay: vi.fn(), - hideLoadingOverlay: vi.fn(), - ...overrides, - }; -} +import { HttpResponse, http, server } from '@/test/msw'; +import { createShellBridge } from '@/test/shell-bridge'; function renderStatsRoute(initialEntries = ['/stats']) { const queryClient = createAppQueryClient(); @@ -47,52 +22,50 @@ describe('stats route', () => { beforeEach(() => { window.SoulSyncWebShellBridge = createShellBridge(); window.showToast = vi.fn(); - vi.stubGlobal( - 'fetch', - vi.fn(async (input: RequestInfo | URL) => { - const url = input instanceof Request ? input.url : String(input); - if (url.includes('/api/stats/cached')) { - return createResponse({ - success: true, - overview: { - total_plays: 24, - total_time_ms: 6_600_000, - unique_artists: 3, - unique_albums: 4, - unique_tracks: 12, - }, - top_artists: [{ id: 7, name: 'Artist A', play_count: 10 }], - top_albums: [], - top_tracks: [], - timeline: [{ date: 'May 10', plays: 4 }], - genres: [{ genre: 'House', play_count: 10, percentage: 80 }], - recent: [{ title: 'Track A', artist: 'Artist A', played_at: '2026-05-14T08:00:00Z' }], - health: { total_tracks: 12, format_breakdown: { FLAC: 12 } }, - }); - } - if (url.includes('/api/listening-stats/status')) { - return createResponse({ stats: { last_poll: '2026-05-14 10:00:00' } }); - } - if (url.includes('/api/stats/db-storage')) { - return createResponse({ - success: true, - tables: [{ name: 'tracks', size: 2048 }], - total_file_size: 4096, - method: 'dbstat', - }); - } - if (url.includes('/api/stats/library-disk-usage')) { - return createResponse({ - success: true, - has_data: true, - total_bytes: 2048, - tracks_with_size: 12, - tracks_without_size: 0, - by_format: { flac: 2048 }, - }); - } - return createResponse({ success: true }); - }) as unknown as typeof fetch, + server.use( + http.get('/api/stats/cached', () => + HttpResponse.json({ + success: true, + overview: { + total_plays: 24, + total_time_ms: 6_600_000, + unique_artists: 3, + unique_albums: 4, + unique_tracks: 12, + }, + top_artists: [{ id: 7, name: 'Artist A', play_count: 10 }], + top_albums: [], + top_tracks: [], + timeline: [{ date: 'May 10', plays: 4 }], + genres: [{ genre: 'House', play_count: 10, percentage: 80 }], + recent: [{ title: 'Track A', artist: 'Artist A', played_at: '2026-05-14T08:00:00Z' }], + health: { total_tracks: 12, format_breakdown: { FLAC: 12 } }, + }), + ), + http.get('/api/listening-stats/status', () => + HttpResponse.json({ stats: { last_poll: '2026-05-14 10:00:00' } }), + ), + http.get('/status', () => + HttpResponse.json({ media_server: { type: 'plex', connected: true } }), + ), + http.get('/api/stats/db-storage', () => + HttpResponse.json({ + success: true, + tables: [{ name: 'tracks', size: 2048 }], + total_file_size: 4096, + method: 'dbstat', + }), + ), + http.get('/api/stats/library-disk-usage', () => + HttpResponse.json({ + success: true, + has_data: true, + total_bytes: 2048, + tracks_with_size: 12, + tracks_without_size: 0, + by_format: { flac: 2048 }, + }), + ), ); }); @@ -107,52 +80,10 @@ describe('stats route', () => { }); it('still renders when listening stats status prefetch fails', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async (input: RequestInfo | URL) => { - const url = input instanceof Request ? input.url : String(input); - if (url.includes('/api/stats/cached')) { - return createResponse({ - success: true, - overview: { - total_plays: 24, - total_time_ms: 6_600_000, - unique_artists: 3, - unique_albums: 4, - unique_tracks: 12, - }, - top_artists: [{ id: 7, name: 'Artist A', play_count: 10 }], - top_albums: [], - top_tracks: [], - timeline: [{ date: 'May 10', plays: 4 }], - genres: [{ genre: 'House', play_count: 10, percentage: 80 }], - recent: [{ title: 'Track A', artist: 'Artist A', played_at: '2026-05-14T08:00:00Z' }], - health: { total_tracks: 12, format_breakdown: { FLAC: 12 } }, - }); - } - if (url.includes('/api/listening-stats/status')) { - return createResponse({ error: 'status unavailable' }, false, 500); - } - if (url.includes('/api/stats/db-storage')) { - return createResponse({ - success: true, - tables: [{ name: 'tracks', size: 2048 }], - total_file_size: 4096, - method: 'dbstat', - }); - } - if (url.includes('/api/stats/library-disk-usage')) { - return createResponse({ - success: true, - has_data: true, - total_bytes: 2048, - tracks_with_size: 12, - tracks_without_size: 0, - by_format: { flac: 2048 }, - }); - } - return createResponse({ success: true }); - }) as unknown as typeof fetch, + server.use( + http.get('/api/listening-stats/status', () => + HttpResponse.json({ error: 'status unavailable' }, { status: 500 }), + ), ); renderStatsRoute(); @@ -160,6 +91,22 @@ describe('stats route', () => { await waitFor(() => expect(screen.getByTestId('stats-page')).toBeInTheDocument()); expect(await screen.findByText('Listening Stats')).toBeInTheDocument(); expect(screen.getByText('Not synced yet')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Sync listening stats' })).toBeInTheDocument(); + }); + + it('shows an explicit standalone notice instead of the sync button', async () => { + server.use( + http.get('/status', () => + HttpResponse.json({ media_server: { type: 'soulsync', connected: true } }), + ), + ); + + renderStatsRoute(); + + await waitFor(() => expect(screen.getByTestId('stats-page')).toBeInTheDocument()); + expect(await screen.findByText('Listening Stats')).toBeInTheDocument(); + expect(screen.getByText('Standalone mode: manual sync unavailable')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Sync listening stats' })).not.toBeInTheDocument(); }); it('stores the time range in route search state', async () => { @@ -186,61 +133,16 @@ describe('stats route', () => { startStream: vi.fn(), }); - vi.stubGlobal( - 'fetch', - vi.fn(async (input: RequestInfo | URL) => { - const url = input instanceof Request ? input.url : String(input); - if (url.includes('/api/stats/cached')) { - return createResponse({ - success: true, - overview: { - total_plays: 24, - total_time_ms: 6_600_000, - unique_artists: 3, - unique_albums: 4, - unique_tracks: 12, - }, - top_artists: [{ id: 7, name: 'Artist A', play_count: 10 }], - top_albums: [], - top_tracks: [{ name: 'Track A', artist: 'Artist A', album: 'Album A', play_count: 3 }], - timeline: [{ date: 'May 10', plays: 4 }], - genres: [{ genre: 'House', play_count: 10, percentage: 80 }], - recent: [{ title: 'Track A', artist: 'Artist A', played_at: '2026-05-14T08:00:00Z' }], - health: { total_tracks: 12, format_breakdown: { FLAC: 12 } }, - }); - } - if (url.includes('/api/listening-stats/status')) { - return createResponse({ stats: { last_poll: '2026-05-14 10:00:00' } }); - } - if (url.includes('/api/stats/resolve-track')) { - return createResponse({ error: 'resolve unavailable' }, false, 500); - } - if (url.includes('/api/enhanced-search/stream-track')) { - return createResponse({ - success: true, - result: { stream_url: '/api/stream/1' }, - }); - } - if (url.includes('/api/stats/db-storage')) { - return createResponse({ - success: true, - tables: [{ name: 'tracks', size: 2048 }], - total_file_size: 4096, - method: 'dbstat', - }); - } - if (url.includes('/api/stats/library-disk-usage')) { - return createResponse({ - success: true, - has_data: true, - total_bytes: 2048, - tracks_with_size: 12, - tracks_without_size: 0, - by_format: { flac: 2048 }, - }); - } - return createResponse({ success: true }); - }) as unknown as typeof fetch, + server.use( + http.post('/api/stats/resolve-track', () => + HttpResponse.json({ error: 'resolve unavailable' }, { status: 500 }), + ), + http.post('/api/enhanced-search/stream-track', () => + HttpResponse.json({ + success: true, + result: { stream_url: '/api/stream/1' }, + }), + ), ); renderStatsRoute(); diff --git a/webui/src/routes/stats/-ui/stats-page.module.css b/webui/src/routes/stats/-ui/stats-page.module.css index 051fbc03..17be6814 100644 --- a/webui/src/routes/stats/-ui/stats-page.module.css +++ b/webui/src/routes/stats/-ui/stats-page.module.css @@ -94,6 +94,9 @@ gap: 16px; position: relative; z-index: 1; + min-width: 0; + flex-wrap: wrap; + justify-content: flex-end; } .statsTimeRange { @@ -133,11 +136,25 @@ display: flex; align-items: center; gap: 8px; + min-width: 0; } .statsLastSynced { + flex: 1 1 auto; + min-width: 0; font-size: 0.72rem; color: rgba(255, 255, 255, 0.3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.statsStandaloneNotice { + max-width: 260px; + font-size: 0.72rem; + line-height: 1.35; + color: rgba(255, 255, 255, 0.38); + text-align: right; } .statsSyncButton { @@ -863,6 +880,11 @@ justify-content: space-between; } + .statsStandaloneNotice { + max-width: none; + text-align: left; + } + .headerIcon { width: 100px; height: 100px; diff --git a/webui/src/routes/stats/-ui/stats-page.tsx b/webui/src/routes/stats/-ui/stats-page.tsx index cf19f02e..63259a7d 100644 --- a/webui/src/routes/stats/-ui/stats-page.tsx +++ b/webui/src/routes/stats/-ui/stats-page.tsx @@ -15,7 +15,7 @@ import { import type { ShellBridge } from '@/platform/shell/bridge'; -import { useReactPageShell } from '@/platform/shell/route-controllers'; +import { useReactPageShell, useShellStatus } from '@/platform/shell/route-controllers'; import type { StatsAlbumRow, @@ -125,6 +125,8 @@ export function StatsPage() { const overview = cachedStats?.overview ?? EMPTY_STATS_OVERVIEW; const hasData = hasStatsData(overview); const lastSynced = listeningStatusQuery.data?.stats?.last_poll ?? null; + const shellStatus = useShellStatus(); + const isStandalone = shellStatus?.media_server?.type === 'soulsync'; const onRangeChange = (nextRange: StatsRange) => { void navigate({ @@ -166,20 +168,32 @@ export function StatsPage() { ))}
- - {lastSynced ? `Last synced: ${lastSynced}` : 'Not synced yet'} - - + {isStandalone ? ( + + Standalone mode: manual sync unavailable + + ) : ( + <> + + {lastSynced ? `Last synced: ${lastSynced}` : 'Not synced yet'} + + + + )}
diff --git a/webui/static/core.js b/webui/static/core.js index f1b047f6..8e272f10 100644 --- a/webui/static/core.js +++ b/webui/static/core.js @@ -515,6 +515,7 @@ function handleServiceStatusUpdate(data) { const isSoulsyncStandalone = data.media_server?.type === 'soulsync'; _isSoulsyncStandalone = isSoulsyncStandalone; document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => { + if (btn.id === 'stats-sync-btn') return; // React stats page owns this control now. if (isSoulsyncStandalone) { btn.dataset.hiddenByStandalone = '1'; btn.style.display = 'none'; diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index 3fc8be27..43c184f9 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -3199,6 +3199,7 @@ async function fetchAndUpdateServiceStatus() { const isSoulsyncStandalone2 = data.media_server?.type === 'soulsync'; _isSoulsyncStandalone = isSoulsyncStandalone2; document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => { + if (btn.id === 'stats-sync-btn') return; // React stats page owns this control now. if (isSoulsyncStandalone2) { btn.dataset.hiddenByStandalone = '1'; btn.style.display = 'none'; diff --git a/webui/static/shell-bridge.js b/webui/static/shell-bridge.js index c1ac1d1b..af9af731 100644 --- a/webui/static/shell-bridge.js +++ b/webui/static/shell-bridge.js @@ -168,7 +168,6 @@ window.SoulSyncWebShellBridge = { activateLegacyPath(pathname) { activateLegacyPath(pathname); }, - navigateToArtistDetail, cancelSimilarArtistsLoad() { if (typeof cancelSimilarArtistsLoad === 'function') { cancelSimilarArtistsLoad(); From 6c226613bf5be1d3198ead0e9dc7ffb5ba443e9f Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 23 May 2026 15:08:21 -0700 Subject: [PATCH 19/26] Add Soulseek album bundle downloads Route primary Soulseek album downloads through the album-bundle staging flow, reusing preflight-selected folders when available. In hybrid mode, only the first configured source can claim whole-album bundle behavior so later Soulseek fallback keeps the existing per-track/source-reuse path. Allow Soulseek album bundles to stage completed tracks when some same-source transfers fail or time out, and keep partial bundles from blocking per-track fallback. Add coverage for dispatcher gating, master flow ordering, task-worker staged-miss behavior, and Soulseek bundle polling. --- core/downloads/album_bundle_dispatch.py | 28 +- core/downloads/master.py | 84 ++++- core/downloads/task_worker.py | 14 +- core/soulseek_client.py | 326 ++++++++++++++++++ tests/downloads/test_downloads_master.py | 158 +++++++++ tests/downloads/test_downloads_task_worker.py | 86 +++++ tests/downloads/test_soulseek_pinning.py | 148 ++++++++ tests/test_album_bundle_dispatch.py | 53 ++- 8 files changed, 872 insertions(+), 25 deletions(-) diff --git a/core/downloads/album_bundle_dispatch.py b/core/downloads/album_bundle_dispatch.py index 6a74914f..09ec1a46 100644 --- a/core/downloads/album_bundle_dispatch.py +++ b/core/downloads/album_bundle_dispatch.py @@ -7,8 +7,9 @@ can be unit-tested in isolation. The gate fires only when ALL conditions hold: - Batch is an album-context download (``is_album_download`` flag). -- Active download source is ``torrent`` or ``usenet`` (single-source - mode β€” hybrid stays per-track to preserve fallback). +- Active download source is ``torrent``, ``usenet``, or ``soulseek``. + In hybrid mode the caller may pass the first configured source as a + source override; later hybrid sources stay per-track to preserve fallback. - Both album-name and artist-name are populated in batch context. - The resolved plugin exposes ``download_album_to_staging``. @@ -57,7 +58,7 @@ class BatchStateAccess(Protocol): # so the Downloads page can render it without coupling to the # specific payload shape. _MIRRORED_KEYS = ('progress', 'release', 'speed', 'downloaded', - 'size', 'seeders', 'grabs', 'count') + 'size', 'seeders', 'grabs', 'count', 'failed') def is_eligible( @@ -72,7 +73,7 @@ def is_eligible( the gate logic without standing up a plugin.""" if not is_album: return False - if (mode or '').lower() not in ('torrent', 'usenet'): + if (mode or '').lower() not in ('torrent', 'usenet', 'soulseek'): return False if not (album_name or '').strip(): return False @@ -90,6 +91,8 @@ def try_dispatch( config_get: Callable[..., Any], plugin_resolver: Callable[[str], Optional[Any]], state: BatchStateAccess, + source_override: Optional[str] = None, + plugin_kwargs: Optional[dict] = None, ) -> bool: """Attempt the album-bundle flow. Returns ``True`` iff the master worker should return early (gate engaged and completed @@ -102,7 +105,7 @@ def try_dispatch( BatchStateAccess shim. Injecting these keeps the module dependency-light + unit-testable. """ - mode = (config_get('download_source.mode', 'soulseek') or 'soulseek').lower() + mode = (source_override or config_get('download_source.mode', 'soulseek') or 'soulseek').lower() album_name = (album_context or {}).get('name') or '' artist_name = (artist_context or {}).get('name') or '' @@ -159,6 +162,7 @@ def try_dispatch( try: outcome = plugin.download_album_to_staging( album_name, artist_name, staging_dir, _emit, + **(plugin_kwargs or {}), ) except Exception as exc: logger.exception("[Album Bundle] %s plugin raised: %s", mode, exc) @@ -166,6 +170,17 @@ def try_dispatch( if not outcome.get('success'): err = outcome.get('error', 'Album bundle download failed') + if outcome.get('fallback'): + logger.warning( + "[Album Bundle] %s flow could not commit for '%s': %s β€” falling back to per-track flow", + mode, album_name, err, + ) + state.update_fields(batch_id, { + 'phase': 'analysis', + 'album_bundle_state': 'fallback', + 'album_bundle_error': err, + }) + return False logger.error("[Album Bundle] %s flow failed for '%s': %s", mode, album_name, err) state.mark_failed(batch_id, err) @@ -178,6 +193,9 @@ def try_dispatch( state.update_fields(batch_id, { 'phase': 'analysis', 'album_bundle_state': 'staged', + 'album_bundle_partial': bool(outcome.get('partial')), + 'album_bundle_expected_count': outcome.get('expected_count'), + 'album_bundle_completed_count': outcome.get('completed_count', len(outcome.get('files', []))), }) # Engaged-and-succeeded: we DON'T early-return because the # per-track flow needs to run to create + complete the per-track diff --git a/core/downloads/master.py b/core/downloads/master.py index 90adbb4e..83842f17 100644 --- a/core/downloads/master.py +++ b/core/downloads/master.py @@ -50,6 +50,7 @@ _VARIANT_WORDS = { 'remix', 'rmx', 'acapella', 'a cappella', 'instrumental', 'karaoke', 'live', 'demo', 'extended', } +_ALBUM_BUNDLE_SOURCES = frozenset(('torrent', 'usenet', 'soulseek')) def _norm_text(value: Any) -> str: @@ -245,6 +246,29 @@ def _soulseek_album_preflight_enabled(config_manager: Any) -> bool: return primary == 'soulseek' +def _resolve_album_bundle_source(config_manager: Any) -> str: + """Return the album-bundle source for this batch. + + In single-source mode, the active source may own the whole album if + it supports album bundles. In hybrid mode, only the first source in + the configured order may claim the whole album; later sources remain + per-track fallback. + """ + mode = (config_manager.get('download_source.mode', 'soulseek') or 'soulseek').lower() + if mode in _ALBUM_BUNDLE_SOURCES: + return mode + if mode != 'hybrid': + return '' + + order = config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek']) + first = '' + if order: + first = str(order[0] or '').lower() + else: + first = str(config_manager.get('download_source.hybrid_primary', '') or '').lower() + return first if first in _ALBUM_BUNDLE_SOURCES else '' + + @dataclass class MasterDeps: """Bundle of cross-cutting deps the master worker needs.""" @@ -338,16 +362,19 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma # should stop (gate fired and failed); False = engaged-and- # succeeded OR didn't engage, both fall through to per-track. _bundle_state = _BatchStateAccessImpl() - if _album_bundle_dispatch.try_dispatch( - batch_id=batch_id, - is_album=batch_is_album, - album_context=batch_album_context, - artist_context=batch_artist_context, - config_get=deps.config_manager.get, - plugin_resolver=deps.download_orchestrator.client, - state=_bundle_state, - ): - return + _album_bundle_source = _resolve_album_bundle_source(deps.config_manager) + if _album_bundle_source and _album_bundle_source != 'soulseek': + if _album_bundle_dispatch.try_dispatch( + batch_id=batch_id, + is_album=batch_is_album, + album_context=batch_album_context, + artist_context=batch_artist_context, + config_get=deps.config_manager.get, + plugin_resolver=deps.download_orchestrator.client, + state=_bundle_state, + source_override=_album_bundle_source, + ): + return # Allow duplicate tracks across albums β€” when enabled, only skip tracks already # owned in THIS album, not tracks owned in other albums @@ -640,6 +667,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma batch_album_context = batch.get('album_context') batch_artist_context = batch.get('artist_context') batch_is_album = batch.get('is_album_download', False) + batch_private_album_bundle = bool(batch.get('album_bundle_private_staging')) batch_playlist_folder_mode = batch.get('playlist_folder_mode', False) batch_playlist_name = batch.get('playlist_name', 'Unknown Playlist') @@ -648,7 +676,8 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma preflight_source = None preflight_tracks = None soulseek_is_source = _soulseek_album_preflight_enabled(deps.config_manager) - if batch_is_album and batch_album_context and batch_artist_context and soulseek_is_source: + if (batch_is_album and batch_album_context and batch_artist_context + and soulseek_is_source and not batch_private_album_bundle): artist_name = batch_artist_context.get('name', '') album_name = batch_album_context.get('name', '') if artist_name and album_name: @@ -761,13 +790,44 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma logger.error(f"[Album Pre-flight] Search failed (non-fatal, falling back to track-by-track): {preflight_err}") deps.source_reuse_logger.info(f"[Album Pre-flight] Exception: {preflight_err}") + # Soulseek album bundles run after analysis so an already-owned + # album does not get downloaded just because the source supports a + # whole-folder flow. When preflight selected a folder, pass that + # exact source into the bundle downloader so we keep the richer + # tracklist-aware scoring instead of doing a weaker second pick. + _bundle_state = _BatchStateAccessImpl() + _album_bundle_source = _resolve_album_bundle_source(deps.config_manager) + if _album_bundle_source == 'soulseek': + if _album_bundle_dispatch.try_dispatch( + batch_id=batch_id, + is_album=batch_is_album, + album_context=batch_album_context, + artist_context=batch_artist_context, + config_get=deps.config_manager.get, + plugin_resolver=deps.download_orchestrator.client, + state=_bundle_state, + source_override=_album_bundle_source, + plugin_kwargs={ + 'preferred_source': preflight_source, + 'preferred_tracks': preflight_tracks, + } if preflight_source and preflight_tracks else None, + ): + return + with tasks_lock: if batch_id not in download_batches: return download_batches[batch_id]['phase'] = 'downloading' # Store album pre-flight results on batch for source reuse - if preflight_source and preflight_tracks: + # unless the Soulseek album-bundle path already staged a private + # release. Task workers check source reuse before staging match, so + # preloading here would make the staged happy path re-download. + if ( + preflight_source + and preflight_tracks + and not download_batches[batch_id].get('album_bundle_private_staging') + ): download_batches[batch_id]['last_good_source'] = preflight_source download_batches[batch_id]['source_folder_tracks'] = preflight_tracks download_batches[batch_id]['failed_sources'] = set() diff --git a/core/downloads/task_worker.py b/core/downloads/task_worker.py index 67b2a3fe..77cb803f 100644 --- a/core/downloads/task_worker.py +++ b/core/downloads/task_worker.py @@ -34,7 +34,7 @@ logger = logging.getLogger(__name__) def _private_album_bundle_staging_miss_reason(batch_id: Optional[str], deps: Any) -> Optional[str]: """Return a user-facing miss reason when per-track search should stop. - Torrent / usenet album batches first download one private staged release, + Torrent / usenet / Soulseek album batches first download one private staged release, then each track claims the matching staged file. If that claim fails after the release is already staged, falling through to the normal per-track search only retries release-level sources N times and can keep re-adding @@ -49,11 +49,19 @@ def _private_album_bundle_staging_miss_reason(batch_id: Optional[str], deps: Any source = (batch.get('album_bundle_source') or '').lower() mode = (getattr(deps.download_orchestrator, 'mode', '') or '').lower() + hybrid_first = '' + if mode == 'hybrid': + order = getattr(deps.download_orchestrator, 'hybrid_order', None) or [] + if order: + hybrid_first = str(order[0] or '').lower() + else: + hybrid_first = str(getattr(deps.download_orchestrator, 'hybrid_primary', '') or '').lower() if ( batch.get('album_bundle_private_staging') and batch.get('album_bundle_state') == 'staged' - and source in ('torrent', 'usenet') - and mode == source + and not batch.get('album_bundle_partial') + and source in ('torrent', 'usenet', 'soulseek') + and (mode == source or (mode == 'hybrid' and hybrid_first == source)) ): return f'Track was not found in the staged {source} album release' diff --git a/core/soulseek_client.py b/core/soulseek_client.py index 71b20345..8a7f82dd 100644 --- a/core/soulseek_client.py +++ b/core/soulseek_client.py @@ -18,7 +18,13 @@ from core.download_plugins.types import ( SearchResult, TrackResult, ) +from core.download_plugins.album_bundle import ( + copy_audio_files_atomically, + get_poll_interval, + get_poll_timeout, +) from core.download_plugins.base import DownloadSourcePlugin +from utils.async_helpers import run_async logger = get_logger("soulseek_client") @@ -1456,6 +1462,326 @@ class SoulseekClient(DownloadSourcePlugin): logger.info(f"Downloading: {best_result.filename} ({quality_info}) from {best_result.username}") return await self.download(best_result.username, best_result.filename, best_result.size) + + def download_album_to_staging( + self, + album_name: str, + artist_name: str, + staging_dir: str, + progress_callback=None, + *, + preferred_source: Optional[Dict[str, Any]] = None, + preferred_tracks: Optional[List[TrackResult]] = None, + ) -> Dict[str, Any]: + """One-shot Soulseek album download. + + Search for one album folder, enqueue files from that single + ``username + folder_path``, wait for slskd to report completion, + then copy completed files into the private album-bundle staging + directory. If the folder cannot be selected or enqueued cleanly, + callers may fall back to the existing per-track Soulseek flow. + Once files are staged, the per-track staging matcher owns final + import, same as torrent / usenet album bundles. + """ + result: Dict[str, Any] = { + 'success': False, + 'files': [], + 'error': None, + 'fallback': True, + 'partial': False, + } + if not self.is_configured(): + result['error'] = 'Soulseek source not configured' + return result + + def _emit(state: str, **extra) -> None: + if progress_callback: + try: + progress_callback({'state': state, **extra}) + except Exception as cb_exc: + logger.debug("[Soulseek album] progress callback failed: %s", cb_exc) + + picked = None + folder_tracks = list(preferred_tracks or []) + username = (preferred_source or {}).get('username', '') if preferred_source else '' + folder_path = (preferred_source or {}).get('folder_path', '') if preferred_source else '' + if username and folder_path: + logger.info( + "[Soulseek album] Using preflight-selected folder %s:%s", + username, + folder_path, + ) + _emit('searching', query=f"{artist_name} {album_name}".strip(), release=folder_path) + else: + query = f"{artist_name} {album_name}".strip() + _emit('searching', query=query) + try: + _, albums = run_async(self.search(query, timeout=30)) + except Exception as exc: + result['error'] = f'Soulseek album search failed: {exc}' + return result + + if not albums: + result['error'] = 'No complete Soulseek album folders found' + return result + + picked = self._pick_album_bundle_folder(albums, album_name, artist_name) + if picked is None: + result['error'] = 'No suitable Soulseek album folder after filtering' + return result + + folder_path = getattr(picked, 'album_path', '') or '' + username = getattr(picked, 'username', '') or '' + if not username or not folder_path: + result['error'] = 'No suitable Soulseek album folder after filtering' + return result + + logger.info( + "[Soulseek album] Picked %s:%s (%s tracks, quality=%s)", + username, + folder_path, + getattr(picked, 'track_count', 0), + getattr(picked, 'dominant_quality', ''), + ) + _emit( + 'queued', + release=getattr(picked, 'album_title', folder_path) if picked else folder_path, + count=getattr(picked, 'track_count', 0) if picked else len(folder_tracks), + ) + + if not folder_tracks: + try: + browse_files = run_async(self.browse_user_directory(username, folder_path)) + except Exception as exc: + result['error'] = f'Soulseek folder browse failed: {exc}' + return result + + if not browse_files: + result['error'] = 'Could not browse selected Soulseek album folder' + return result + + folder_tracks = self.parse_browse_results_to_tracks( + username, + browse_files, + directory=folder_path, + ) + folder_tracks = self.filter_results_by_quality_preference(folder_tracks) + if not folder_tracks: + result['error'] = 'Selected Soulseek album folder contained no audio files' + return result + + transfer_keys: Dict[tuple, TrackResult] = {} + _emit( + 'downloading', + release=getattr(picked, 'album_title', folder_path) if picked else folder_path, + count=len(folder_tracks), + ) + for track in folder_tracks: + try: + download_id = run_async(self.download(track.username, track.filename, track.size)) + except Exception as exc: + logger.warning("[Soulseek album] Failed to enqueue %s: %s", track.filename, exc) + continue + if download_id: + transfer_keys[(track.username, track.filename)] = track + + if not transfer_keys: + result['error'] = 'No Soulseek album files could be enqueued' + return result + + result['fallback'] = False + completed = self._poll_album_bundle_downloads(transfer_keys, _emit) + if not completed: + result['error'] = 'Soulseek album download failed or timed out' + return result + + _emit('staging', release=getattr(picked, 'album_title', folder_path) if picked else folder_path) + copied = copy_audio_files_atomically(completed, Path(staging_dir)) + if not copied: + result['error'] = 'No Soulseek album files copied to staging' + return result + + partial = len(copied) < len(transfer_keys) + if partial: + logger.warning( + "[Soulseek album] Staged partial album for '%s': %d/%d files", + album_name, + len(copied), + len(transfer_keys), + ) + else: + logger.info("[Soulseek album] Staged %d files for '%s'", len(copied), album_name) + _emit('staged', count=len(copied)) + result['success'] = True + result['files'] = copied + result['partial'] = partial + result['expected_count'] = len(transfer_keys) + result['completed_count'] = len(copied) + return result + + def _pick_album_bundle_folder( + self, + albums: List[AlbumResult], + album_name: str, + artist_name: str, + ) -> Optional[AlbumResult]: + scored = [] + for album in albums: + tracks = self.filter_results_by_quality_preference(list(getattr(album, 'tracks', []) or [])) + if not tracks: + continue + album_text = f"{getattr(album, 'album_title', '')} {getattr(album, 'album_path', '')}" + artist_text = f"{getattr(album, 'artist', '')} {getattr(album, 'album_path', '')}" + album_score = self._bundle_similarity(album_name, album_text) + artist_score = self._bundle_similarity(artist_name, artist_text) + track_count = int(getattr(album, 'track_count', 0) or len(tracks)) + count_score = 1.0 if track_count >= 3 else 0.35 + score = ( + album_score * 0.42 + + artist_score * 0.22 + + count_score * 0.12 + + min(1.0, len(tracks) / max(1, track_count)) * 0.12 + + float(getattr(album, 'quality_score', 0.0) or 0.0) * 0.12 + ) + scored.append((score, len(tracks), album)) + if not scored: + return None + scored.sort(key=lambda row: (row[0], row[1], getattr(row[2], 'quality_score', 0.0)), reverse=True) + best_score, _, best = scored[0] + if best_score < 0.58: + logger.warning("[Soulseek album] Best folder score %.3f below threshold", best_score) + return None + return best + + @staticmethod + def _bundle_similarity(expected: Any, actual: Any) -> float: + import re + from difflib import SequenceMatcher + left = re.sub(r'[^a-z0-9]+', ' ', str(expected or '').lower()).strip() + right = re.sub(r'[^a-z0-9]+', ' ', str(actual or '').lower()).strip() + if not left or not right: + return 0.0 + if left == right: + return 1.0 + left_words = set(left.split()) + right_words = set(right.split()) + if left_words and left_words.issubset(right_words): + return 0.92 + if right_words and right_words.issubset(left_words): + return 0.86 + if left in right or right in left: + return min(len(left), len(right)) / max(len(left), len(right)) + return SequenceMatcher(None, left, right).ratio() + + def _poll_album_bundle_downloads(self, transfer_keys: Dict[tuple, TrackResult], emit) -> List[Path]: + deadline = time.monotonic() + get_poll_timeout() + interval = get_poll_interval() + completed_paths: Dict[tuple, Path] = {} + failed_states: Dict[tuple, str] = {} + while time.monotonic() < deadline: + try: + downloads = run_async(self.get_all_downloads()) + except Exception as exc: + logger.warning("[Soulseek album] Poll error: %s", exc) + downloads = [] + + by_key = {} + for dl in downloads: + exact_key = (dl.username, dl.filename) + by_key[exact_key] = dl + basename_key = ( + dl.username, + os.path.basename((dl.filename or '').replace('\\', '/')), + ) + by_key.setdefault(basename_key, dl) + for key, track in transfer_keys.items(): + if key in completed_paths or key in failed_states: + continue + dl = by_key.get(key) or by_key.get(( + key[0], + os.path.basename((key[1] or '').replace('\\', '/')), + )) + state = (getattr(dl, 'state', '') or '') if dl else '' + if any(token in state for token in ('Errored', 'Failed', 'Rejected', 'TimedOut')): + failed_states[key] = state or 'Failed' + logger.warning( + "[Soulseek album] Transfer failed from selected folder: %s (%s)", + os.path.basename((track.filename or '').replace('\\', '/')), + failed_states[key], + ) + continue + if dl and ('Completed' in state or 'Succeeded' in state): + if dl.size and dl.transferred and dl.transferred < dl.size: + continue + path = self._resolve_downloaded_album_file(track.filename) + if path: + completed_paths[key] = path + else: + logger.debug( + "[Soulseek album] Transfer completed but local file not found yet: %s", + track.filename, + ) + emit( + 'downloading', + progress=round(len(completed_paths) / max(1, len(transfer_keys)) * 100, 1), + count=len(completed_paths), + failed=len(failed_states), + ) + if completed_paths and len(completed_paths) + len(failed_states) == len(transfer_keys): + logger.warning( + "[Soulseek album] Selected folder finished with %d completed and %d failed transfer(s)", + len(completed_paths), + len(failed_states), + ) + return list(completed_paths.values()) + if not completed_paths and len(failed_states) == len(transfer_keys): + logger.warning("[Soulseek album] All %d transfer(s) failed from selected folder", len(failed_states)) + return [] + if len(completed_paths) == len(transfer_keys): + return list(completed_paths.values()) + time.sleep(interval) + pending = len(transfer_keys) - len(completed_paths) - len(failed_states) + if completed_paths: + logger.warning( + "[Soulseek album] Timed out with partial album: %d completed, %d failed, %d pending", + len(completed_paths), + len(failed_states), + pending, + ) + return list(completed_paths.values()) + logger.error( + "[Soulseek album] Timed out waiting for %d album files (%d failed, %d pending)", + len(transfer_keys), + len(failed_states), + pending, + ) + return [] + + def _resolve_downloaded_album_file(self, remote_filename: str) -> Optional[Path]: + basename = os.path.basename((remote_filename or '').replace('\\', '/')) + if not basename: + return None + candidates = [ + self.download_path / remote_filename, + self.download_path / basename, + ] + normalized_parts = [p for p in remote_filename.replace('\\', '/').split('/') if p] + if normalized_parts: + candidates.append(self.download_path.joinpath(*normalized_parts)) + for candidate in candidates: + try: + if candidate.exists() and candidate.is_file(): + return candidate + except OSError: + continue + try: + matches = list(self.download_path.rglob(basename)) + except OSError: + matches = [] + for match in matches: + if match.is_file(): + return match + return None async def check_connection(self) -> bool: """Check if slskd is running and connected to the Soulseek network""" diff --git a/tests/downloads/test_downloads_master.py b/tests/downloads/test_downloads_master.py index 80daa759..a1586028 100644 --- a/tests/downloads/test_downloads_master.py +++ b/tests/downloads/test_downloads_master.py @@ -132,6 +132,37 @@ class _FakeSoulseekWrapper: return self.soulseek if name == 'soulseek' else None +class _FakePluginWrapper: + def __init__(self, plugins): + self._plugins = dict(plugins) + + def client(self, name): + return self._plugins.get(name) + + +class _FakeAlbumBundleSoulseek: + def __init__(self, outcome=None): + self.calls = [] + self.outcome = outcome or {'success': True, 'files': ['/tmp/a.flac']} + + def download_album_to_staging(self, album, artist, staging, emit, **kwargs): + self.calls.append((album, artist, staging, kwargs)) + emit({'state': 'staged', 'count': len(self.outcome.get('files', []))}) + return self.outcome + + +class _FakePreflightAlbumBundleSoulseek(_FakeSoulseek): + def __init__(self, *args, outcome=None, **kwargs): + super().__init__(*args, **kwargs) + self.calls = [] + self.outcome = outcome or {'success': True, 'files': ['/tmp/a.flac']} + + def download_album_to_staging(self, album, artist, staging, emit, **kwargs): + self.calls.append((album, artist, staging, kwargs)) + emit({'state': 'staged', 'count': len(self.outcome.get('files', []))}) + return self.outcome + + class _FakeMonitor: def __init__(self): self.started = [] @@ -704,6 +735,133 @@ def test_soulseek_album_preflight_does_not_jump_ahead_of_hybrid_primary(monkeypa assert 'last_good_source' not in download_batches['B24'] +def test_soulseek_album_bundle_runs_after_missing_analysis(monkeypatch): + """Soulseek whole-folder bundles should engage only after analysis + has confirmed there is something missing.""" + db = _FakeDB() + monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db) + + plugin = _FakeAlbumBundleSoulseek() + deps = _build_deps( + config=_FakeConfig({'download_source.mode': 'soulseek'}), + soulseek=_FakeSoulseekWrapper(plugin), + ) + _seed_batch( + 'B25', + is_album_download=True, + album_context={'name': 'Test Album', 'total_tracks': 1}, + artist_context={'name': 'Artist'}, + ) + tracks = [{'name': 'T1', 'artists': ['Artist'], 'track_number': 1}] + + mw.run_full_missing_tracks_process('B25', 'album:1', tracks, deps) + + assert len(plugin.calls) == 1 + album, artist, staging, kwargs = plugin.calls[0] + assert (album, artist) == ('Test Album', 'Artist') + assert staging.replace('\\', '/').endswith('storage/album_bundle_staging/B25') + assert kwargs == {} + assert download_batches['B25']['album_bundle_source'] == 'soulseek' + assert download_batches['B25']['album_bundle_private_staging'] is True + assert download_batches['B25']['album_bundle_state'] == 'staged' + assert 'last_good_source' not in download_batches['B25'] + + +def test_hybrid_first_soulseek_uses_album_bundle(monkeypatch): + """Hybrid keeps fallback semantics, but the first source can own + album-bundle downloads when it supports them.""" + db = _FakeDB() + monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db) + + plugin = _FakeAlbumBundleSoulseek() + deps = _build_deps( + config=_FakeConfig({ + 'download_source.mode': 'hybrid', + 'download_source.hybrid_order': ['soulseek', 'hifi'], + }), + soulseek=_FakeSoulseekWrapper(plugin), + ) + _seed_batch( + 'B26', + is_album_download=True, + album_context={'name': 'Test Album', 'total_tracks': 1}, + artist_context={'name': 'Artist'}, + ) + tracks = [{'name': 'T1', 'artists': ['Artist'], 'track_number': 1}] + + mw.run_full_missing_tracks_process('B26', 'album:1', tracks, deps) + + assert len(plugin.calls) == 1 + assert download_batches['B26']['album_bundle_source'] == 'soulseek' + assert download_batches['B26']['album_bundle_private_staging'] is True + + +def test_soulseek_album_bundle_uses_preflight_source_without_preloading_reuse(monkeypatch): + """When the bundle path stages files, workers must claim staging + before any Soulseek source-reuse attempt can fire.""" + db = _FakeDB() + monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db) + + tracks = [{'name': 'T1', 'artists': ['Artist'], 'track_number': 1}] + folder_tracks = [_slsk_track('T1', 1, folder='Artist/Test Album')] + album = _album_result('peer', 'Artist/Test Album', 'Test Album', folder_tracks) + slsk = _FakePreflightAlbumBundleSoulseek( + album_results=[album], + browse_files=None, + parsed_tracks=folder_tracks, + ) + deps = _build_deps( + config=_FakeConfig({'download_source.mode': 'soulseek'}), + soulseek=_FakeSoulseekWrapper(slsk), + ) + _seed_batch( + 'B28', + is_album_download=True, + album_context={'name': 'Test Album', 'total_tracks': 1}, + artist_context={'name': 'Artist'}, + ) + + mw.run_full_missing_tracks_process('B28', 'album:1', tracks, deps) + + assert len(slsk.calls) == 1 + assert slsk.calls[0][3] == { + 'preferred_source': { + 'username': 'peer', + 'folder_path': 'Artist/Test Album', + }, + 'preferred_tracks': folder_tracks, + } + assert download_batches['B28']['album_bundle_private_staging'] is True + assert 'last_good_source' not in download_batches['B28'] + assert 'source_folder_tracks' not in download_batches['B28'] + + +def test_hybrid_first_torrent_uses_album_bundle_before_per_track(monkeypatch): + db = _FakeDB() + monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db) + + plugin = _FakeAlbumBundleSoulseek() + deps = _build_deps( + config=_FakeConfig({ + 'download_source.mode': 'hybrid', + 'download_source.hybrid_order': ['torrent', 'soulseek'], + }), + soulseek=_FakePluginWrapper({'torrent': plugin}), + ) + _seed_batch( + 'B27', + is_album_download=True, + album_context={'name': 'Test Album', 'total_tracks': 1}, + artist_context={'name': 'Artist'}, + ) + tracks = [{'name': 'T1', 'artists': ['Artist'], 'track_number': 1}] + + mw.run_full_missing_tracks_process('B27', 'album:1', tracks, deps) + + assert len(plugin.calls) == 1 + assert download_batches['B27']['album_bundle_source'] == 'torrent' + + # --------------------------------------------------------------------------- # Task creation # --------------------------------------------------------------------------- diff --git a/tests/downloads/test_downloads_task_worker.py b/tests/downloads/test_downloads_task_worker.py index e5156b4a..a222b311 100644 --- a/tests/downloads/test_downloads_task_worker.py +++ b/tests/downloads/test_downloads_task_worker.py @@ -223,6 +223,92 @@ def test_private_torrent_album_staging_miss_skips_per_track_search(): assert ('done', ('b1', 't1', False), {}) in rec.calls +def test_private_soulseek_album_staging_miss_skips_per_track_search(): + _seed_task(track_info={ + 'id': 'sp-1', 'name': 'Song', 'artists': ['Artist'], + 'album': 'Album', 'duration_ms': 180000, + }) + download_batches['b1'] = { + 'album_bundle_private_staging': True, + 'album_bundle_state': 'staged', + 'album_bundle_source': 'soulseek', + } + client = _FakeClient(results=['should-not-search'], mode='soulseek') + rec = _Recorder() + deps, _ = _build_deps( + soulseek=client, + matching=_FakeMatchEngine(queries=['Artist Song']), + try_staging_match=lambda *a, **kw: False, + on_download_completed=rec('done'), + ) + + tw.download_track_worker('t1', 'b1', deps) + + assert client.search_calls == [] + assert download_tasks['t1']['status'] == 'not_found' + assert 'staged soulseek album release' in download_tasks['t1']['error_message'] + assert ('done', ('b1', 't1', False), {}) in rec.calls + + +def test_private_hybrid_first_soulseek_album_staging_miss_skips_per_track_search(): + _seed_task(track_info={ + 'id': 'sp-1', 'name': 'Song', 'artists': ['Artist'], + 'album': 'Album', 'duration_ms': 180000, + }) + download_batches['b1'] = { + 'album_bundle_private_staging': True, + 'album_bundle_state': 'staged', + 'album_bundle_source': 'soulseek', + } + client = _FakeClient( + results=['should-not-search'], + mode='hybrid', + subclients={'hybrid_order': ['soulseek', 'hifi']}, + ) + rec = _Recorder() + deps, _ = _build_deps( + soulseek=client, + matching=_FakeMatchEngine(queries=['Artist Song']), + try_staging_match=lambda *a, **kw: False, + on_download_completed=rec('done'), + ) + + tw.download_track_worker('t1', 'b1', deps) + + assert client.search_calls == [] + assert download_tasks['t1']['status'] == 'not_found' + assert 'staged soulseek album release' in download_tasks['t1']['error_message'] + + +def test_partial_private_hybrid_first_soulseek_album_staging_miss_allows_per_track_search(): + _seed_task(track_info={ + 'id': 'sp-1', 'name': 'Song', 'artists': ['Artist'], + 'album': 'Album', 'duration_ms': 180000, + }) + download_batches['b1'] = { + 'album_bundle_private_staging': True, + 'album_bundle_state': 'staged', + 'album_bundle_source': 'soulseek', + 'album_bundle_partial': True, + } + client = _FakeClient( + results=[], + mode='hybrid', + subclients={'hybrid_order': ['soulseek', 'hifi']}, + ) + deps, _ = _build_deps( + soulseek=client, + matching=_FakeMatchEngine(queries=['Artist Song']), + try_staging_match=lambda *a, **kw: False, + ) + + tw.download_track_worker('t1', 'b1', deps) + + assert client.search_calls + assert download_tasks['t1']['status'] == 'not_found' + assert 'staged soulseek album release' not in download_tasks['t1']['error_message'] + + # --------------------------------------------------------------------------- # Search loop happy path # --------------------------------------------------------------------------- diff --git a/tests/downloads/test_soulseek_pinning.py b/tests/downloads/test_soulseek_pinning.py index 0c7a8202..3ba3508b 100644 --- a/tests/downloads/test_soulseek_pinning.py +++ b/tests/downloads/test_soulseek_pinning.py @@ -28,6 +28,7 @@ from unittest.mock import AsyncMock, patch import pytest from core.soulseek_client import SoulseekClient +from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult def _run_async(coro): @@ -121,6 +122,153 @@ def test_download_extracts_id_from_dict_response(configured_client): assert result == 'abc123' +# --------------------------------------------------------------------------- +# album bundle +# --------------------------------------------------------------------------- + + +def _track(username='peer', filename='Artist/Album/01 - Song.flac', title='Song', number=1, size=10): + return TrackResult( + username=username, + filename=filename, + size=size, + bitrate=None, + duration=180000, + quality='flac', + free_upload_slots=1, + upload_speed=1_000_000, + queue_length=0, + artist='Artist', + title=title, + album='Album', + track_number=number, + ) + + +def test_album_bundle_stages_one_selected_soulseek_folder(configured_client, tmp_path): + configured_client.download_path = tmp_path + local_file = tmp_path / '01 - Song.flac' + local_file.write_bytes(b'audio') + track = _track(filename='Artist/Album/01 - Song.flac') + album = AlbumResult( + username='peer', + album_path='Artist/Album', + album_title='Album', + artist='Artist', + track_count=1, + total_size=10, + tracks=[track], + dominant_quality='flac', + free_upload_slots=1, + upload_speed=1_000_000, + queue_length=0, + ) + events = [] + + with patch.object(configured_client, 'search', AsyncMock(return_value=([], [album]))), \ + patch.object(configured_client, 'browse_user_directory', AsyncMock(return_value=[ + {'filename': '01 - Song.flac', 'size': 10} + ])), \ + patch.object(configured_client, 'filter_results_by_quality_preference', side_effect=lambda tracks: tracks), \ + patch.object(configured_client, 'download', AsyncMock(return_value='dl-1')) as download_mock, \ + patch.object(configured_client, 'get_all_downloads', AsyncMock(return_value=[ + DownloadStatus( + id='dl-1', + username='peer', + filename='Artist/Album/01 - Song.flac', + state='Completed, Succeeded', + progress=100, + size=10, + transferred=10, + speed=0, + ) + ])), \ + patch('core.soulseek_client.get_poll_timeout', return_value=1), \ + patch('core.soulseek_client.get_poll_interval', return_value=0.01): + outcome = configured_client.download_album_to_staging( + 'Album', + 'Artist', + str(tmp_path / 'staging'), + events.append, + ) + + assert outcome['success'] is True + assert outcome['fallback'] is False + assert len(outcome['files']) == 1 + assert Path(outcome['files'][0]).read_bytes() == b'audio' + download_mock.assert_awaited_once_with( + 'peer', + 'Artist/Album/01 - Song.flac', + 10, + ) + assert events[-1]['state'] == 'staged' + + +def test_album_bundle_stages_completed_files_when_same_source_partially_times_out(configured_client, tmp_path): + configured_client.download_path = tmp_path + (tmp_path / '01 - Ready.flac').write_bytes(b'audio') + ready = _track(filename='Artist/Album/01 - Ready.flac', title='Ready', number=1) + timed_out = _track(filename='Artist/Album/02 - Waiting.flac', title='Waiting', number=2) + events = [] + + with patch.object(configured_client, 'download', AsyncMock(side_effect=['dl-1', 'dl-2'])), \ + patch.object(configured_client, 'filter_results_by_quality_preference', side_effect=lambda tracks: tracks), \ + patch.object(configured_client, 'get_all_downloads', AsyncMock(return_value=[ + DownloadStatus( + id='dl-1', + username='peer', + filename='Artist/Album/01 - Ready.flac', + state='Completed, Succeeded', + progress=100, + size=10, + transferred=10, + speed=0, + ), + DownloadStatus( + id='dl-2', + username='peer', + filename='Artist/Album/02 - Waiting.flac', + state='TimedOut', + progress=0, + size=10, + transferred=0, + speed=0, + ), + ])), \ + patch('core.soulseek_client.get_poll_timeout', return_value=1), \ + patch('core.soulseek_client.get_poll_interval', return_value=0.01): + outcome = configured_client.download_album_to_staging( + 'Album', + 'Artist', + str(tmp_path / 'staging'), + events.append, + preferred_source={'username': 'peer', 'folder_path': 'Artist/Album'}, + preferred_tracks=[ready, timed_out], + ) + + assert outcome['success'] is True + assert outcome['fallback'] is False + assert len(outcome['files']) == 1 + assert Path(outcome['files'][0]).name == '01 - Ready.flac' + assert any(event.get('failed') == 1 for event in events) + + +def test_album_bundle_falls_back_when_no_album_folder(configured_client, tmp_path): + configured_client.download_path = tmp_path + with patch.object(configured_client, 'search', AsyncMock(return_value=([], []))), \ + patch.object(configured_client, 'download', AsyncMock(return_value='dl-1')) as dl: + outcome = configured_client.download_album_to_staging( + 'Missing Album', + 'Artist', + str(tmp_path / 'staging'), + ) + + assert outcome['success'] is False + assert outcome['fallback'] is True + assert 'No complete Soulseek album folders' in outcome['error'] + dl.assert_not_awaited() + + def test_download_extracts_id_from_list_response(configured_client): """Pinning: slskd sometimes returns a list of file objects. The first item's id is the download_id.""" diff --git a/tests/test_album_bundle_dispatch.py b/tests/test_album_bundle_dispatch.py index 68579f6d..5cafa065 100644 --- a/tests/test_album_bundle_dispatch.py +++ b/tests/test_album_bundle_dispatch.py @@ -56,18 +56,20 @@ def test_is_eligible_requires_album_flag() -> None: album_name='X', artist_name='Y') is False -def test_is_eligible_requires_torrent_or_usenet_mode() -> None: - for mode in ('soulseek', 'youtube', 'tidal', 'qobuz', 'hifi', +def test_is_eligible_requires_album_bundle_mode() -> None: + for mode in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'amazon', 'lidarr', 'soundcloud', 'hybrid'): assert is_eligible(mode=mode, is_album=True, album_name='X', artist_name='Y') is False -def test_is_eligible_accepts_torrent_and_usenet() -> None: +def test_is_eligible_accepts_torrent_usenet_and_soulseek() -> None: assert is_eligible(mode='torrent', is_album=True, album_name='X', artist_name='Y') is True assert is_eligible(mode='usenet', is_album=True, album_name='X', artist_name='Y') is True + assert is_eligible(mode='soulseek', is_album=True, + album_name='X', artist_name='Y') is True def test_is_eligible_requires_non_empty_names() -> None: @@ -103,13 +105,13 @@ def test_dispatch_returns_false_when_not_album() -> None: plugin.download_album_to_staging.assert_not_called() -def test_dispatch_returns_false_for_non_torrent_modes() -> None: +def test_dispatch_returns_false_for_non_album_bundle_modes() -> None: state = _FakeState() plugin = MagicMock() result = try_dispatch( batch_id='b1', is_album=True, album_context={'name': 'X'}, artist_context={'name': 'Y'}, - config_get=_config({'download_source.mode': 'soulseek'}), + config_get=_config({'download_source.mode': 'youtube'}), plugin_resolver=lambda _name: plugin, state=state, ) assert result is False @@ -234,6 +236,28 @@ def test_dispatch_failure_returns_true_so_master_stops() -> None: assert state.fields['phase'] == 'failed' +def test_dispatch_fallback_failure_returns_false_for_per_track_flow() -> None: + state = _FakeState() + plugin = MagicMock() + plugin.download_album_to_staging.return_value = { + 'success': False, + 'files': [], + 'error': 'No complete Soulseek album folders found', + 'fallback': True, + } + result = try_dispatch( + batch_id='b1', is_album=True, + album_context={'name': 'Album'}, artist_context={'name': 'Artist'}, + config_get=_config({'download_source.mode': 'soulseek'}), + plugin_resolver=lambda _name: plugin, state=state, + ) + assert result is False + assert state.failed_with == '' + assert state.fields['phase'] == 'analysis' + assert state.fields['album_bundle_state'] == 'fallback' + assert state.fields['album_bundle_error'] == 'No complete Soulseek album folders found' + + def test_dispatch_plugin_exception_treated_as_failure() -> None: """A bug / network error in the plugin must not propagate into the master worker β€” caught + treated as a normal failure so @@ -269,6 +293,25 @@ def test_dispatch_strips_whitespace_from_names() -> None: assert args.args[1] == 'Kendrick' +def test_dispatch_source_override_uses_first_hybrid_source() -> None: + state = _FakeState() + plugin = MagicMock() + plugin.download_album_to_staging.return_value = {'success': True, 'files': ['/x']} + seen = [] + + try_dispatch( + batch_id='b1', is_album=True, + album_context={'name': 'GNX'}, artist_context={'name': 'Kendrick Lamar'}, + config_get=_config({'download_source.mode': 'hybrid'}), + plugin_resolver=lambda name: seen.append(name) or plugin, + state=state, + source_override='soulseek', + ) + + assert seen == ['soulseek'] + assert state.fields['album_bundle_source'] == 'soulseek' + + def test_dispatch_progress_callback_mirrors_payload_to_state() -> None: """The progress callback the plugin gets must mirror its payload onto the batch state under ``album_bundle_*`` keys so From 94129d30996e90ae762ac26843f35d1614070e7e Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 23 May 2026 15:15:28 -0700 Subject: [PATCH 20/26] Clarify hybrid source album behavior Add dynamic level badges to the hybrid source order settings. The first enabled source shows Album-level only when it supports album-bundle downloads; every other source shows Track-level to make fallback behavior visible. Update the helper copy and badge styling so users can understand why putting Soulseek, Torrent, or Usenet first changes album-download behavior. --- webui/index.html | 1 + webui/static/settings.js | 9 +++++++++ webui/static/style.css | 17 +++++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/webui/index.html b/webui/index.html index 7ff29dc5..b22dd056 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4372,6 +4372,7 @@ + +
+
+

Your Qobuz Playlists

+ +
+
+
Click 'Refresh' to load your Qobuz playlists.
+
+
+