Merge pull request #685 from Nezreka/dev

Dev
This commit is contained in:
BoulderBadgeDad 2026-05-24 01:54:42 -07:00 committed by GitHub
commit 0af66b1bb4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 8621 additions and 2286 deletions

View file

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

299
core/discovery/qobuz.py Normal file
View file

@ -0,0 +1,299 @@
"""Background worker for Qobuz playlist discovery.
`run_qobuz_discovery_worker(playlist_id, deps)` is the function the
Qobuz discovery start-endpoint submits to its executor to match each
Qobuz playlist track against Spotify (preferred) or the configured
fallback metadata source (iTunes / Deezer / Discogs / MusicBrainz).
Mirrors `core/discovery/deezer.py` exactly Qobuz playlists arrive as
dicts (not dataclasses) from `core/qobuz_client.py:get_playlist`, so
this worker uses dict-style access on track data and wraps each entry
in a SimpleNamespace before handing it to the shared
`_search_spotify_for_tidal_track` helper.
"""
from __future__ import annotations
import logging
import time
import types
from dataclasses import dataclass
from typing import Any, Callable
logger = logging.getLogger(__name__)
@dataclass
class QobuzDiscoveryDeps:
"""Bundle of cross-cutting deps the Qobuz discovery worker needs."""
qobuz_discovery_states: dict
spotify_client: Any
pause_enrichment_workers: Callable[[str], dict]
resume_enrichment_workers: Callable[[dict, str], None]
get_active_discovery_source: Callable[[], str]
get_metadata_fallback_client: Callable[[], Any]
get_discovery_cache_key: Callable
get_database: Callable[[], Any]
validate_discovery_cache_artist: Callable
search_spotify_for_tidal_track: Callable
build_discovery_wing_it_stub: Callable
add_activity_item: Callable
sync_discovery_results_to_mirrored: Callable
def run_qobuz_discovery_worker(playlist_id, deps: QobuzDiscoveryDeps):
"""Background worker for Qobuz discovery process (Spotify preferred, fallback metadata source)."""
_ew_state = {}
try:
_ew_state = deps.pause_enrichment_workers('Qobuz discovery')
state = deps.qobuz_discovery_states[playlist_id]
playlist = state['playlist']
# Determine which provider to use
discovery_source = deps.get_active_discovery_source()
use_spotify = (discovery_source == 'spotify') and deps.spotify_client and deps.spotify_client.is_spotify_authenticated()
# Initialize fallback client if needed
itunes_client_instance = None
if not use_spotify:
itunes_client_instance = deps.get_metadata_fallback_client()
logger.info(f"Starting Qobuz discovery for: {playlist['name']} (using {discovery_source.upper()})")
# Store discovery source in state for frontend
state['discovery_source'] = discovery_source
successful_discoveries = 0
tracks = playlist['tracks']
for i, qobuz_track in enumerate(tracks):
if state.get('cancelled', False):
break
try:
track_name = qobuz_track['name']
track_artists = qobuz_track['artists']
track_id = qobuz_track['id']
track_album = qobuz_track.get('album', '')
track_duration_ms = qobuz_track.get('duration_ms', 0)
logger.info(f"[{i+1}/{len(tracks)}] Searching {discovery_source.upper()}: {track_name} by {', '.join(track_artists)}")
# Check discovery cache first
cache_key = deps.get_discovery_cache_key(track_name, track_artists[0] if track_artists else '')
try:
cache_db = deps.get_database()
cached_match = cache_db.get_discovery_cache_match(cache_key[0], cache_key[1], discovery_source)
if cached_match and deps.validate_discovery_cache_artist(track_artists[0] if track_artists else '', cached_match):
logger.debug(f"CACHE HIT [{i+1}/{len(tracks)}]: {track_name} by {', '.join(track_artists)}")
cached_artists = cached_match.get('artists', [])
if cached_artists:
cached_artist_str = ', '.join(
a if isinstance(a, str) else a.get('name', '') for a in cached_artists
)
else:
cached_artist_str = ''
cached_album = cached_match.get('album', '')
if isinstance(cached_album, dict):
cached_album = cached_album.get('name', '')
result = {
'qobuz_track': {
'id': track_id,
'name': track_name,
'artists': track_artists or [],
'album': track_album,
'duration_ms': track_duration_ms,
},
'spotify_data': cached_match,
'match_data': cached_match,
'status': 'Found',
'status_class': 'found',
'spotify_track': cached_match.get('name', ''),
'spotify_artist': cached_artist_str,
'spotify_album': cached_album,
'spotify_id': cached_match.get('id', ''),
'discovery_source': discovery_source,
'index': i
}
successful_discoveries += 1
state['spotify_matches'] = successful_discoveries
state['discovery_results'].append(result)
state['discovery_progress'] = int(((i + 1) / len(tracks)) * 100)
continue
except Exception as cache_err:
logger.error(f"Cache lookup error: {cache_err}")
# SimpleNamespace duck-type for _search_spotify_for_tidal_track
track_ns = types.SimpleNamespace(
id=track_id,
name=track_name,
artists=track_artists,
album=track_album,
duration_ms=track_duration_ms
)
track_result = deps.search_spotify_for_tidal_track(
track_ns,
use_spotify=use_spotify,
itunes_client=itunes_client_instance
)
result = {
'qobuz_track': {
'id': track_id,
'name': track_name,
'artists': track_artists or [],
'album': track_album,
'duration_ms': track_duration_ms,
},
'spotify_data': None,
'match_data': None,
'status': 'Not Found',
'status_class': 'not-found',
'spotify_track': '',
'spotify_artist': '',
'spotify_album': '',
'discovery_source': discovery_source
}
match_confidence = 0.0
if use_spotify and isinstance(track_result, tuple):
track_obj, raw_track_data, match_confidence = track_result
album_obj = raw_track_data.get('album', {}) if raw_track_data else {}
if isinstance(album_obj, dict) and not album_obj.get('name') and track_obj.album:
album_obj['name'] = track_obj.album
elif not album_obj and track_obj.album:
album_obj = {'name': track_obj.album}
if isinstance(album_obj, dict) and not album_obj.get('release_date'):
album_obj['release_date'] = getattr(track_obj, 'release_date', '') or ''
_album_images = album_obj.get('images', []) if isinstance(album_obj, dict) else []
_image_url = _album_images[0].get('url', '') if _album_images else (getattr(track_obj, 'image_url', '') or '')
match_data = {
'id': track_obj.id,
'name': track_obj.name,
'artists': track_obj.artists,
'album': album_obj,
'duration_ms': track_obj.duration_ms,
'external_urls': track_obj.external_urls,
'image_url': _image_url,
'source': 'spotify'
}
if raw_track_data and raw_track_data.get('track_number'):
match_data['track_number'] = raw_track_data['track_number']
if raw_track_data and raw_track_data.get('disc_number'):
match_data['disc_number'] = raw_track_data['disc_number']
result['spotify_data'] = match_data
result['match_data'] = match_data
result['status'] = 'Found'
result['status_class'] = 'found'
result['spotify_track'] = track_obj.name
result['spotify_artist'] = ', '.join(track_obj.artists) if isinstance(track_obj.artists, list) else str(track_obj.artists)
result['spotify_album'] = album_obj.get('name', '') if isinstance(album_obj, dict) else str(album_obj)
result['spotify_id'] = track_obj.id
result['confidence'] = match_confidence
successful_discoveries += 1
state['spotify_matches'] = successful_discoveries
elif not use_spotify and track_result and isinstance(track_result, dict):
match_confidence = track_result.pop('confidence', 0.80)
match_data = track_result
match_data['source'] = discovery_source
_fb_album = match_data.get('album', {})
_fb_images = _fb_album.get('images', []) if isinstance(_fb_album, dict) else []
if _fb_images and 'image_url' not in match_data:
match_data['image_url'] = _fb_images[0].get('url', '')
result['spotify_data'] = match_data
result['match_data'] = match_data
result['status'] = 'Found'
result['status_class'] = 'found'
result['spotify_track'] = match_data.get('name', '')
itunes_artists = match_data.get('artists', [])
result['spotify_artist'] = ', '.join(a if isinstance(a, str) else a.get('name', '') for a in itunes_artists) if itunes_artists else ''
result['spotify_album'] = match_data.get('album', {}).get('name', '') if isinstance(match_data.get('album'), dict) else match_data.get('album', '')
result['spotify_id'] = match_data.get('id', '')
result['confidence'] = match_confidence
successful_discoveries += 1
state['spotify_matches'] = successful_discoveries
# Save to discovery cache if match found
if result['status_class'] == 'found' and result.get('match_data'):
try:
cache_db = deps.get_database()
cache_db.save_discovery_cache_match(
cache_key[0], cache_key[1], discovery_source, match_confidence,
result['match_data'], track_name,
track_artists[0] if track_artists else ''
)
logger.info(f"CACHE SAVED: {track_name} (confidence: {match_confidence:.3f})")
except Exception as cache_err:
logger.error(f"Cache save error: {cache_err}")
# Auto Wing It fallback for unmatched tracks
if result['status_class'] == 'not-found':
qobuz_t = result.get('qobuz_track', {})
stub = deps.build_discovery_wing_it_stub(
qobuz_t.get('name', ''),
', '.join(qobuz_t.get('artists', [])),
qobuz_t.get('duration_ms', 0)
)
result['status'] = 'Wing It'
result['status_class'] = 'wing-it'
result['spotify_data'] = stub
result['match_data'] = stub
result['spotify_track'] = qobuz_t.get('name', '')
result['spotify_artist'] = ', '.join(qobuz_t.get('artists', []))
result['wing_it_fallback'] = True
result['confidence'] = 0
successful_discoveries += 1
state['spotify_matches'] = successful_discoveries
state['wing_it_count'] = state.get('wing_it_count', 0) + 1
result['index'] = i
state['discovery_results'].append(result)
state['discovery_progress'] = int(((i + 1) / len(tracks)) * 100)
time.sleep(0.1)
except Exception as e:
logger.error(f"Error processing track {i+1}: {e}")
result = {
'qobuz_track': {
'name': qobuz_track.get('name', 'Unknown'),
'artists': qobuz_track.get('artists', []),
},
'spotify_data': None,
'match_data': None,
'status': 'Error',
'status_class': 'error',
'spotify_track': '',
'spotify_artist': '',
'spotify_album': '',
'error': str(e),
'discovery_source': discovery_source,
'index': i
}
state['discovery_results'].append(result)
state['discovery_progress'] = int(((i + 1) / len(tracks)) * 100)
# Mark as complete
state['phase'] = 'discovered'
state['status'] = 'discovered'
state['discovery_progress'] = 100
source_label = discovery_source.upper()
deps.add_activity_item("", f"Qobuz Discovery Complete ({source_label})", f"'{playlist['name']}' - {successful_discoveries}/{len(tracks)} tracks found", "Now")
logger.info(f"Qobuz discovery complete ({source_label}): {successful_discoveries}/{len(tracks)} tracks found")
deps.sync_discovery_results_to_mirrored('qobuz', playlist_id, state.get('discovery_results', []), discovery_source, profile_id=state.get('_profile_id', 1))
except Exception as e:
logger.error(f"Error in Qobuz discovery worker: {e}")
if playlist_id in deps.qobuz_discovery_states:
deps.qobuz_discovery_states[playlist_id]['phase'] = 'error'
deps.qobuz_discovery_states[playlist_id]['status'] = f'error: {str(e)}'
finally:
deps.resume_enrichment_workers(_ew_state, 'Qobuz discovery')

View file

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

View file

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

View file

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

View file

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

View file

@ -217,7 +217,12 @@ def staging_hints(runtime: ImportRouteRuntime) -> tuple[Dict[str, Any], int]:
def staging_suggestions() -> tuple[Dict[str, Any], int]:
"""Return cached import suggestions and readiness state."""
cache = get_import_suggestions_cache()
return {"success": True, "suggestions": cache["suggestions"], "ready": cache["built"]}, 200
return {
"success": True,
"suggestions": cache["suggestions"],
"ready": cache["built"],
"primary_source": _get_primary_source(),
}, 200
def search_albums(runtime: ImportRouteRuntime, query: str, limit: int = 12) -> tuple[Dict[str, Any], int]:
@ -228,11 +233,12 @@ def search_albums(runtime: ImportRouteRuntime, query: str, limit: int = 12) -> t
return {"success": False, "error": "Missing query parameter"}, 400
limit = min(int(limit), 50)
if runtime.get_primary_source() == "hydrabase" and runtime.hydrabase_worker and runtime.dev_mode_enabled:
primary_source = runtime.get_primary_source()
if primary_source == "hydrabase" and runtime.hydrabase_worker and runtime.dev_mode_enabled:
runtime.hydrabase_worker.enqueue(query, "albums")
albums = runtime.search_import_albums(query, limit=limit)
return {"success": True, "albums": albums}, 200
return {"success": True, "albums": albums, "primary_source": primary_source}, 200
except Exception as exc:
runtime.logger.error("Error searching albums for import: %s", exc)
return {"success": False, "error": str(exc)}, 500
@ -362,11 +368,12 @@ def search_tracks(runtime: ImportRouteRuntime, query: str, limit: int = 10) -> t
return {"success": False, "error": "Missing query parameter"}, 400
limit = min(int(limit), 30)
if runtime.get_primary_source() == "hydrabase" and runtime.hydrabase_worker and runtime.dev_mode_enabled:
primary_source = runtime.get_primary_source()
if primary_source == "hydrabase" and runtime.hydrabase_worker and runtime.dev_mode_enabled:
runtime.hydrabase_worker.enqueue(query, "tracks")
tracks = runtime.search_import_tracks(query, limit=limit)
return {"success": True, "tracks": tracks}, 200
return {"success": True, "tracks": tracks, "primary_source": primary_source}, 200
except Exception as exc:
runtime.logger.error("Error searching tracks for import: %s", exc)
return {"success": False, "error": str(exc)}, 500

View file

@ -711,6 +711,249 @@ class QobuzClient(DownloadSourcePlugin):
logger.error(f"Error getting Qobuz track {track_id}: {e}")
return None
# ===================== Playlists & Favorites =====================
#
# Qobuz playlist sync surface — mirrors the Tidal client contract
# (see core/tidal_client.py:629 + :1227) so the Sync page's
# per-service handlers can render Qobuz playlists in the same
# discovery / mirror flow. Returns normalized dicts rather than
# dataclasses to match the rest of this client's idiom.
#
# Favorite Tracks ride on the same `get_playlist()` entry point via
# the virtual ID below — same pattern as Tidal's COLLECTION_PLAYLIST_ID
# so sync-services.js can treat favorites as just another playlist
# card without per-service special-casing.
QOBUZ_FAVORITES_ID = "qobuz-favorites"
QOBUZ_FAVORITES_NAME = "Favorite Tracks"
QOBUZ_FAVORITES_DESCRIPTION = "Your favorited tracks on Qobuz"
# Page size for paginated playlist + favorite listings. Qobuz caps at
# 500 per page; 100 is a safe middle ground for responsiveness.
_PLAYLIST_PAGE_SIZE = 100
def _normalize_qobuz_playlist(self, p: Dict) -> Dict:
"""Project a Qobuz playlist dict into the shape the Sync page expects."""
image = p.get('images', []) or []
image_url = image[0] if image else (p.get('image_rectangle', [None])[0] if p.get('image_rectangle') else None)
if not image_url:
image_url = p.get('image', '') or ''
return {
'id': str(p.get('id', '')),
'name': p.get('name', 'Unknown Playlist'),
'description': p.get('description', '') or '',
'public': bool(p.get('is_public', False)),
'track_count': int(p.get('tracks_count', 0) or 0),
'image_url': image_url,
'external_urls': {'qobuz': f"https://play.qobuz.com/playlist/{p.get('id', '')}"} if p.get('id') else {},
}
def _normalize_qobuz_track(self, t: Dict) -> Dict:
"""Project a Qobuz track dict into the shape the Sync page expects."""
performer = t.get('performer') or {}
album = t.get('album') or {}
album_artist = album.get('artist') or {}
# Artist names — Qobuz can stash the artist on performer, album.artist,
# or composer depending on the track. Prefer performer, fall back to
# album artist, then composer, then "Unknown Artist".
artist_name = (
performer.get('name')
or album_artist.get('name')
or (t.get('composer') or {}).get('name')
or 'Unknown Artist'
)
album_image = album.get('image') or {}
image_url = album_image.get('large') or album_image.get('small') or album_image.get('thumbnail') or ''
return {
'id': str(t.get('id', '')),
'name': t.get('title', '') or '',
'artists': [artist_name],
'album': album.get('title', '') or '',
'duration_ms': int(t.get('duration', 0) or 0) * 1000,
'image_url': image_url,
'external_urls': {'qobuz': f"https://play.qobuz.com/track/{t.get('id', '')}"} if t.get('id') else {},
'explicit': bool(t.get('parental_warning', False)),
}
def get_user_playlists(self) -> List[Dict[str, Any]]:
"""Fetch the authenticated user's Qobuz playlists.
Returns metadata only (no tracks) track lists are fetched
on-demand via `get_playlist()` when the user selects one.
Matches the Tidal `get_user_playlists_metadata_only` contract
so the Sync page renderer can treat both services uniformly.
"""
if not self.is_authenticated():
logger.warning("Qobuz not authenticated — cannot list playlists")
return []
playlists: List[Dict[str, Any]] = []
offset = 0
while True:
data = self._api_request('playlist/getUserPlaylists', {
'limit': self._PLAYLIST_PAGE_SIZE,
'offset': offset,
})
if not data:
break
container = data.get('playlists') or {}
items = container.get('items', []) or []
if not items:
break
for raw in items:
try:
playlists.append(self._normalize_qobuz_playlist(raw))
except Exception as exc:
logger.debug(f"Skipping malformed Qobuz playlist entry: {exc}")
total = int(container.get('total', len(playlists)) or len(playlists))
offset += len(items)
if offset >= total or len(items) < self._PLAYLIST_PAGE_SIZE:
break
logger.info(f"Retrieved {len(playlists)} Qobuz user playlists")
return playlists
def get_playlist(self, playlist_id: str) -> Optional[Dict[str, Any]]:
"""Fetch a Qobuz playlist with its full tracklist.
Recognizes the virtual ``qobuz-favorites`` ID and dispatches to
``get_user_favorite_tracks`` so the Sync page can treat
favorites as just another playlist card (same pattern as
Tidal's ``tidal-favorites``).
"""
if not self.is_authenticated():
logger.warning("Qobuz not authenticated — cannot fetch playlist")
return None
if str(playlist_id) == self.QOBUZ_FAVORITES_ID:
tracks = self.get_user_favorite_tracks()
return {
'id': self.QOBUZ_FAVORITES_ID,
'name': self.QOBUZ_FAVORITES_NAME,
'description': self.QOBUZ_FAVORITES_DESCRIPTION,
'public': False,
'track_count': len(tracks),
'image_url': '',
'external_urls': {},
'tracks': tracks,
}
# Paginate tracks ourselves — Qobuz's playlist/get only returns
# the first ~50 tracks even with limit=500 on some accounts.
tracks: List[Dict[str, Any]] = []
offset = 0
playlist_meta: Optional[Dict] = None
while True:
data = self._api_request('playlist/get', {
'playlist_id': playlist_id,
'extra': 'tracks',
'limit': self._PLAYLIST_PAGE_SIZE,
'offset': offset,
})
if not data:
break
if playlist_meta is None:
playlist_meta = data
track_container = data.get('tracks') or {}
items = track_container.get('items', []) or []
if not items:
break
for raw in items:
try:
tracks.append(self._normalize_qobuz_track(raw))
except Exception as exc:
logger.debug(f"Skipping malformed Qobuz playlist track: {exc}")
total = int(track_container.get('total', len(tracks)) or len(tracks))
offset += len(items)
if offset >= total or len(items) < self._PLAYLIST_PAGE_SIZE:
break
if playlist_meta is None:
logger.warning(f"Qobuz playlist {playlist_id} not found")
return None
normalized = self._normalize_qobuz_playlist(playlist_meta)
normalized['tracks'] = tracks
normalized['track_count'] = len(tracks)
logger.info(f"Retrieved Qobuz playlist '{normalized['name']}' with {len(tracks)} tracks")
return normalized
def get_user_favorite_tracks(self, limit: Optional[int] = None) -> List[Dict[str, Any]]:
"""Fetch the authenticated user's favorited tracks.
Mirrors ``TidalClient.get_collection_tracks`` the Sync page's
Favorite Tracks card pulls from here on click. By default this
fetches the full favorites collection so the card count and the
discovered track list cannot silently diverge. Pass ``limit`` for
explicit capped callers.
"""
if not self.is_authenticated():
logger.warning("Qobuz not authenticated — cannot list favorite tracks")
return []
tracks: List[Dict[str, Any]] = []
offset = 0
while True:
page_size = self._PLAYLIST_PAGE_SIZE if limit is None else min(self._PLAYLIST_PAGE_SIZE, limit - len(tracks))
if page_size <= 0:
break
data = self._api_request('favorite/getUserFavorites', {
'type': 'tracks',
'limit': page_size,
'offset': offset,
})
if not data:
break
container = data.get('tracks') or {}
items = container.get('items', []) or []
if not items:
break
for raw in items:
try:
tracks.append(self._normalize_qobuz_track(raw))
except Exception as exc:
logger.debug(f"Skipping malformed Qobuz favorite track: {exc}")
total = int(container.get('total', len(tracks)) or len(tracks))
offset += len(items)
if offset >= total or len(items) < page_size:
break
if limit is not None and len(tracks) >= limit:
break
logger.info(f"Retrieved {len(tracks)} Qobuz favorite tracks")
return tracks
def get_user_favorite_tracks_count(self) -> int:
"""Cheap track-count lookup for the Favorite Tracks card metadata.
Mirrors ``TidalClient.get_collection_tracks_count`` avoids
fetching the full list just to populate the card's track-count
chip on the Sync page.
"""
if not self.is_authenticated():
return 0
data = self._api_request('favorite/getUserFavorites', {
'type': 'tracks',
'limit': 1,
'offset': 0,
})
if not data:
return 0
return int((data.get('tracks') or {}).get('total', 0) or 0)
async def search(self, query: str, timeout: int = None, progress_callback=None) -> Tuple[List[TrackResult], List[AlbumResult]]:
"""
Search Qobuz for tracks (async, Soulseek-compatible interface).

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -207,6 +207,7 @@ def test_staging_suggestions_returns_cache_payload(monkeypatch):
"get_import_suggestions_cache",
lambda: {"suggestions": [{"album": "Album"}], "built": True},
)
monkeypatch.setattr(import_routes, "_get_primary_source", lambda: "deezer")
payload, status = staging_suggestions()
@ -215,6 +216,7 @@ def test_staging_suggestions_returns_cache_payload(monkeypatch):
"success": True,
"suggestions": [{"album": "Album"}],
"ready": True,
"primary_source": "deezer",
}
@ -303,7 +305,11 @@ def test_search_albums_enqueues_hydrabase_and_caps_limit():
payload, status = search_albums(runtime, " Album ", 99)
assert status == 200
assert payload == {"success": True, "albums": [{"id": "album-1"}]}
assert payload == {
"success": True,
"albums": [{"id": "album-1"}],
"primary_source": "hydrabase",
}
assert worker.enqueued == [("Album", "albums")]
assert calls == [("Album", 50)]
@ -315,6 +321,28 @@ def test_search_albums_requires_query():
assert payload == {"success": False, "error": "Missing query parameter"}
def test_search_albums_exposes_primary_source_when_chain_falls_back():
# Pins github issue #681: when the primary source returns nothing and the
# silent fallback chain (intentional, see core/auto_import_worker.py:1316)
# serves results from a different source, the response must carry both
# `primary_source` (what the user configured) and per-album `source`
# (what actually served the result) so the UI can warn the user.
runtime = ImportRouteRuntime(
get_primary_source=lambda: "musicbrainz",
search_import_albums=lambda query, limit: [
{"id": "deezer-1", "name": "Album", "source": "deezer"},
],
logger=_FakeLogger(),
)
payload, status = search_albums(runtime, "Weapons of Mass Destruction", 12)
assert status == 200
assert payload["success"] is True
assert payload["primary_source"] == "musicbrainz"
assert payload["albums"][0]["source"] == "deezer"
def test_search_tracks_enqueues_hydrabase_and_caps_limit():
worker = _FakeHydrabaseWorker()
calls = []
@ -329,7 +357,11 @@ def test_search_tracks_enqueues_hydrabase_and_caps_limit():
payload, status = search_tracks(runtime, " Track ", 99)
assert status == 200
assert payload == {"success": True, "tracks": [{"id": "track-1"}]}
assert payload == {
"success": True,
"tracks": [{"id": "track-1"}],
"primary_source": "hydrabase",
}
assert worker.enqueued == [("Track", "tracks")]
assert calls == [("Track", 30)]

View file

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

View file

@ -82,19 +82,39 @@ def test_select_album_handler_reads_cache(js_source: str):
)
def test_card_renderers_populate_cache_before_onclick(js_source: str):
"""Both renderers (suggestion card + search-result card) must write
to ``_albumLookup`` before emitting the onclick otherwise the
click handler reads an empty cache for newly-displayed albums."""
cache_writes = re.findall(
r"_albumLookup\[a\.id\]\s*=\s*\{",
js_source,
def test_card_renderer_populates_cache_before_onclick(js_source: str):
"""The shared card-renderer ``_renderSuggestionCard`` must write to
``_albumLookup`` before emitting the onclick otherwise the click
handler reads an empty cache for newly-displayed albums.
Originally this test required >=2 cache writes (one per inline
renderer), but the search-results inline render was consolidated
into a single ``_renderSuggestionCard`` call as part of the #681
fix. The invariant now is: the shared renderer populates the cache,
and every render call site goes through it (no inline duplicates)."""
# 1. The shared renderer must contain the cache write.
match = re.search(
r"function _renderSuggestionCard\([^)]*\) \{(.*?)^\}",
js_source, re.DOTALL | re.MULTILINE,
)
assert len(cache_writes) >= 2, (
f"Expected >=2 _albumLookup writes (one per card renderer - "
f"suggestions + search results), found {len(cache_writes)}. "
"Adding a new card-rendering site without populating the cache "
"regresses issue #524 for that path."
assert match, "_renderSuggestionCard function not found"
body = match.group(1)
assert re.search(r"_albumLookup\[a\.id\]\s*=\s*\{", body), (
"_renderSuggestionCard no longer writes to _albumLookup before "
"emitting the onclick — every card rendered through this helper "
"would have an empty cache on click, regressing issue #524."
)
# 2. No inline card render allowed outside the shared helper.
# A second `_albumLookup[a.id] = {` write means a caller is
# re-implementing the renderer instead of calling the helper —
# that's exactly the duplication the #524 fix consolidated away.
cache_writes = re.findall(r"_albumLookup\[a\.id\]\s*=\s*\{", js_source)
assert len(cache_writes) == 1, (
f"Expected exactly 1 _albumLookup write (inside _renderSuggestionCard), "
f"found {len(cache_writes)}. A new inline card-render site has "
"duplicated the cache-write logic — route the new caller through "
"_renderSuggestionCard(a, primarySource) instead."
)

View file

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

View file

@ -0,0 +1,456 @@
"""Unit tests for QobuzClient playlist + favorites methods.
Covers the Sync-page parity added for github issue #677:
- `get_user_playlists` paginates + normalizes the playlist list
- `get_playlist` paginates the tracklist + normalizes track shape
- `get_playlist` recognizes the virtual `qobuz-favorites` ID and
dispatches to `get_user_favorite_tracks` (same pattern as Tidal's
COLLECTION_PLAYLIST_ID)
- `get_user_favorite_tracks_count` reads the cheap count-only path
"""
from __future__ import annotations
import sys
import types
from typing import Any, Dict, List
import pytest
@pytest.fixture
def qobuz_client_module():
"""Import core.qobuz_client with config_manager stubbed to a mutable
in-memory dict. Snapshots and restores sys.modules entries on
teardown so downstream tests still see the real config.
"""
config_state: Dict[str, Any] = {}
class _StubConfigManager:
def get(self, key, default=None):
cur: Any = config_state
for part in key.split('.'):
if isinstance(cur, dict) and part in cur:
cur = cur[part]
else:
return default
return cur
def set(self, key, value):
cur: Any = config_state
parts = key.split('.')
for part in parts[:-1]:
cur = cur.setdefault(part, {})
cur[parts[-1]] = value
original_modules = {
name: sys.modules.get(name)
for name in ('config', 'config.settings', 'core.qobuz_client')
}
if 'config' not in sys.modules:
sys.modules['config'] = types.ModuleType('config')
settings_mod = types.ModuleType('config.settings')
settings_mod.config_manager = _StubConfigManager()
sys.modules['config.settings'] = settings_mod
sys.modules.pop('core.qobuz_client', None)
try:
import core.qobuz_client as qobuz_client_module
yield qobuz_client_module, config_state
finally:
for name, original in original_modules.items():
if original is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = original
@pytest.fixture
def authed_client(qobuz_client_module):
"""A QobuzClient with stub credentials so is_authenticated() returns True."""
module, config = qobuz_client_module
config['qobuz'] = {
'session': {
'app_id': 'APP-1',
'app_secret': 'SECRET-1',
'user_auth_token': 'TOKEN-1',
}
}
client = module.QobuzClient()
client.reload_credentials()
assert client.is_authenticated() is True
return client
def _install_api_responder(client, responder):
"""Replace `_api_request` with a deterministic responder for the test."""
client._api_request = responder # type: ignore[method-assign]
# ---------------------------------------------------------------------------
# get_user_playlists — pagination + normalization
# ---------------------------------------------------------------------------
def test_get_user_playlists_returns_normalized_metadata(authed_client):
calls: List[Dict[str, Any]] = []
def responder(endpoint, params=None):
calls.append({'endpoint': endpoint, 'params': params})
return {
'playlists': {
'items': [
{
'id': 1001,
'name': 'My Mix',
'description': 'on repeat',
'is_public': True,
'tracks_count': 12,
'images': ['https://qobuz.example/cover.jpg'],
},
],
'total': 1,
}
}
_install_api_responder(authed_client, responder)
playlists = authed_client.get_user_playlists()
assert calls == [{
'endpoint': 'playlist/getUserPlaylists',
'params': {'limit': 100, 'offset': 0},
}]
assert playlists == [{
'id': '1001',
'name': 'My Mix',
'description': 'on repeat',
'public': True,
'track_count': 12,
'image_url': 'https://qobuz.example/cover.jpg',
'external_urls': {'qobuz': 'https://play.qobuz.com/playlist/1001'},
}]
def test_get_user_playlists_paginates_until_total_reached(authed_client):
# Two pages of 100 each, third page returns empty to verify the loop
# terminates on `total` rather than waiting for an empty page.
page_one = [{'id': i, 'name': f'P{i}', 'tracks_count': 0} for i in range(100)]
page_two = [{'id': 100 + i, 'name': f'P{100 + i}', 'tracks_count': 0} for i in range(50)]
calls: List[int] = []
def responder(endpoint, params=None):
calls.append(params['offset'])
if params['offset'] == 0:
return {'playlists': {'items': page_one, 'total': 150}}
if params['offset'] == 100:
return {'playlists': {'items': page_two, 'total': 150}}
return {'playlists': {'items': [], 'total': 150}}
_install_api_responder(authed_client, responder)
playlists = authed_client.get_user_playlists()
assert len(playlists) == 150
assert calls == [0, 100] # No third request needed
def test_get_user_playlists_returns_empty_when_unauthenticated(qobuz_client_module):
module, _ = qobuz_client_module
client = module.QobuzClient() # no credentials configured
assert client.is_authenticated() is False
def responder(endpoint, params=None):
raise AssertionError('should not hit the API when unauthenticated')
_install_api_responder(client, responder)
assert client.get_user_playlists() == []
# ---------------------------------------------------------------------------
# get_playlist — track pagination + normalization
# ---------------------------------------------------------------------------
def test_get_playlist_normalizes_tracks(authed_client):
def responder(endpoint, params=None):
assert endpoint == 'playlist/get'
return {
'id': 2002,
'name': 'Deep Cuts',
'description': '',
'is_public': False,
'tracks_count': 1,
'images': ['https://qobuz.example/dc.jpg'],
'tracks': {
'items': [
{
'id': 555,
'title': 'Forgotten Track',
'duration': 240,
'parental_warning': True,
'performer': {'name': 'Some Artist'},
'album': {
'title': 'Some Album',
'image': {'large': 'https://qobuz.example/art.jpg'},
},
},
],
'total': 1,
},
}
_install_api_responder(authed_client, responder)
playlist = authed_client.get_playlist('2002')
assert playlist is not None
assert playlist['id'] == '2002'
assert playlist['name'] == 'Deep Cuts'
assert playlist['track_count'] == 1
assert playlist['tracks'] == [{
'id': '555',
'name': 'Forgotten Track',
'artists': ['Some Artist'],
'album': 'Some Album',
'duration_ms': 240_000,
'image_url': 'https://qobuz.example/art.jpg',
'external_urls': {'qobuz': 'https://play.qobuz.com/track/555'},
'explicit': True,
}]
def test_get_playlist_routes_favorites_virtual_id(authed_client):
"""The virtual `qobuz-favorites` ID must dispatch to the favorites
endpoint rather than the playlist/get endpoint mirrors Tidal's
COLLECTION_PLAYLIST_ID pattern."""
seen_endpoints: List[str] = []
def responder(endpoint, params=None):
seen_endpoints.append(endpoint)
# favorite/getUserFavorites is the only endpoint that should fire
return {
'tracks': {
'items': [
{
'id': 777,
'title': 'Liked Song',
'duration': 180,
'performer': {'name': 'Loved Artist'},
'album': {'title': 'Heart Album', 'image': {'large': 'https://q.example/h.jpg'}},
},
],
'total': 1,
}
}
_install_api_responder(authed_client, responder)
playlist = authed_client.get_playlist(authed_client.QOBUZ_FAVORITES_ID)
assert playlist is not None
assert playlist['id'] == authed_client.QOBUZ_FAVORITES_ID
assert playlist['name'] == authed_client.QOBUZ_FAVORITES_NAME
assert playlist['track_count'] == 1
assert playlist['tracks'][0]['name'] == 'Liked Song'
# Only the favorites endpoint should have been hit — no playlist/get.
assert seen_endpoints == ['favorite/getUserFavorites']
def test_get_playlist_paginates_track_list(authed_client):
page_one_tracks = [
{'id': i, 'title': f'T{i}', 'duration': 100, 'performer': {'name': 'A'}, 'album': {'title': 'Alb', 'image': {}}}
for i in range(100)
]
page_two_tracks = [
{'id': 100 + i, 'title': f'T{100 + i}', 'duration': 100, 'performer': {'name': 'A'}, 'album': {'title': 'Alb', 'image': {}}}
for i in range(25)
]
offsets: List[int] = []
def responder(endpoint, params=None):
offsets.append(params['offset'])
if params['offset'] == 0:
return {
'id': 'X', 'name': 'Long', 'description': '', 'is_public': False,
'tracks_count': 125, 'images': [],
'tracks': {'items': page_one_tracks, 'total': 125},
}
if params['offset'] == 100:
return {
'id': 'X', 'name': 'Long', 'description': '', 'is_public': False,
'tracks_count': 125, 'images': [],
'tracks': {'items': page_two_tracks, 'total': 125},
}
return {'tracks': {'items': [], 'total': 125}}
_install_api_responder(authed_client, responder)
playlist = authed_client.get_playlist('X')
assert playlist is not None
assert len(playlist['tracks']) == 125
assert playlist['track_count'] == 125
assert offsets == [0, 100]
def test_get_playlist_returns_none_when_unauthenticated(qobuz_client_module):
module, _ = qobuz_client_module
client = module.QobuzClient()
assert client.get_playlist('whatever') is None
# ---------------------------------------------------------------------------
# get_user_favorite_tracks + get_user_favorite_tracks_count
# ---------------------------------------------------------------------------
def test_get_user_favorite_tracks_paginates(authed_client):
def make_items(start, count):
return [
{'id': start + i, 'title': f'F{start + i}', 'duration': 200,
'performer': {'name': 'Fav Artist'},
'album': {'title': 'Fav Album', 'image': {}}}
for i in range(count)
]
offsets: List[int] = []
def responder(endpoint, params=None):
assert endpoint == 'favorite/getUserFavorites'
assert params['type'] == 'tracks'
offsets.append(params['offset'])
if params['offset'] == 0:
return {'tracks': {'items': make_items(0, 100), 'total': 130}}
if params['offset'] == 100:
return {'tracks': {'items': make_items(100, 30), 'total': 130}}
return {'tracks': {'items': [], 'total': 130}}
_install_api_responder(authed_client, responder)
tracks = authed_client.get_user_favorite_tracks()
assert len(tracks) == 130
assert offsets == [0, 100]
assert tracks[0]['name'] == 'F0'
assert tracks[-1]['name'] == 'F129'
def test_get_user_favorite_tracks_fetches_all_by_default(authed_client):
def make_items(start, count):
return [
{'id': start + i, 'title': f'F{start + i}', 'duration': 200,
'performer': {'name': 'Fav Artist'},
'album': {'title': 'Fav Album', 'image': {}}}
for i in range(count)
]
offsets: List[int] = []
def responder(endpoint, params=None):
assert endpoint == 'favorite/getUserFavorites'
offsets.append(params['offset'])
start = params['offset']
remaining = max(0, 625 - start)
return {'tracks': {'items': make_items(start, min(params['limit'], remaining)), 'total': 625}}
_install_api_responder(authed_client, responder)
tracks = authed_client.get_user_favorite_tracks()
assert len(tracks) == 625
assert offsets == [0, 100, 200, 300, 400, 500, 600]
assert tracks[-1]['name'] == 'F624'
def test_get_user_favorite_tracks_honors_explicit_limit(authed_client):
def make_items(start, count):
return [
{'id': start + i, 'title': f'F{start + i}', 'duration': 200,
'performer': {'name': 'Fav Artist'},
'album': {'title': 'Fav Album', 'image': {}}}
for i in range(count)
]
requests: List[Dict[str, Any]] = []
def responder(endpoint, params=None):
assert endpoint == 'favorite/getUserFavorites'
requests.append(dict(params))
start = params['offset']
return {'tracks': {'items': make_items(start, params['limit']), 'total': 625}}
_install_api_responder(authed_client, responder)
tracks = authed_client.get_user_favorite_tracks(limit=150)
assert len(tracks) == 150
assert requests == [
{'type': 'tracks', 'limit': 100, 'offset': 0},
{'type': 'tracks', 'limit': 50, 'offset': 100},
]
assert tracks[-1]['name'] == 'F149'
def test_get_user_favorite_tracks_count_uses_cheap_call(authed_client):
captured: Dict[str, Any] = {}
def responder(endpoint, params=None):
captured['endpoint'] = endpoint
captured['params'] = params
return {'tracks': {'items': [], 'total': 4242}}
_install_api_responder(authed_client, responder)
count = authed_client.get_user_favorite_tracks_count()
assert count == 4242
# Single request with limit=1 — must not iterate the full list.
assert captured == {
'endpoint': 'favorite/getUserFavorites',
'params': {'type': 'tracks', 'limit': 1, 'offset': 0},
}
def test_get_user_favorite_tracks_count_returns_zero_when_unauthenticated(qobuz_client_module):
module, _ = qobuz_client_module
client = module.QobuzClient()
assert client.get_user_favorite_tracks_count() == 0
# ---------------------------------------------------------------------------
# Track normalization fallbacks — artist resolution chain
# ---------------------------------------------------------------------------
def test_track_normalization_falls_back_to_album_artist(authed_client):
"""When `performer.name` is missing, album.artist.name should win
over the bare 'Unknown Artist' default."""
def responder(endpoint, params=None):
return {
'id': 'P', 'name': 'p', 'description': '', 'is_public': False,
'tracks_count': 1, 'images': [],
'tracks': {
'items': [{
'id': 1, 'title': 'X', 'duration': 10,
'album': {
'title': 'A',
'artist': {'name': 'Album Artist'},
'image': {'large': ''},
},
}],
'total': 1,
}
}
_install_api_responder(authed_client, responder)
playlist = authed_client.get_playlist('P')
assert playlist['tracks'][0]['artists'] == ['Album Artist']
def test_track_normalization_uses_unknown_artist_when_all_sources_empty(authed_client):
def responder(endpoint, params=None):
return {
'id': 'P', 'name': 'p', 'description': '', 'is_public': False,
'tracks_count': 1, 'images': [],
'tracks': {
'items': [{'id': 1, 'title': 'X', 'duration': 10}],
'total': 1,
}
}
_install_api_responder(authed_client, responder)
playlist = authed_client.get_playlist('P')
assert playlist['tracks'][0]['artists'] == ['Unknown Artist']

View file

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

View file

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

View file

@ -40,7 +40,7 @@ logger = setup_logging(_log_level, _log_path)
# App version — single source of truth for backup metadata, system-info, update check, etc.
# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release.
_SOULSYNC_BASE_VERSION = "2.5.9"
_SOULSYNC_BASE_VERSION = "2.6.0"
def _build_version_string():
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
@ -1586,6 +1586,7 @@ def _shutdown_runtime_components():
(import_singles_executor, "import singles executor"),
(tidal_discovery_executor, "tidal discovery executor"),
(deezer_discovery_executor, "deezer discovery executor"),
(qobuz_discovery_executor, "qobuz discovery executor"),
(spotify_public_discovery_executor, "spotify public discovery executor"),
(youtube_discovery_executor, "youtube discovery executor"),
(beatport_discovery_executor, "beatport discovery executor"),
@ -8738,6 +8739,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)
@ -21844,6 +21848,655 @@ def cancel_deezer_sync(playlist_id):
return jsonify({"error": str(e)}), 500
# ===================================================================
# QOBUZ PLAYLIST DISCOVERY API ENDPOINTS
# ===================================================================
#
# Mirrors the Tidal + Deezer endpoint set for parity on the Sync page.
# Qobuz playlists arrive from `core/qobuz_client.py` as dicts (matching
# the Deezer client's shape), so the state + endpoint code follows the
# Deezer template rather than Tidal's dataclass-based one. Github issue
# #677.
# Global state for Qobuz playlist discovery management
qobuz_discovery_states = {} # Key: playlist_id, Value: discovery state
qobuz_discovery_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="qobuz_discovery")
def _get_qobuz_client_for_sync():
"""Resolve the Qobuz client via the download orchestrator.
The orchestrator owns the canonical instance (same one Settings
Connections authenticates against), so the Sync page tab always sees
fresh auth state without a second login flow.
"""
if not download_orchestrator or not hasattr(download_orchestrator, 'client'):
return None
try:
return download_orchestrator.client("qobuz")
except Exception as exc:
logger.debug(f"Qobuz client lookup failed: {exc}")
return None
@app.route('/api/qobuz/playlists', methods=['GET'])
def get_qobuz_playlists():
"""Fetches the authenticated user's Qobuz playlists (metadata only).
Tracks are fetched on demand by the per-playlist detail endpoint
matches the Tidal + Deezer behaviour so the Sync page renderer can
treat all three services uniformly.
"""
qobuz = _get_qobuz_client_for_sync()
if not qobuz or not qobuz.is_authenticated():
return jsonify({"error": "Qobuz not authenticated."}), 401
try:
playlists = qobuz.get_user_playlists()
playlist_data = []
for p in playlists:
playlist_data.append({
"id": p['id'],
"name": p['name'],
"owner": "You",
"track_count": p.get('track_count', 0),
"image_url": p.get('image_url') or None,
"description": p.get('description', ''),
"tracks": []
})
# Append virtual "Favorite Tracks" entry at the END (mirrors
# Tidal's COLLECTION_PLAYLIST_ID pattern — count only here, full
# fetch deferred to the per-playlist detail endpoint).
try:
from core.qobuz_client import QobuzClient as _QobuzClientTypeRef
favorites_count = qobuz.get_user_favorite_tracks_count()
if favorites_count > 0:
playlist_data.append({
"id": qobuz.QOBUZ_FAVORITES_ID,
"name": qobuz.QOBUZ_FAVORITES_NAME,
"owner": "You",
"track_count": favorites_count,
"image_url": None,
"description": qobuz.QOBUZ_FAVORITES_DESCRIPTION,
"tracks": [],
})
logger.info(
f"Added virtual '{qobuz.QOBUZ_FAVORITES_NAME}' playlist with {favorites_count} tracks (count only)"
)
except Exception as favorites_error:
logger.error(f"Failed to add Qobuz Favorite Tracks playlist: {favorites_error}")
logger.info(f"Loaded {len(playlist_data)} Qobuz playlists")
return jsonify(playlist_data)
except Exception as e:
logger.error(f"Error loading Qobuz playlists: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/qobuz/playlist/<playlist_id>', methods=['GET'])
def get_qobuz_playlist_tracks(playlist_id):
"""Fetches full track details for a specific Qobuz playlist."""
qobuz = _get_qobuz_client_for_sync()
if not qobuz or not qobuz.is_authenticated():
return jsonify({"error": "Qobuz not authenticated."}), 401
try:
logger.info(f"Getting full Qobuz playlist with tracks for: {playlist_id}")
full_playlist = qobuz.get_playlist(playlist_id)
if not full_playlist:
return jsonify({"error": "Playlist not found or unable to access."}), 404
tracks = full_playlist.get('tracks') or []
if not tracks:
return jsonify({"error": "This playlist appears to have no tracks or they cannot be accessed"}), 403
logger.info(f"Loaded {len(tracks)} tracks from Qobuz playlist: {full_playlist['name']}")
playlist_dict = {
'id': full_playlist['id'],
'name': full_playlist['name'],
'description': full_playlist.get('description', ''),
'owner': 'You',
'track_count': len(tracks),
'image_url': full_playlist.get('image_url') or None,
'tracks': tracks,
}
return jsonify(playlist_dict)
except Exception as e:
logger.error(f"Error getting Qobuz playlist tracks: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/qobuz/discovery/start/<playlist_id>', methods=['POST'])
def start_qobuz_discovery(playlist_id):
"""Start Spotify discovery process for a Qobuz playlist."""
try:
qobuz = _get_qobuz_client_for_sync()
if not qobuz or not qobuz.is_authenticated():
return jsonify({"error": "Qobuz not authenticated."}), 401
if playlist_id in qobuz_discovery_states:
existing_state = qobuz_discovery_states[playlist_id]
if existing_state['phase'] == 'discovering':
return jsonify({"error": "Discovery already in progress"}), 400
if not existing_state.get('playlist'):
playlist_data = qobuz.get_playlist(playlist_id)
if not playlist_data:
return jsonify({"error": "Qobuz playlist not found"}), 404
existing_state['playlist'] = playlist_data
existing_state['phase'] = 'discovering'
existing_state['status'] = 'discovering'
existing_state['last_accessed'] = time.time()
state = existing_state
else:
playlist_data = qobuz.get_playlist(playlist_id)
if not playlist_data:
return jsonify({"error": "Qobuz playlist not found"}), 404
if not playlist_data.get('tracks'):
return jsonify({"error": "Playlist has no tracks"}), 400
state = {
'playlist': playlist_data,
'phase': 'discovering',
'status': 'discovering',
'discovery_progress': 0,
'spotify_matches': 0,
'spotify_total': len(playlist_data['tracks']),
'discovery_results': [],
'sync_playlist_id': None,
'converted_spotify_playlist_id': None,
'download_process_id': None,
'created_at': time.time(),
'last_accessed': time.time(),
'discovery_future': None,
'sync_progress': {}
}
qobuz_discovery_states[playlist_id] = state
playlist_name = state['playlist']['name']
track_count = len(state['playlist']['tracks'])
add_activity_item("", "Qobuz Discovery Started", f"'{playlist_name}' - {track_count} tracks", "Now")
qobuz_discovery_states[playlist_id]['_profile_id'] = get_current_profile_id()
future = qobuz_discovery_executor.submit(_run_qobuz_discovery_worker, playlist_id)
state['discovery_future'] = future
logger.info(f"Started Spotify discovery for Qobuz playlist: {playlist_name}")
return jsonify({"success": True, "message": "Discovery started"})
except Exception as e:
logger.error(f"Error starting Qobuz discovery: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/qobuz/discovery/status/<playlist_id>', methods=['GET'])
def get_qobuz_discovery_status(playlist_id):
"""Get real-time discovery status for a Qobuz playlist."""
try:
if playlist_id not in qobuz_discovery_states:
return jsonify({"error": "Qobuz discovery not found"}), 404
state = qobuz_discovery_states[playlist_id]
state['last_accessed'] = time.time()
response = {
'phase': state['phase'],
'status': state['status'],
'progress': state['discovery_progress'],
'spotify_matches': state['spotify_matches'],
'spotify_total': state['spotify_total'],
'results': state['discovery_results'],
'complete': state['phase'] == 'discovered'
}
return jsonify(response)
except Exception as e:
logger.error(f"Error getting Qobuz discovery status: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/qobuz/discovery/update_match', methods=['POST'])
def update_qobuz_discovery_match():
"""Update a Qobuz discovery result with a manually selected Spotify track."""
try:
data = request.get_json()
identifier = data.get('identifier') # playlist_id
track_index = data.get('track_index')
spotify_track = data.get('spotify_track')
if not identifier or track_index is None or not spotify_track:
return jsonify({'error': 'Missing required fields'}), 400
state = qobuz_discovery_states.get(identifier)
if not state:
return jsonify({'error': 'Discovery state not found'}), 404
if track_index >= len(state['discovery_results']):
return jsonify({'error': 'Invalid track index'}), 400
result = state['discovery_results'][track_index]
old_status = result.get('status')
result['status'] = 'Found'
result['status_class'] = 'found'
result['spotify_track'] = spotify_track['name']
result['spotify_artist'] = _join_artist_names(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else _extract_artist_name(spotify_track['artists'])
result['spotify_album'] = spotify_track['album']
result['spotify_id'] = spotify_track['id']
duration_ms = spotify_track.get('duration_ms', 0)
if duration_ms:
minutes = duration_ms // 60000
seconds = (duration_ms % 60000) // 1000
result['duration'] = f"{minutes}:{seconds:02d}"
else:
result['duration'] = '0:00'
# Manual match from the fix modal — build rich spotify_data matching
# the normal discovery shape, clear wing-it flag since the user
# picked a real metadata match.
result['spotify_data'] = _build_fix_modal_spotify_data(spotify_track)
result['wing_it_fallback'] = False
result['manual_match'] = True
if old_status != 'found' and old_status != 'Found':
state['spotify_matches'] = state.get('spotify_matches', 0) + 1
logger.info(f"Manual match updated: qobuz - {identifier} - track {track_index}")
logger.info(f"{result['spotify_artist']} - {result['spotify_track']}")
try:
original_track = result.get('qobuz_track', {})
original_name = original_track.get('name', spotify_track['name'])
original_artists = original_track.get('artists', [])
original_artist = original_artists[0] if original_artists else ''
cache_key = _get_discovery_cache_key(original_name, original_artist)
artists_list = spotify_track['artists']
if isinstance(artists_list, list):
artists_list = [a if isinstance(a, str) else a.get('name', '') for a in artists_list]
image_url = spotify_track.get('image_url') or ''
album_raw = spotify_track.get('album', '')
if isinstance(album_raw, dict):
album_obj = dict(album_raw)
if image_url and not album_obj.get('image_url'):
album_obj['image_url'] = image_url
if image_url and not album_obj.get('images'):
album_obj['images'] = [{'url': image_url}]
else:
album_obj = {'name': album_raw or ''}
if image_url:
album_obj['image_url'] = image_url
album_obj['images'] = [{'url': image_url}]
matched_data = {
'id': spotify_track['id'],
'name': spotify_track['name'],
'artists': artists_list,
'album': album_obj,
'duration_ms': spotify_track.get('duration_ms', 0),
'image_url': image_url,
'source': 'spotify',
}
cache_db = get_database()
cache_db.save_discovery_cache_match(
cache_key[0], cache_key[1], _get_active_discovery_source(), 1.0, matched_data,
original_name, original_artist
)
logger.info(f"Manual fix saved to discovery cache: {original_name} by {original_artist}")
except Exception as cache_err:
logger.error(f"Error saving manual fix to discovery cache: {cache_err}")
return jsonify({'success': True, 'result': result})
except Exception as e:
logger.error(f"Error updating Qobuz discovery match: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/qobuz/playlists/states', methods=['GET'])
def get_qobuz_playlist_states():
"""Get all stored Qobuz playlist discovery states for frontend hydration."""
try:
states = []
current_time = time.time()
for playlist_id, state in qobuz_discovery_states.items():
state['last_accessed'] = current_time
state_info = {
'playlist_id': playlist_id,
'phase': state['phase'],
'status': state['status'],
'discovery_progress': state['discovery_progress'],
'spotify_matches': state['spotify_matches'],
'spotify_total': state['spotify_total'],
'discovery_results': state['discovery_results'],
'converted_spotify_playlist_id': state.get('converted_spotify_playlist_id'),
'download_process_id': state.get('download_process_id'),
'last_accessed': state['last_accessed']
}
states.append(state_info)
logger.info(f"Returning {len(states)} stored Qobuz playlist states for hydration")
return jsonify({"states": states})
except Exception as e:
logger.error(f"Error getting Qobuz playlist states: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/qobuz/state/<playlist_id>', methods=['GET'])
def get_qobuz_playlist_state(playlist_id):
"""Get specific Qobuz playlist state (detailed version)."""
try:
if playlist_id not in qobuz_discovery_states:
return jsonify({"error": "Qobuz playlist not found"}), 404
state = qobuz_discovery_states[playlist_id]
state['last_accessed'] = time.time()
response = {
'playlist_id': playlist_id,
'playlist': state['playlist'],
'phase': state['phase'],
'status': state['status'],
'discovery_progress': state['discovery_progress'],
'spotify_matches': state['spotify_matches'],
'spotify_total': state['spotify_total'],
'discovery_results': state['discovery_results'],
'sync_playlist_id': state.get('sync_playlist_id'),
'converted_spotify_playlist_id': state.get('converted_spotify_playlist_id'),
'download_process_id': state.get('download_process_id'),
'sync_progress': state.get('sync_progress', {}),
'last_accessed': state['last_accessed']
}
return jsonify(response)
except Exception as e:
logger.error(f"Error getting Qobuz playlist state: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/qobuz/reset/<playlist_id>', methods=['POST'])
def reset_qobuz_playlist(playlist_id):
"""Reset Qobuz playlist to fresh phase (clear discovery/sync data)."""
try:
if playlist_id not in qobuz_discovery_states:
return jsonify({"error": "Qobuz playlist not found"}), 404
state = qobuz_discovery_states[playlist_id]
if 'discovery_future' in state and state['discovery_future']:
state['discovery_future'].cancel()
state['phase'] = 'fresh'
state['status'] = 'fresh'
state['discovery_results'] = []
state['discovery_progress'] = 0
state['spotify_matches'] = 0
state['sync_playlist_id'] = None
state['converted_spotify_playlist_id'] = None
state['download_process_id'] = None
state['sync_progress'] = {}
state['discovery_future'] = None
state['last_accessed'] = time.time()
logger.info(f"Reset Qobuz playlist to fresh: {playlist_id}")
return jsonify({"success": True, "message": "Playlist reset to fresh phase"})
except Exception as e:
logger.error(f"Error resetting Qobuz playlist: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/qobuz/delete/<playlist_id>', methods=['POST'])
def delete_qobuz_playlist(playlist_id):
"""Delete Qobuz playlist state completely."""
try:
if playlist_id not in qobuz_discovery_states:
return jsonify({"error": "Qobuz playlist not found"}), 404
state = qobuz_discovery_states[playlist_id]
if 'discovery_future' in state and state['discovery_future']:
state['discovery_future'].cancel()
del qobuz_discovery_states[playlist_id]
logger.info(f"Deleted Qobuz playlist state: {playlist_id}")
return jsonify({"success": True, "message": "Playlist deleted"})
except Exception as e:
logger.error(f"Error deleting Qobuz playlist: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/qobuz/update_phase/<playlist_id>', methods=['POST'])
def update_qobuz_playlist_phase(playlist_id):
"""Update Qobuz playlist phase (used when modal closes to reset from download_complete to discovered)."""
try:
if playlist_id not in qobuz_discovery_states:
return jsonify({"error": "Qobuz playlist not found"}), 404
data = request.get_json()
if not data or 'phase' not in data:
return jsonify({"error": "Phase not provided"}), 400
new_phase = data['phase']
valid_phases = ['fresh', 'discovering', 'discovered', 'syncing', 'sync_complete', 'downloading', 'download_complete']
if new_phase not in valid_phases:
return jsonify({"error": f"Invalid phase. Must be one of: {', '.join(valid_phases)}"}), 400
state = qobuz_discovery_states[playlist_id]
old_phase = state.get('phase', 'unknown')
state['phase'] = new_phase
state['last_accessed'] = time.time()
if 'download_process_id' in data:
state['download_process_id'] = data['download_process_id']
if 'converted_spotify_playlist_id' in data:
state['converted_spotify_playlist_id'] = data['converted_spotify_playlist_id']
logger.info(f"Updated Qobuz playlist {playlist_id} phase: {old_phase}{new_phase}")
return jsonify({"success": True, "message": f"Phase updated to {new_phase}", "old_phase": old_phase, "new_phase": new_phase})
except Exception as e:
logger.error(f"Error updating Qobuz playlist phase: {e}")
return jsonify({"error": str(e)}), 500
# Qobuz discovery worker logic lives in core/discovery/qobuz.py.
from core.discovery import qobuz as _discovery_qobuz
def _build_qobuz_discovery_deps():
"""Build the QobuzDiscoveryDeps bundle from web_server.py globals on each call."""
return _discovery_qobuz.QobuzDiscoveryDeps(
qobuz_discovery_states=qobuz_discovery_states,
spotify_client=spotify_client,
pause_enrichment_workers=_pause_enrichment_workers,
resume_enrichment_workers=_resume_enrichment_workers,
get_active_discovery_source=_get_active_discovery_source,
get_metadata_fallback_client=_get_metadata_fallback_client,
get_discovery_cache_key=_get_discovery_cache_key,
get_database=get_database,
validate_discovery_cache_artist=_validate_discovery_cache_artist,
search_spotify_for_tidal_track=_search_spotify_for_tidal_track,
build_discovery_wing_it_stub=_build_discovery_wing_it_stub,
add_activity_item=add_activity_item,
sync_discovery_results_to_mirrored=_sync_discovery_results_to_mirrored,
)
def _run_qobuz_discovery_worker(playlist_id):
return _discovery_qobuz.run_qobuz_discovery_worker(playlist_id, _build_qobuz_discovery_deps())
def convert_qobuz_results_to_spotify_tracks(discovery_results):
"""Convert Qobuz discovery results to Spotify tracks format for sync."""
spotify_tracks = []
for result in discovery_results:
if result.get('spotify_data'):
spotify_data = result['spotify_data']
track = {
'id': spotify_data['id'],
'name': spotify_data['name'],
'artists': spotify_data['artists'],
'album': spotify_data['album'],
'duration_ms': spotify_data.get('duration_ms', 0)
}
if spotify_data.get('track_number'):
track['track_number'] = spotify_data['track_number']
if spotify_data.get('disc_number'):
track['disc_number'] = spotify_data['disc_number']
spotify_tracks.append(track)
elif result.get('spotify_track') and result.get('status_class') == 'found':
track = {
'id': result.get('spotify_id', 'unknown'),
'name': result.get('spotify_track', 'Unknown Track'),
'artists': [result.get('spotify_artist', 'Unknown Artist')] if result.get('spotify_artist') else ['Unknown Artist'],
'album': result.get('spotify_album', 'Unknown Album'),
'duration_ms': 0
}
spotify_tracks.append(track)
logger.info(f"Converted {len(spotify_tracks)} Qobuz matches to Spotify tracks for sync")
return spotify_tracks
# ===================================================================
# QOBUZ SYNC API ENDPOINTS
# ===================================================================
@app.route('/api/qobuz/sync/start/<playlist_id>', methods=['POST'])
def start_qobuz_sync(playlist_id):
"""Start sync process for a Qobuz playlist using discovered Spotify tracks."""
try:
if playlist_id not in qobuz_discovery_states:
return jsonify({"error": "Qobuz playlist not found"}), 404
state = qobuz_discovery_states[playlist_id]
state['last_accessed'] = time.time()
if state['phase'] not in ['discovered', 'sync_complete', 'download_complete']:
return jsonify({"error": "Qobuz playlist not ready for sync"}), 400
spotify_tracks = convert_qobuz_results_to_spotify_tracks(state['discovery_results'])
if not spotify_tracks:
return jsonify({"error": "No Spotify matches found for sync"}), 400
sync_playlist_id = f"qobuz_{playlist_id}"
playlist_name = state['playlist']['name']
add_activity_item("", "Qobuz Sync Started", f"'{playlist_name}' - {len(spotify_tracks)} tracks", "Now")
state['phase'] = 'syncing'
state['sync_playlist_id'] = sync_playlist_id
state['sync_progress'] = {}
sync_data = {
'playlist_id': sync_playlist_id,
'playlist_name': playlist_name,
'tracks': spotify_tracks
}
with sync_lock:
sync_states[sync_playlist_id] = {"status": "starting", "progress": {}}
playlist_image_url = state['playlist'].get('image_url', '')
future = sync_executor.submit(_run_sync_task, sync_playlist_id, sync_data['playlist_name'], spotify_tracks, None, get_current_profile_id(), playlist_image_url)
active_sync_workers[sync_playlist_id] = future
logger.info(f"Started Qobuz sync for: {playlist_name} ({len(spotify_tracks)} tracks)")
return jsonify({"success": True, "sync_playlist_id": sync_playlist_id})
except Exception as e:
logger.error(f"Error starting Qobuz sync: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/qobuz/sync/status/<playlist_id>', methods=['GET'])
def get_qobuz_sync_status(playlist_id):
"""Get sync status for a Qobuz playlist."""
try:
if playlist_id not in qobuz_discovery_states:
return jsonify({"error": "Qobuz playlist not found"}), 404
state = qobuz_discovery_states[playlist_id]
state['last_accessed'] = time.time()
sync_playlist_id = state.get('sync_playlist_id')
if not sync_playlist_id:
return jsonify({"error": "No sync in progress"}), 404
with sync_lock:
sync_state = sync_states.get(sync_playlist_id, {})
response = {
'phase': state['phase'],
'sync_status': sync_state.get('status', 'unknown'),
'progress': sync_state.get('progress', {}),
'complete': sync_state.get('status') == 'finished',
'error': sync_state.get('error')
}
if sync_state.get('status') == 'finished':
state['phase'] = 'sync_complete'
state['sync_progress'] = sync_state.get('progress', {})
playlist_name = state['playlist']['name']
add_activity_item("", "Sync Complete", f"Qobuz playlist '{playlist_name}' synced successfully", "Now")
elif sync_state.get('status') == 'error':
state['phase'] = 'discovered'
playlist_name = state['playlist']['name']
add_activity_item("", "Sync Failed", f"Qobuz playlist '{playlist_name}' sync failed", "Now")
return jsonify(response)
except Exception as e:
logger.error(f"Error getting Qobuz sync status: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/qobuz/sync/cancel/<playlist_id>', methods=['POST'])
def cancel_qobuz_sync(playlist_id):
"""Cancel sync for a Qobuz playlist."""
try:
if playlist_id not in qobuz_discovery_states:
return jsonify({"error": "Qobuz playlist not found"}), 404
state = qobuz_discovery_states[playlist_id]
state['last_accessed'] = time.time()
sync_playlist_id = state.get('sync_playlist_id')
if sync_playlist_id:
with sync_lock:
sync_states[sync_playlist_id] = {"status": "cancelled"}
if sync_playlist_id in active_sync_workers:
del active_sync_workers[sync_playlist_id]
state['phase'] = 'discovered'
state['sync_playlist_id'] = None
state['sync_progress'] = {}
return jsonify({"success": True, "message": "Qobuz sync cancelled"})
except Exception as e:
logger.error(f"Error cancelling Qobuz sync: {e}")
return jsonify({"error": str(e)}), 500
# ===================================================================
# SPOTIFY PUBLIC PLAYLIST DISCOVERY API ENDPOINTS
# ===================================================================
@ -24315,9 +24968,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 +25056,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:
@ -33159,6 +33817,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."""
@ -34519,6 +35272,7 @@ def _emit_discovery_progress_loop():
"""Push discovery progress to subscribed rooms every 1 second."""
platform_states = {
'tidal': lambda: tidal_discovery_states,
'qobuz': lambda: qobuz_discovery_states,
'deezer': lambda: deezer_discovery_states,
'youtube': lambda: youtube_playlist_states,
'beatport': lambda: beatport_chart_states,

View file

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

View file

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

View file

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

View file

@ -0,0 +1,408 @@
# 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.
- 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.
This route has now validated those assumptions successfully.
## 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.
- 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
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
## 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

View file

@ -194,78 +194,78 @@
<!-- Navigation Section -->
<nav class="sidebar-nav">
<button class="nav-button" data-page="dashboard">
<a class="nav-button" data-page="dashboard" href="/dashboard">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="4" rx="1"/><rect x="3" y="14" width="7" height="4" rx="1"/><rect x="14" y="11" width="7" height="7" rx="1"/><line x1="5" y1="20" x2="5" y2="22"/><line x1="8" y1="19" x2="8" y2="22"/></svg></span>
<span class="nav-text">Dashboard</span>
</button>
<button class="nav-button" data-page="sync">
</a>
<a class="nav-button" data-page="sync" href="/sync">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg></span>
<span class="nav-text">Sync</span>
</button>
<button class="nav-button" data-page="search">
</a>
<a class="nav-button" data-page="search" href="/search">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><polyline points="8 11 11 14 14 11"/></svg></span>
<span class="nav-text">Search</span>
</button>
<button class="nav-button" data-page="discover">
</a>
<a class="nav-button" data-page="discover" href="/discover">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76" fill="currentColor" opacity="0.2" stroke="currentColor"/></svg></span>
<span class="nav-text">Discover</span>
</button>
<button class="nav-button" data-page="playlist-explorer">
</a>
<a class="nav-button" data-page="playlist-explorer" href="/playlist-explorer">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="3"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="12" x2="5" y2="18"/><line x1="12" y1="12" x2="19" y2="18"/><circle cx="5" cy="19" r="2"/><circle cx="19" cy="19" r="2"/><line x1="12" y1="12" x2="12" y2="18"/><circle cx="12" cy="19" r="2"/></svg></span>
<span class="nav-text">Explorer</span>
</button>
<button class="nav-button" data-page="watchlist">
</a>
<a class="nav-button" data-page="watchlist" href="/watchlist">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="nav-text">Watchlist</span>
<span class="dl-nav-badge hidden" id="watchlist-nav-badge">0</span>
</button>
<button class="nav-button" data-page="wishlist">
</a>
<a class="nav-button" data-page="wishlist" href="/wishlist">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>
<span class="nav-text">Wishlist</span>
<span class="dl-nav-badge hidden" id="wishlist-nav-badge">0</span>
</button>
<button class="nav-button" data-page="automations">
</a>
<a class="nav-button" data-page="automations" href="/automations">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></span>
<span class="nav-text">Automations</span>
</button>
<button class="nav-button" data-page="active-downloads">
</a>
<a class="nav-button" data-page="active-downloads" href="/active-downloads">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>
<span class="nav-text">Downloads</span>
<span class="dl-nav-badge hidden" id="dl-nav-badge">0</span>
</button>
<button class="nav-button" data-page="import">
</a>
<a class="nav-button" data-page="import" href="/import">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/><polyline points="7 9 12 4 17 9"/><line x1="12" y1="4" x2="12" y2="16"/></svg></span>
<span class="nav-text">Import</span>
</button>
<button class="nav-button" data-page="library">
</a>
<a class="nav-button" data-page="library" href="/library">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="9" y1="7" x2="16" y2="7"/><line x1="9" y1="11" x2="14" y2="11"/></svg></span>
<span class="nav-text">Library</span>
</button>
<button class="nav-button" data-page="tools">
</a>
<a class="nav-button" data-page="tools" href="/tools">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg></span>
<span class="nav-text">Tools</span>
</button>
<button class="nav-button" data-page="stats">
</a>
<a class="nav-button" data-page="stats" href="/stats">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg></span>
<span class="nav-text">Stats</span>
</button>
<button class="nav-button" data-page="settings">
</a>
<a class="nav-button" data-page="settings" href="/settings">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
<span class="nav-text">Settings</span>
</button>
<button class="nav-button" data-page="issues">
</a>
<a class="nav-button" data-page="issues" href="/issues">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></span>
<span class="nav-text">Issues</span>
<span class="issues-nav-badge hidden" id="issues-nav-badge">0</span>
</button>
<button class="nav-button" data-page="help">
</a>
<a class="nav-button" data-page="help" href="/help">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>
<span class="nav-text">Help & Docs</span>
</button>
<button class="nav-button" data-page="hydrabase" id="hydrabase-nav" style="display: none;">
</a>
<a class="nav-button" data-page="hydrabase" id="hydrabase-nav" href="/hydrabase" style="display: none;">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></span>
<span class="nav-text">Hydrabase</span>
</button>
</a>
</nav>
<!-- Spacer -->
@ -943,6 +943,9 @@
<button class="sync-tab-button" data-tab="deezer">
<span class="tab-icon deezer-icon"></span> Deezer
</button>
<button class="sync-tab-button" data-tab="qobuz">
<span class="tab-icon qobuz-icon"></span> Qobuz
</button>
<button class="sync-tab-button" data-tab="deezer-link">
<span class="tab-icon deezer-icon"></span> Deezer Link
</button>
@ -993,6 +996,17 @@
</div>
</div>
<!-- Qobuz Tab Content -->
<div class="sync-tab-content" id="qobuz-tab-content">
<div class="playlist-header">
<h3>Your Qobuz Playlists</h3>
<button class="refresh-button qobuz" id="qobuz-refresh-btn">🔄 Refresh</button>
</div>
<div class="playlist-scroll-container" id="qobuz-playlist-container">
<div class="playlist-placeholder">Click 'Refresh' to load your Qobuz playlists.</div>
</div>
</div>
<!-- Deezer Link Tab Content (URL Import) -->
<div class="sync-tab-content" id="deezer-link-tab-content">
<div class="youtube-input-section">
@ -4372,6 +4386,7 @@
<div id="hybrid-settings-container" style="display: none;">
<div class="setting-help-text">
Drag to reorder source priority. Enable sources you want to use. Downloads try each enabled source in order.
The first source can use album-level downloads when supported; later sources stay track-level fallback.
</div>
<div class="hybrid-source-list" id="hybrid-source-list">
<!-- Populated by JS -->
@ -6184,147 +6199,6 @@
</div>
</div>
<!-- Stats Page -->
<div class="page" id="stats-page">
<div class="stats-container">
<div class="stats-header">
<div class="stats-header-title">
<img src="/static/trans2.png" alt="Stats" class="page-header-icon">
<h1 class="header-title">Listening Stats</h1>
</div>
<div class="stats-header-controls">
<div class="stats-time-range" id="stats-time-range">
<button class="stats-range-btn active" data-range="7d">7 Days</button>
<button class="stats-range-btn" data-range="30d">30 Days</button>
<button class="stats-range-btn" data-range="12m">12 Months</button>
<button class="stats-range-btn" data-range="all">All Time</button>
</div>
<div class="stats-sync-controls">
<span class="stats-last-synced" id="stats-last-synced"></span>
<button class="stats-sync-btn" id="stats-sync-btn" onclick="triggerStatsSync()" title="Sync now"><span class="stats-sync-icon"></span></button>
</div>
</div>
</div>
<!-- Overview Cards -->
<div class="stats-overview" id="stats-overview">
<div class="stats-card">
<div class="stats-card-value" id="stats-total-plays">0</div>
<div class="stats-card-label">Total Plays</div>
</div>
<div class="stats-card">
<div class="stats-card-value" id="stats-listening-time">0h</div>
<div class="stats-card-label">Listening Time</div>
</div>
<div class="stats-card">
<div class="stats-card-value" id="stats-unique-artists">0</div>
<div class="stats-card-label">Artists</div>
</div>
<div class="stats-card">
<div class="stats-card-value" id="stats-unique-albums">0</div>
<div class="stats-card-label">Albums</div>
</div>
<div class="stats-card">
<div class="stats-card-value" id="stats-unique-tracks">0</div>
<div class="stats-card-label">Tracks</div>
</div>
</div>
<!-- Main Grid: Charts + Rankings -->
<div class="stats-main-grid">
<div class="stats-left-col">
<div class="stats-section-card">
<div class="stats-section-title">Listening Activity</div>
<div style="position:relative;height:220px;">
<canvas id="stats-timeline-chart"></canvas>
</div>
</div>
<div class="stats-section-card">
<div class="stats-section-title">Genre Breakdown</div>
<div class="stats-genre-chart-container">
<canvas id="stats-genre-chart"></canvas>
<div class="stats-genre-legend" id="stats-genre-legend"></div>
</div>
</div>
<div class="stats-section-card">
<div class="stats-section-title">Recently Played</div>
<div class="stats-recent-list" id="stats-recent-plays"></div>
</div>
</div>
<div class="stats-right-col">
<div class="stats-section-card">
<div class="stats-section-title">Top Artists</div>
<div class="stats-top-artists-visual" id="stats-top-artists-visual"></div>
<div class="stats-ranked-list" id="stats-top-artists"></div>
</div>
<div class="stats-section-card">
<div class="stats-section-title">Top Albums</div>
<div class="stats-ranked-list" id="stats-top-albums"></div>
</div>
<div class="stats-section-card">
<div class="stats-section-title">Top Tracks</div>
<div class="stats-ranked-list" id="stats-top-tracks"></div>
</div>
</div>
</div>
<!-- Library Health -->
<div class="stats-section-card stats-full-width">
<div class="stats-section-title">Library Health</div>
<div class="stats-health-grid" id="stats-library-health">
<div class="stats-health-item">
<div class="stats-health-label">Format Breakdown</div>
<div class="stats-format-bar" id="stats-format-bar"></div>
</div>
<div class="stats-health-item">
<div class="stats-health-value" id="stats-unplayed">0</div>
<div class="stats-health-label">Unplayed Tracks</div>
</div>
<div class="stats-health-item">
<div class="stats-health-value" id="stats-total-duration">0h</div>
<div class="stats-health-label">Total Duration</div>
</div>
<div class="stats-health-item">
<div class="stats-health-value" id="stats-total-tracks-count">0</div>
<div class="stats-health-label">Total Tracks</div>
</div>
</div>
<div class="stats-enrichment" id="stats-enrichment-coverage"></div>
</div>
<!-- Library Disk Usage -->
<div class="stats-section-card stats-full-width">
<div class="stats-section-title">Library Disk Usage</div>
<div class="stats-disk-usage-wrap" id="stats-disk-usage-wrap">
<div class="stats-disk-total-row">
<div class="stats-disk-total-value" id="stats-disk-total-value"></div>
<div class="stats-disk-total-meta" id="stats-disk-total-meta">Run a Deep Scan to populate</div>
</div>
<div class="stats-disk-formats" id="stats-disk-formats"></div>
</div>
</div>
<!-- Database Storage -->
<div class="stats-section-card stats-full-width">
<div class="stats-section-title">Database Storage</div>
<div class="stats-db-storage-wrap">
<div class="stats-db-chart-container">
<canvas id="stats-db-storage-chart"></canvas>
<div class="stats-db-total" id="stats-db-total"></div>
</div>
<div class="stats-db-legend" id="stats-db-legend"></div>
</div>
</div>
<!-- Empty State -->
<div class="stats-empty hidden" id="stats-empty">
<div class="stats-empty-icon">📊</div>
<h3>No Listening Data Yet</h3>
<p>Enable "Listening Stats" in Settings to start tracking your listening activity from your media server.</p>
</div>
</div>
</div>
<!-- Import Page -->
<div class="page" id="import-page">
<div class="import-page-container">
@ -7275,6 +7149,22 @@
</div>
</div>
</div>
<!-- Lyrics Panel -->
<div class="np-lyrics-panel collapsed" id="np-lyrics-panel">
<div class="np-lyrics-header">
<button class="np-lyrics-toggle" id="np-lyrics-toggle" title="Toggle lyrics" aria-expanded="false">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
<span>Lyrics</span>
<span class="np-lyrics-status" id="np-lyrics-status"></span>
</button>
</div>
<div class="np-lyrics-body hidden" id="np-lyrics-body">
<div class="np-lyrics-content" id="np-lyrics-content">
<div class="np-lyrics-empty">No lyrics loaded</div>
</div>
</div>
</div>
<!-- Queue Panel -->
<div class="np-queue-panel" id="np-queue-panel">
<div class="np-queue-header">
@ -8126,7 +8016,6 @@
</div>
<script src="{{ url_for('static', filename='vendor/socket.io.min.js', v=static_v) }}"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
{{ vite_assets('body')|safe }}
<script src="{{ url_for('static', filename='setup-wizard.js', v=static_v) }}"></script>
<!-- Split modules (was: script.js) — core.js must load first, init.js before shell-bridge.js -->

372
webui/package-lock.json generated
View file

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

View file

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

View file

@ -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<T>(promise: ResponsePromise<T>): Promise<T> {
try {
return await promise.json<T>();

View file

@ -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,32 +39,8 @@ function mockIssuesFetch() {
},
],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
throw new Error(`Unexpected fetch request: ${url}`);
});
}
function createShellBridge(overrides: Partial<ShellBridge> = {}): 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(),
...overrides,
};
}
describe('createAppRouter', () => {
beforeEach(() => {
vi.stubGlobal('fetch', mockIssuesFetch());
),
);
});
afterEach(() => {

View file

@ -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,16 +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(),
} as NonNullable<typeof window.SoulSyncWebShellBridge>;
window.SoulSyncWebShellBridge = createShellBridge();
await expect(waitForShellContext()).resolves.toEqual({
bridge: window.SoulSyncWebShellBridge,
@ -32,16 +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(),
} as NonNullable<typeof window.SoulSyncWebShellBridge>;
});
const contextPromise = waitForShellContext();
@ -96,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();
});
});

View file

@ -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<typeof window.SoulSyncWebShellBridge>;
@ -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 });

View file

@ -45,6 +45,22 @@ declare global {
) => void;
cancelSimilarArtistsLoad: () => void;
showReactHost: (pageId: ShellPageId) => 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<void>;
startStream: (searchResult: Record<string, unknown>) => void | Promise<void>;
showLoadingOverlay: (message?: string) => void;
hideLoadingOverlay: () => void;
};
}
}

View file

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

View file

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

View file

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

View file

@ -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 },
});
});
});

View file

@ -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<ShellStatusPayload> {
return await readJson<ShellStatusPayload>(shellClient.get('status'));
}
export function shellStatusQueryOptions() {
return queryOptions({
queryKey: ['shell', 'status'] as const,
queryFn: fetchShellStatus,
staleTime: 30_000,
retry: false,
});
}

View file

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

View file

@ -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<AppRouterContext>()({
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: () => (
<>

View file

@ -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> = {}): 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<typeof vi.fn>;
const cancelSimilarArtistsLoad = window.SoulSyncWebShellBridge
?.cancelSimilarArtistsLoad as ReturnType<typeof vi.fn>;
cancelSimilarArtistsLoad.mockClear();
unmount();

View file

@ -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,21 +13,6 @@ function createResponse(body: unknown, ok = true, status = 200) {
});
}
function createShellBridge(overrides: Partial<ShellBridge> = {}): 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(),
...overrides,
};
}
function renderIssuesRoute(initialEntries = ['/issues']) {
const queryClient = createAppQueryClient();
const history = createMemoryHistory({ initialEntries });

View file

@ -0,0 +1,169 @@
import { createMemoryHistory } from '@tanstack/react-router';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createAppQueryClient } from '@/app/query-client';
import { AppRouterProvider, createAppRouter } from '@/app/router';
import { HttpResponse, http, server } from '@/test/msw';
import { createShellBridge } from '@/test/shell-bridge';
function renderStatsRoute(initialEntries = ['/stats']) {
const queryClient = createAppQueryClient();
const history = createMemoryHistory({ initialEntries });
const router = createAppRouter({ history, queryClient });
return {
history,
...render(<AppRouterProvider router={router} queryClient={queryClient} />),
};
}
describe('stats route', () => {
beforeEach(() => {
window.SoulSyncWebShellBridge = createShellBridge();
window.showToast = vi.fn();
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 },
}),
),
);
});
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('still renders when listening stats status prefetch fails', async () => {
server.use(
http.get('/api/listening-stats/status', () =>
HttpResponse.json({ error: 'status unavailable' }, { status: 500 }),
),
);
renderStatsRoute();
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 () => {
const { history } = renderStatsRoute();
fireEvent.click(await screen.findByRole('button', { name: '30 Days' }));
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('falls back to streaming when track resolution fails', async () => {
window.SoulSyncWebShellBridge = createShellBridge({
startStream: vi.fn(),
});
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();
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'),
});
const { history } = renderStatsRoute(['/stats']);
await waitFor(() => expect(history.location.pathname).toBe('/discover'));
});
});

View file

@ -0,0 +1,143 @@
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('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', () =>
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',
});
});
});

View file

@ -0,0 +1,157 @@
import { queryOptions, type QueryClient } from '@tanstack/react-query';
import { HTTPError } from 'ky';
import { apiClient, readJson } from '@/app/api-client';
import type {
ListeningStatsStatus,
StatsCachedPayload,
StatsDbStoragePayload,
StatsLibraryDiskUsagePayload,
StatsRange,
StatsResolveTrackPayload,
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<StatsCachedPayload> {
try {
const payload = await readJson<StatsCachedPayload>(
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;
}
}
export async function fetchListeningStatsStatus(): Promise<ListeningStatsStatus> {
return await readJson<ListeningStatsStatus>(apiClient.get('listening-stats/status'));
}
export async function fetchStatsDbStorage(): Promise<StatsDbStoragePayload> {
const payload = await readJson<StatsDbStoragePayload>(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<StatsLibraryDiskUsagePayload> {
const payload = await readJson<StatsLibraryDiskUsagePayload>(
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<void> {
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<StatsResolveTrackPayload['track'] | null> {
const payload = await readJson<StatsResolveTrackPayload>(
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<Record<string, unknown> | null> {
const payload = await readJson<StatsStreamTrackPayload>(
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 });
}

View file

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

View file

@ -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<StatsOverview> | 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$/, '');
}

View file

@ -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<typeof statsSearchSchema>;
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<string, number>;
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<StatsOverview>;
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<string, number>;
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<string, unknown>;
}

View file

@ -0,0 +1,938 @@
.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;
min-width: 0;
flex-wrap: wrap;
justify-content: flex-end;
}
.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;
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 {
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;
}
.statsStandaloneNotice {
max-width: none;
text-align: left;
}
.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;
}
}

View file

@ -0,0 +1,929 @@
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,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { ShellBridge } from '@/platform/shell/bridge';
import { useReactPageShell, useShellStatus } 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() {
const bridge = useReactPageShell('stats');
const navigate = useNavigate({ from: Route.fullPath });
const queryClient = useQueryClient();
const { range } = Route.useSearch();
const syncTimeoutRef = useRef<number | null>(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 shellStatus = useShellStatus();
const isStandalone = shellStatus?.media_server?.type === 'soulsync';
const onRangeChange = (nextRange: StatsRange) => {
void navigate({
to: Route.fullPath,
search: { range: nextRange },
replace: true,
});
};
const openArtistDetail = (artistId: string | number, artistName: string) => {
bridge.navigateToArtistDetail(artistId, artistName);
};
return (
<div id="stats-container" className={styles.statsContainer} data-testid="stats-page">
<header className={styles.statsHeader}>
<div className={styles.statsHeaderTitle}>
<img src="/static/trans2.png" alt="Stats" className={styles.headerIcon} />
<h1 className={styles.headerTitle}>Listening Stats</h1>
</div>
<div className={styles.statsHeaderControls}>
<div
id="stats-time-range"
className={styles.statsTimeRange}
role="tablist"
aria-label="Listening stats range"
>
{(['7d', '30d', '12m', 'all'] as const).map((option) => (
<button
key={option}
type="button"
className={`${styles.statsRangeButton} ${
range === option ? styles.statsRangeButtonActive : ''
}`}
onClick={() => onRangeChange(option)}
>
{getStatsRangeLabel(option)}
</button>
))}
</div>
<div className={styles.statsSyncControls}>
{isStandalone ? (
<span
className={styles.statsStandaloneNotice}
role="note"
title="SoulSync standalone does not use an external media server, so manual listening stats sync is unavailable."
>
Standalone mode: manual sync unavailable
</span>
) : (
<>
<span className={styles.statsLastSynced}>
{lastSynced ? `Last synced: ${lastSynced}` : 'Not synced yet'}
</span>
<button
id="stats-sync-btn"
type="button"
className={`${styles.statsSyncButton} ${syncing ? styles.statsSyncButtonSyncing : ''}`}
onClick={() => syncMutation.mutate()}
disabled={syncing}
aria-label="Sync listening stats"
title="Sync now"
>
<span aria-hidden="true"></span>
</button>
</>
)}
</div>
</div>
</header>
{cachedStatsQuery.isPending ? (
<SectionLoadingState />
) : cachedStatsQuery.error ? (
<SectionErrorState message={getErrorMessage(cachedStatsQuery.error)} />
) : hasData ? (
<>
<OverviewCards overview={overview} />
<div className={styles.statsMainGrid}>
<div className={styles.statsLeftCol}>
<StatsSectionCard title="Listening Activity">
<div id="stats-timeline-chart" className={styles.chartContainer}>
<StatsActivityChart timeline={cachedStats?.timeline ?? []} />
</div>
</StatsSectionCard>
<StatsSectionCard title="Genre Breakdown">
<div className={styles.statsGenreChartContainer}>
<div id="stats-genre-chart" className={styles.statsGenreChartWrap}>
<StatsGenreChart genres={cachedStats?.genres ?? []} />
</div>
<StatsGenreLegend genres={cachedStats?.genres ?? []} />
</div>
</StatsSectionCard>
<StatsSectionCard title="Recently Played">
<StatsRecentPlays
tracks={cachedStats?.recent ?? []}
onPlay={(track) => playStatsTrack(bridge, track)}
/>
</StatsSectionCard>
</div>
<div className={styles.statsRightCol}>
<StatsSectionCard title="Top Artists">
<TopArtistsVisual
artists={cachedStats?.top_artists ?? []}
onArtistSelect={(artistId, artistName) => openArtistDetail(artistId, artistName)}
/>
<StatsRankedArtists
artists={cachedStats?.top_artists ?? []}
onArtistSelect={(artistId, artistName) => openArtistDetail(artistId, artistName)}
/>
</StatsSectionCard>
<StatsSectionCard title="Top Albums">
<StatsRankedAlbums
albums={cachedStats?.top_albums ?? []}
onArtistSelect={(artistId, artistName) => openArtistDetail(artistId, artistName)}
/>
</StatsSectionCard>
<StatsSectionCard title="Top Tracks">
<StatsRankedTracks
tracks={cachedStats?.top_tracks ?? []}
onArtistSelect={(artistId, artistName) => openArtistDetail(artistId, artistName)}
onPlay={(track) => playStatsTrack(bridge, track)}
/>
</StatsSectionCard>
</div>
</div>
<StatsSectionCard title="Library Health" fullWidth>
<StatsLibraryHealth health={cachedStats?.health ?? {}} />
</StatsSectionCard>
<StatsSectionCard title="Library Disk Usage" fullWidth>
<StatsDiskUsage payload={diskUsageQuery.data} error={diskUsageQuery.error} />
</StatsSectionCard>
<StatsSectionCard title="Database Storage" fullWidth>
<StatsDbStorage payload={dbStorageQuery.data} error={dbStorageQuery.error} />
</StatsSectionCard>
</>
) : (
<StatsEmptyState />
)}
</div>
);
}
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 (
<div id="stats-overview" className={styles.statsOverview}>
{cards.map((card) => (
<div key={card.label} className={styles.statsCard}>
<div className={styles.statsCardValue}>{card.value}</div>
<div className={styles.statsCardLabel}>{card.label}</div>
</div>
))}
</div>
);
}
function StatsSectionCard({
children,
fullWidth = false,
title,
}: {
children: ReactNode;
fullWidth?: boolean;
title: string;
}) {
return (
<section className={`${styles.statsSectionCard} ${fullWidth ? styles.statsFullWidth : ''}`}>
<div className={styles.statsSectionTitle}>{title}</div>
{children}
</section>
);
}
function StatsActivityChart({ timeline }: { timeline: Array<{ date: string; plays: number }> }) {
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={timeline} margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
<CartesianGrid stroke="rgba(255,255,255,0.04)" vertical={false} />
<XAxis
dataKey="date"
tick={{ fill: 'rgba(255,255,255,0.3)', fontSize: 10 }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fill: 'rgba(255,255,255,0.3)', fontSize: 10 }}
axisLine={false}
tickLine={false}
allowDecimals={false}
width={28}
/>
<Tooltip
contentStyle={STATS_TOOLTIP_STYLE}
wrapperStyle={STATS_TOOLTIP_WRAPPER_STYLE}
cursor={STATS_CHART_CURSOR}
/>
<Bar
dataKey="plays"
radius={[4, 4, 0, 0]}
fill="rgba(var(--accent-rgb), 0.55)"
stroke="rgba(var(--accent-rgb), 0.8)"
/>
</BarChart>
</ResponsiveContainer>
);
}
function StatsGenreChart({
genres,
}: {
genres: Array<{ genre: string; play_count: number; percentage: number }>;
}) {
const topGenres = genres.slice(0, 10).map((genre, index) => ({
...genre,
fill: STATS_GENRE_COLORS[index % STATS_GENRE_COLORS.length],
}));
return (
<ResponsiveContainer width={180} height={180}>
<PieChart>
<Pie
data={topGenres}
dataKey="play_count"
nameKey="genre"
innerRadius={52}
outerRadius={84}
paddingAngle={2}
stroke="transparent"
/>
<Tooltip contentStyle={STATS_TOOLTIP_STYLE} wrapperStyle={STATS_TOOLTIP_WRAPPER_STYLE} />
</PieChart>
</ResponsiveContainer>
);
}
function StatsGenreLegend({
genres,
}: {
genres: Array<{ genre: string; play_count: number; percentage: number }>;
}) {
const topGenres = genres.slice(0, 10);
return (
<div className={styles.statsGenreLegend}>
{topGenres.map((genre, index) => (
<div key={genre.genre} className={styles.statsGenreLegendItem}>
<span
className={styles.statsGenreDot}
style={{ background: STATS_GENRE_COLORS[index % STATS_GENRE_COLORS.length] }}
/>
<span>{genre.genre}</span>
<span className={styles.statsGenrePct}>{genre.percentage}%</span>
</div>
))}
</div>
);
}
function TopArtistsVisual({
artists,
onArtistSelect,
}: {
artists: StatsArtistRow[];
onArtistSelect: (artistId: string | number, artistName: string) => void;
}) {
const topArtists = getTopArtistBubbles(artists);
if (topArtists.length === 0) return null;
return (
<div className={styles.statsTopArtistsVisual}>
<div className={styles.statsArtistBubbles}>
{topArtists.map(({ artist, percent, size }) => {
const isClickable = artist.id !== null && artist.id !== undefined;
return (
<button
key={`${artist.name}-${artist.id ?? 'unknown'}`}
type="button"
className={styles.statsArtistBubble}
onClick={() => {
if (isClickable) {
onArtistSelect(artist.id as string | number, artist.name);
}
}}
disabled={!isClickable}
>
<div
className={styles.statsBubbleImage}
style={{
width: size,
height: size,
backgroundImage: artist.image_url ? `url(${artist.image_url})` : undefined,
}}
>
{!artist.image_url ? (
<span className={styles.statsBubbleInitial}>{artist.name[0] ?? '?'}</span>
) : null}
</div>
<div className={styles.statsBubbleBarContainer}>
<div className={styles.statsBubbleBar} style={{ width: `${percent}%` }} />
</div>
<div className={styles.statsBubbleName}>{artist.name}</div>
<div className={styles.statsBubbleCount}>
{formatCompactNumber(artist.play_count)}
</div>
</button>
);
})}
</div>
</div>
);
}
function StatsRankedArtists({
artists,
onArtistSelect,
}: {
artists: StatsArtistRow[];
onArtistSelect: (artistId: string | number, artistName: string) => void;
}) {
return (
<div id="stats-top-artists" className={styles.statsRankedList}>
{artists.length === 0 ? <EmptyListState message="No data yet" /> : null}
{artists.map((artist, index) => (
<div key={`${artist.name}-${artist.id ?? index}`} className={styles.statsRankedItem}>
<span className={styles.statsRankedNum}>{index + 1}</span>
{artist.image_url ? (
<img className={styles.statsRankedImage} src={artist.image_url} alt="" />
) : (
<div className={styles.statsRankedImageFallback} />
)}
<div className={styles.statsRankedInfo}>
<div className={styles.statsRankedName}>
{artist.id ? (
<button
type="button"
className={styles.statsArtistLink}
onClick={() => onArtistSelect(artist.id as string | number, artist.name)}
>
{artist.name}
</button>
) : (
artist.name
)}
{artist.soul_id && !String(artist.soul_id).startsWith('soul_unnamed_') ? (
<img src="/static/trans2.png" className={styles.statsSoulIdBadge} alt="SoulID" />
) : null}
</div>
<div className={styles.statsRankedMeta}>
{artist.global_listeners
? `${formatCompactNumber(artist.global_listeners)} global listeners`
: ''}
</div>
</div>
<span className={styles.statsRankedCount}>
{formatCompactNumber(artist.play_count)} plays
</span>
</div>
))}
</div>
);
}
function StatsRankedAlbums({
albums,
onArtistSelect,
}: {
albums: StatsAlbumRow[];
onArtistSelect: (artistId: string | number, artistName: string) => void;
}) {
return (
<div id="stats-top-albums" className={styles.statsRankedList}>
{albums.length === 0 ? <EmptyListState message="No data yet" /> : null}
{albums.map((album, index) => (
<div key={`${album.name}-${index}`} className={styles.statsRankedItem}>
<span className={styles.statsRankedNum}>{index + 1}</span>
{album.image_url ? (
<img className={styles.statsRankedImage} src={album.image_url} alt="" />
) : (
<div className={styles.statsRankedImageFallback} />
)}
<div className={styles.statsRankedInfo}>
<div className={styles.statsRankedName}>{album.name}</div>
<div className={styles.statsRankedMeta}>
{album.artist_id ? (
<button
type="button"
className={styles.statsArtistLink}
onClick={() =>
onArtistSelect(album.artist_id as string | number, album.artist || '')
}
>
{album.artist || ''}
</button>
) : (
album.artist || ''
)}
</div>
</div>
<span className={styles.statsRankedCount}>
{formatCompactNumber(album.play_count)} plays
</span>
</div>
))}
</div>
);
}
function StatsRankedTracks({
tracks,
onArtistSelect,
onPlay,
}: {
tracks: StatsTrackRow[];
onArtistSelect: (artistId: string | number, artistName: string) => void;
onPlay: (track: { title: string; artist: string; album: string }) => Promise<void>;
}) {
return (
<div id="stats-top-tracks" className={styles.statsRankedList}>
{tracks.length === 0 ? <EmptyListState message="No data yet" /> : null}
{tracks.map((track, index) => (
<div key={`${track.name}-${index}`} className={styles.statsRankedItem}>
<span className={styles.statsRankedNum}>{index + 1}</span>
{track.image_url ? (
<img className={styles.statsRankedImage} src={track.image_url} alt="" />
) : (
<div className={styles.statsRankedImageFallback} />
)}
<div className={styles.statsRankedInfo}>
<div className={styles.statsRankedName}>{track.name}</div>
<div className={styles.statsRankedMeta}>
{track.artist_id ? (
<button
type="button"
className={styles.statsArtistLink}
onClick={() =>
onArtistSelect(track.artist_id as string | number, track.artist || '')
}
>
{track.artist || ''}
</button>
) : (
track.artist || ''
)}
{track.album ? ` · ${track.album}` : ''}
</div>
</div>
<button
type="button"
className={`${styles.statsPlayButton} ${styles.statsPlayButtonSmall}`}
onClick={() =>
void onPlay({
title: track.name,
artist: track.artist || '',
album: track.album || '',
})
}
title="Play"
>
</button>
<span className={styles.statsRankedCount}>
{formatCompactNumber(track.play_count)} plays
</span>
</div>
))}
</div>
);
}
function StatsRecentPlays({
tracks,
onPlay,
}: {
tracks: StatsRecentTrack[];
onPlay: (track: { title: string; artist: string; album: string }) => Promise<void>;
}) {
return (
<div id="stats-recent-plays" className={styles.statsRecentList}>
{tracks.length === 0 ? <EmptyListState message="No recent plays" /> : null}
{tracks.map((track, index) => (
<div
key={`${track.title}-${track.artist ?? ''}-${track.album ?? ''}-${track.played_at ?? ''}-${index}`}
className={styles.statsRecentItem}
>
<button
type="button"
className={`${styles.statsPlayButton} ${styles.statsPlayButtonSmall}`}
onClick={() =>
void onPlay({
title: track.title,
artist: track.artist || '',
album: track.album || '',
})
}
title="Play"
>
</button>
<span className={styles.statsRecentTitle}>{track.title}</span>
<span className={styles.statsRecentArtist}>{track.artist || ''}</span>
<span className={styles.statsRecentTime}>{formatRelativePlayedAt(track.played_at)}</span>
</div>
))}
</div>
);
}
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<string, string> = {
FLAC: '#3b82f6',
MP3: '#f97316',
Opus: '#a855f7',
AAC: '#14b8a6',
OGG: '#eab308',
WAV: '#ec4899',
Other: '#555555',
};
return (
<div id="stats-library-health">
<div className={styles.statsHealthGrid}>
<div className={`${styles.statsHealthItem} ${styles.statsHealthItemWide}`}>
<div className={styles.statsHealthLabel}>Format Breakdown</div>
<div className={styles.statsFormatBar}>
{formatEntries.map(([format, count]) => {
const percentage = ((count / formatTotal) * 100).toFixed(1);
return (
<div
key={format}
className={styles.statsFormatSegment}
style={{
flex: count,
background: formatColors[format] || formatColors.Other,
}}
title={`${format}: ${count} tracks (${percentage}%)`}
>
{Number(percentage) > 8 ? format : ''}
</div>
);
})}
</div>
</div>
<div className={styles.statsHealthItem}>
<div className={styles.statsHealthValue}>
{formatCompactNumber(health.unplayed_count)} ({health.unplayed_percentage || 0}%)
</div>
<div className={styles.statsHealthLabel}>Unplayed Tracks</div>
</div>
<div className={styles.statsHealthItem}>
<div className={styles.statsHealthValue}>
{formatTotalDuration(health.total_duration_ms)}
</div>
<div className={styles.statsHealthLabel}>Total Duration</div>
</div>
<div className={styles.statsHealthItem}>
<div className={styles.statsHealthValue}>{formatCompactNumber(totalTracks)}</div>
<div className={styles.statsHealthLabel}>Total Tracks</div>
</div>
</div>
<div id="stats-enrichment-coverage" className={styles.statsEnrichment}>
{STATS_ENRICHMENT_SERVICES.map((service) => {
const percent = health.enrichment_coverage?.[service.key] || 0;
return (
<div key={service.key} className={styles.statsEnrichItem}>
<span className={styles.statsEnrichName}>{service.label}</span>
<div className={styles.statsEnrichBar}>
<div
className={styles.statsEnrichFill}
style={{ width: `${percent}%`, background: service.color }}
/>
</div>
<span className={styles.statsEnrichPct}>{percent}%</span>
</div>
);
})}
</div>
</div>
);
}
function StatsDiskUsage({
error,
payload,
}: {
error: unknown;
payload: StatsLibraryDiskUsagePayload | undefined;
}) {
if (error) {
return <SectionSubtleError message={getErrorMessage(error)} />;
}
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 (
<div className={styles.statsDiskUsageWrap}>
<div className={styles.statsDiskTotalRow}>
<div className={styles.statsDiskTotalValue}>
{hasData ? formatBytes(payload?.total_bytes) : '—'}
</div>
<div className={styles.statsDiskTotalMeta}>
{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'}
</div>
</div>
<div className={styles.statsDiskFormats}>
{formats.map(([format, bytes]) => {
const width = Math.max(2, Math.round((bytes / max) * 100));
return (
<div key={format} className={styles.statsDiskFormatRow}>
<span className={styles.statsDiskFormatName}>{format.toUpperCase()}</span>
<div className={styles.statsDiskFormatBar}>
<div className={styles.statsDiskFormatFill} style={{ width: `${width}%` }} />
</div>
<span className={styles.statsDiskFormatSize}>{formatBytes(bytes)}</span>
</div>
);
})}
</div>
</div>
);
}
function StatsDbStorage({
error,
payload,
}: {
error: unknown;
payload: StatsDbStoragePayload | undefined;
}) {
if (error) {
return <SectionSubtleError message={getErrorMessage(error)} />;
}
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 (
<div className={styles.statsDbStorageWrap}>
<div id="stats-db-storage-chart" className={styles.statsDbChartContainer}>
<ResponsiveContainer width={180} height={180}>
<PieChart>
<Pie
data={tables}
dataKey="size"
nameKey="name"
innerRadius={52}
outerRadius={84}
paddingAngle={2}
stroke="transparent"
/>
<Tooltip
contentStyle={STATS_TOOLTIP_STYLE}
wrapperStyle={STATS_TOOLTIP_WRAPPER_STYLE}
/>
</PieChart>
</ResponsiveContainer>
<div className={styles.statsDbTotal}>
<div className={styles.statsDbTotalValue}>
{formatDbStorageValue(payload?.total_file_size || 0, method)}
</div>
<div className={styles.statsDbTotalLabel}>Total Size</div>
</div>
</div>
<div className={styles.statsDbLegend}>
{tables.map((table, index) => (
<div key={table.name} className={styles.statsDbLegendItem}>
<span
className={styles.statsDbLegendDot}
style={{
background: STATS_DB_STORAGE_COLORS[index % STATS_DB_STORAGE_COLORS.length],
}}
/>
<span className={styles.statsDbLegendName}>{table.name}</span>
<span className={styles.statsDbLegendSize}>
{formatDbStorageValue(table.size, method)}
</span>
</div>
))}
</div>
</div>
);
}
function StatsEmptyState() {
return (
<div className={styles.statsEmpty}>
<div className={styles.statsEmptyIcon}>📊</div>
<h3>No Listening Data Yet</h3>
<p>
Enable &quot;Listening Stats&quot; in Settings to start tracking your listening activity
from your media server.
</p>
</div>
);
}
function SectionLoadingState() {
return <div className={styles.statsLoading}>Loading listening stats...</div>;
}
function SectionErrorState({ message }: { message: string }) {
return (
<div className={styles.statsEmpty}>
<h3>Failed to load listening stats</h3>
<p>{message}</p>
</div>
);
}
function SectionSubtleError({ message }: { message: string }) {
return <div className={styles.statsSubtleError}>{message}</div>;
}
function EmptyListState({ message }: { message: string }) {
return <div className={styles.emptyListState}>{message}</div>;
}
async function playStatsTrack(
bridge: ShellBridge,
track: { title: string; artist: string; album: string },
) {
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}...`);
try {
const streamResult = await streamStatsTrack(track.title, track.artist, track.album);
bridge.hideLoadingOverlay();
if (streamResult) {
await bridge.startStream(streamResult);
return;
}
window.showToast?.('Track not found in library or any source', 'error');
} catch (error) {
bridge.hideLoadingOverlay();
window.showToast?.(getErrorMessage(error), 'error');
}
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Unknown error';
}

View file

@ -0,0 +1,33 @@
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
.fetchQuery({
...listeningStatsStatusQueryOptions(),
retry: false,
})
.catch(() => undefined),
]);
},
component: StatsPage,
});

View file

@ -0,0 +1,24 @@
import { vi } from 'vitest';
import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge';
export function createShellBridge(overrides: Partial<ShellBridge> = {}): 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;
}

View file

@ -67,6 +67,11 @@ let deezerPlaylistStates = {};
let deezerArlPlaylists = [];
let deezerArlPlaylistsLoaded = false;
// --- Qobuz Playlist State Management (mirrors Tidal — github issue #677) ---
let qobuzPlaylists = [];
let qobuzPlaylistStates = {}; // Key: playlist_id, Value: playlist state with phases
let qobuzPlaylistsLoaded = false;
// --- Beatport Chart State Management (Similar to YouTube/Tidal) ---
let beatportChartStates = {}; // Key: chart_hash, Value: chart state with phases
let beatportContentState = {
@ -515,6 +520,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';

View file

@ -6841,7 +6841,7 @@ function _renderByltTrackCard(t) {
return `
<div class="discover-card">
<div class="discover-card-image">
${t.image_url ? `<img src="${t.image_url}" alt="" loading="lazy" onerror="this.src='/static/placeholder.png'">` : '<div class="discover-card-placeholder">🎵</div>'}
${t.image_url ? `<img src="${t.image_url}" alt="" loading="lazy" onerror="this.src='/static/placeholder-album.png'">` : '<div class="discover-card-placeholder">🎵</div>'}
</div>
<div class="discover-card-title">${_esc(t.name)}</div>
<div class="discover-card-artist">${_esc(t.artist)}</div>

View file

@ -64,10 +64,11 @@ function _wingItAction(urlHash, action) {
const tracks = state.tracks || state.rawTracks || state.playlist?.tracks || [];
const name = state.playlistName || state.name || state.playlist?.name || 'Playlist';
const isTidal = state.is_tidal_playlist;
const isQobuz = state.is_qobuz_playlist;
const isLB = state.is_listenbrainz_playlist;
const isBeatport = state.is_beatport_playlist;
const isDeezer = state.is_deezer_playlist;
const source = isLB ? 'ListenBrainz' : isTidal ? 'Tidal' : isDeezer ? 'Deezer' : isBeatport ? 'Beatport' : 'YouTube';
const source = isLB ? 'ListenBrainz' : isTidal ? 'Tidal' : isQobuz ? 'Qobuz' : isDeezer ? 'Deezer' : isBeatport ? 'Beatport' : 'YouTube';
if (!tracks.length) {
showToast('No tracks available for Wing It', 'error');
@ -347,10 +348,11 @@ async function _wingItFromModal(urlHash) {
const tracks = state.tracks || state.rawTracks || state.playlist?.tracks || [];
const name = state.playlistName || state.name || state.playlist?.name || 'Playlist';
const isTidal = state.is_tidal_playlist;
const isQobuz = state.is_qobuz_playlist;
const isLB = state.is_listenbrainz_playlist;
const isBeatport = state.is_beatport_playlist;
const isDeezer = state.is_deezer_playlist;
const source = isLB ? 'ListenBrainz' : isTidal ? 'Tidal' : isDeezer ? 'Deezer' : isBeatport ? 'Beatport' : 'YouTube';
const source = isLB ? 'ListenBrainz' : isTidal ? 'Tidal' : isQobuz ? 'Qobuz' : isDeezer ? 'Deezer' : isBeatport ? 'Beatport' : 'YouTube';
if (!tracks.length) {
showToast('No tracks available for Wing It', 'error');
@ -582,7 +584,7 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam
onchange="updateTrackSelectionCount('${virtualPlaylistId}')">
</td>
<td class="track-number">${index + 1}</td>
<td class="track-name" title="${escapeHtml(track.name)}">${escapeHtml(track.name)}</td>
<td class="track-name" title="${escapeHtml(track.name)}">${renderModalTrackPlayButton(virtualPlaylistId, index)}${escapeHtml(track.name)}</td>
<td class="track-artist" title="${escapeHtml(formatArtists(track.artists))}">${escapeHtml(formatArtists(track.artists))}</td>
<td class="track-duration">${formatDuration(track.duration_ms)}</td>
<td class="track-match-status match-checking" id="match-${virtualPlaylistId}-${index}">🔍 Pending</td>
@ -2143,7 +2145,7 @@ async function openDownloadMissingWishlistModal(category = null, selectedTrackId
${tracks.map((track, index) => `
<tr data-track-index="${index}">
<td class="track-number">${index + 1}</td>
<td class="track-name" title="${escapeHtml(track.name)}">${escapeHtml(track.name)}</td>
<td class="track-name" title="${escapeHtml(track.name)}">${renderModalTrackPlayButton(playlistId, index)}${escapeHtml(track.name)}</td>
<td class="track-artist" title="${escapeHtml(formatArtists(track.artists))}">${escapeHtml(formatArtists(track.artists))}</td>
<td class="track-match-status match-checking" id="match-${playlistId}-${index}">🔍 Pending</td>
<td class="track-download-status" id="download-${playlistId}-${index}">-</td>
@ -2613,6 +2615,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 `<button class="modal-track-play-btn" onclick="event.stopPropagation(); playDownloadModalTrack('${escapeForInlineJs(playlistId)}', ${trackIndex})" title="Play track">&#9654;</button>`;
}
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 || '')
);
}
// ============================================================================

View file

@ -1632,7 +1632,7 @@ const HELPER_CONTENT = {
// ─── STATS PAGE ──────────────────────────────────────────────────
'.stats-container': {
'#stats-container': {
title: 'Listening Stats',
description: 'Analytics dashboard showing your listening activity, top artists/albums/tracks, genre breakdown, library health, and storage usage. Data syncs from your media server.',
},
@ -3413,8 +3413,15 @@ function closeHelperSearch() {
// projects that span multiple commits before shipping. Strip the flag at
// release time and add a real `date:` line at the top of the version block.
const WHATS_NEW = {
'2.6.0': [
{ date: 'May 24, 2026 — 2.6.0 release' },
{ title: 'Qobuz playlist sync', desc: 'new Qobuz tab on the Sync page. Connect Qobuz in Settings → Connections, hit Refresh on the tab, and your Qobuz playlists + Favorite Tracks show up alongside Tidal and Deezer. clicks run the same discovery → sync → download flow as the other sources.', page: 'sync' },
{ title: 'Import search: show when results came from the fallback source', desc: 'if you picked MusicBrainz (or Discogs / iTunes / etc.) as your primary metadata source but the Import album search ended up serving Deezer cards, you had no idea — the chain silently fell through when the primary returned nothing. now each card shows a small "via Deezer" label when the source differs from your primary, and a banner above the grid spells it out when all results came from the fallback. backend behavior unchanged.' },
],
'2.5.9': [
{ date: 'May 21, 2026 — 2.5.9 release' },
{ title: 'Now-playing modal: lyrics panel', desc: 'new lyrics panel below the player controls in the expanded now-playing modal. fetches from LRClib via /api/lyrics/fetch, but prefers the local .lrc / .txt sidecar files SoulSync drops next to your audio during post-processing so downloaded tracks show lyrics instantly with zero network. synced LRC (timestamped) highlights the active line and auto-scrolls it into the middle of the viewport on every audio timeupdate; plain text renders without highlighting. status chip shows whether the result came back Synced or Plain. panel is collapsed by default — click the Lyrics header to expand. cached per track so revisiting a track doesn\'t refetch.' },
{ title: 'Now-playing modal: View Artist closes the modal first', desc: 'tapping View Artist on the expanded media player now closes the now-playing modal before navigating, so the artist page is actually visible instead of sitting under a modal you\'d have to manually dismiss. click is a no-op when no artist_id is attached to the current track.' },
{ title: 'Torrent and Usenet release downloads', desc: 'torrent and usenet are now real download sources backed by Prowlarr plus your configured downloader client. album downloads use a release-first staging flow so SoulSync downloads one release, watches live progress, then imports the matching tracks from the staged files. hybrid mode keeps torrent / usenet out of album batches, but still lets them participate for playlist singles and wishlist tracks.' },
{ title: 'Fix: HiFi public instance compatibility', desc: 'HiFi instance probing now understands both supported manifest shapes: the newer /trackManifests-style flow and the public hifi-api /track/ legacy flow. instances that can search and return a playable HLS manifest are no longer mislabeled as Search only. browser-openable pages can still be offline from the API side, so HTTP 502 / Offline labels are still shown honestly.' },
{ title: 'Fix: Jellyfin full refresh imports tracks on older databases', desc: 'full refresh could import artists and albums but fail every Jellyfin track on upgraded databases that were missing newer media columns. startup repair now adds the missing tracks.file_size and albums.api_track_count columns before refresh work runs, so old databases can accept new Jellyfin track rows again.' },
@ -3492,16 +3499,28 @@ const WHATS_NEW = {
// usage_note?: 'optional hint shown at the bottom' }
const VERSION_MODAL_SECTIONS = [
{
title: "2.5.9 Release Stability Pass",
description: "this release ties together the new release-based download sources and a set of fixes from real user reports: HiFi instance detection, Jellyfin full refreshes, transient SQLite disk I/O failures, and wrong-artist Album Completeness fills.",
title: "Qobuz Playlist Sync",
description: "Qobuz joins Tidal and Deezer as a first-class playlist sync source on the Sync page. Browse your Qobuz playlists and Favorite Tracks, run them through the same discovery flow as Tidal, sync the resulting Spotify-matched tracks, and queue downloads — same multi-step pipeline you already know.",
features: [
"new Qobuz tab on the Sync page, listed between Deezer and Deezer Link",
"lists your Qobuz user playlists plus a Favorite Tracks entry (same virtual-playlist treatment Tidal gets)",
"click any card to fire discovery (Spotify-preferred, your primary metadata fallback otherwise), then sync or download just like Tidal / Deezer playlists",
"uses the Qobuz auth token you already configured for downloads — no extra connection step",
],
usage_note: "Sync → Qobuz → 🔄 Refresh",
},
{
title: "Earlier in v2.5",
description: "highlights from the 2.5.x cycle that landed just before 2.6.0: new release-based download sources, HiFi instance probing, Jellyfin full-refresh repairs, transient SQLite retries, and tighter Album Completeness artist matching.",
features: [
"Torrent and Usenet are now available as Prowlarr-backed download sources with release staging, live progress, and source-aware history labels",
"HiFi instances are probed by actually checking whether they can return a playable manifest, including legacy public hifi-api instances",
"Jellyfin full refresh repairs older media databases before importing tracks, so stale schemas no longer drop every track row",
"Cache maintenance and full refresh retry transient SQLite disk I/O errors instead of failing the whole job on the first attempt",
"Album Completeness rejects same-title releases from the wrong artist before importing anything",
"Import album search now labels each card with the source that served it and warns when results came from a fallback instead of your primary",
],
usage_note: "version 2.5.9 focuses on safer matching and making the new release-based sources usable without disturbing existing source flows",
usage_note: "browse the What's New panel for the full 2.5.x changelog",
},
{
title: "Torrent and Usenet Are Now Live Download Sources",
@ -3887,7 +3906,7 @@ function _getLatestWhatsNewVersion() {
const versions = Object.keys(WHATS_NEW)
.filter(v => _compareVersions(v, buildVer) <= 0)
.sort((a, b) => _compareVersions(b, a));
return versions[0] || '2.5.9';
return versions[0] || '2.6.0';
}
function openWhatsNew() {

View file

@ -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 = () => {
@ -2125,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([
@ -2415,9 +2420,6 @@ async function loadPageData(pageId) {
loadApiKeys();
loadBlacklistCount();
break;
case 'stats':
initializeStatsPage();
break;
case 'import':
initializeImportPage();
break;

View file

@ -1512,6 +1512,70 @@ const _TOP_TRACKS_SOURCE_LABELS = {
lastfm: 'Popular on Last.fm',
};
async function playTrackByMetadata(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 track = data.track;
playLibraryTrack(
{
id: track.id,
title: track.title,
file_path: track.file_path,
bitrate: track.bitrate,
artist_id: track.artist_id,
album_id: track.album_id,
_stats_image: track.image_url || null,
},
track.album_title || album || '',
track.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.
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);
}
}
async function _loadArtistTopTracks(artistName) {
const sidebar = document.getElementById('artist-hero-sidebar');
const container = document.getElementById('hero-top-tracks');
@ -1576,7 +1640,7 @@ async function _loadArtistTopTracks(artistName) {
const playBtn = e.target.closest('.hero-top-track-play');
if (playBtn) {
e.stopPropagation();
playStatsTrack(playBtn.dataset.track, playBtn.dataset.artist, '');
playTrackByMetadata(playBtn.dataset.track, playBtn.dataset.artist, '');
return;
}
const dlBtn = e.target.closest('.hero-top-track-download');
@ -1632,7 +1696,7 @@ async function _loadArtistTopTracks(artistName) {
const btn = e.target.closest('.hero-top-track-play');
if (btn) {
e.stopPropagation();
playStatsTrack(btn.dataset.track, btn.dataset.artist, '');
playTrackByMetadata(btn.dataset.track, btn.dataset.artist, '');
}
};
sidebar.style.display = '';
@ -7974,6 +8038,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
// ``<source_id>||<display>`` 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) {

View file

@ -64,8 +64,20 @@ function toggleMediaPlayerExpansion() {
function extractTrackTitle(filename) {
if (!filename) return null;
// Strip the ``<source_id>||<display>`` 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 ``<source_id>||<display>``
// 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 = `<span class="title-text">${escapeHtml(trackTitle)}</span>`;
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 = '<div class="np-lyrics-empty">No lyrics loaded</div>';
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 = '<div class="np-lyrics-empty">No timestamped lyrics for this track</div>';
return;
}
content.innerHTML = lines.map((line, idx) => {
const safe = (line.text || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])) || '&nbsp;';
return `<div class="np-lyrics-line" data-idx="${idx}">${safe}</div>`;
}).join('');
}
function _npLyricsRenderPlain(text) {
const content = document.getElementById('np-lyrics-content');
if (!content) return;
const safe = (text || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
content.innerHTML = `<div class="np-lyrics-plain">${safe.replace(/\n/g, '<br>')}</div>`;
}
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 = '<div class="np-lyrics-empty">No lyrics found</div>';
if (status) status.textContent = '';
} catch (e) {
console.debug('lyrics fetch failed:', e);
const content = document.getElementById('np-lyrics-content');
if (content) content.innerHTML = '<div class="np-lyrics-empty">Lyrics unavailable</div>';
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(); }
}

View file

@ -2750,323 +2750,6 @@
}
}
/* ======================================
STATS PAGE Mobile
====================================== */
@media (max-width: 768px) {
.stats-container {
padding: 12px !important;
margin: 8px !important;
gap: 14px;
border-radius: 16px;
}
.stats-header {
flex-direction: column;
align-items: flex-start;
padding: 12px !important;
margin: -12px -12px 0 -12px !important;
gap: 10px;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
}
.stats-header-title {
gap: 8px;
}
.stats-header-title .page-header-icon {
width: 24px;
height: 24px;
}
.stats-header-title h1 {
font-size: 18px;
}
.stats-header-controls {
width: 100%;
flex-direction: column;
align-items: flex-start !important;
gap: 8px;
}
.stats-time-range {
display: flex;
flex-wrap: wrap;
gap: 6px;
width: 100%;
}
.stats-range-btn {
padding: 6px 12px;
font-size: 11px;
flex: 1;
text-align: center;
}
.stats-sync-controls {
width: 100%;
justify-content: space-between;
}
.stats-last-synced {
font-size: 11px;
}
.stats-sync-btn {
padding: 6px 12px;
font-size: 12px;
}
/* Overview cards — 2 columns */
.stats-overview {
grid-template-columns: repeat(2, 1fr) !important;
gap: 8px;
}
.stats-card {
padding: 12px 10px;
border-radius: 10px;
}
.stats-card-value {
font-size: 18px !important;
}
.stats-card-label {
font-size: 9px;
letter-spacing: 0.04em;
}
/* Main grid — single column */
.stats-main-grid {
grid-template-columns: 1fr !important;
gap: 14px;
}
.stats-left-col,
.stats-right-col {
width: 100% !important;
gap: 14px;
}
.stats-section-card {
padding: 12px;
border-radius: 12px;
}
.stats-section-title {
font-size: 13px;
margin-bottom: 10px;
}
/* Timeline chart — shrink height */
.stats-section-card > div[style*="height:220px"],
.stats-section-card > div[style*="height: 220px"] {
height: 160px !important;
}
/* Genre chart — stack vertically, shrink canvas */
.stats-genre-chart-container {
flex-direction: column !important;
align-items: center;
gap: 12px;
}
.stats-genre-chart-container canvas {
width: 130px !important;
height: 130px !important;
}
.stats-genre-legend {
width: 100%;
}
.stats-genre-legend-item {
font-size: 11px;
}
/* Top artists bubbles */
.stats-artist-bubbles {
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.stats-artist-bubble {
max-width: 60px;
}
.stats-bubble-img {
width: 44px !important;
height: 44px !important;
}
.stats-bubble-name {
font-size: 9px;
}
.stats-bubble-count {
font-size: 9px;
}
.stats-bubble-bar-container {
height: 3px;
}
/* Ranked lists */
.stats-ranked-item {
padding: 8px 8px;
gap: 6px;
}
.stats-ranked-num {
font-size: 11px;
min-width: 18px;
}
.stats-ranked-img {
width: 32px;
height: 32px;
border-radius: 6px;
}
.stats-ranked-info {
min-width: 0;
}
.stats-ranked-name {
font-size: 12px;
}
.stats-ranked-meta {
font-size: 10px;
}
.stats-ranked-count {
font-size: 10px;
white-space: nowrap;
}
.stats-play-btn {
width: 24px;
height: 24px;
font-size: 8px;
opacity: 1;
}
.stats-play-btn-sm {
width: 20px;
height: 20px;
font-size: 7px;
opacity: 1;
}
/* Recent plays */
.stats-recent-item {
padding: 6px 8px;
gap: 6px;
}
.stats-recent-title {
font-size: 12px;
}
.stats-recent-artist {
font-size: 11px;
}
.stats-recent-time {
font-size: 10px;
}
/* Library health */
.stats-full-width {
padding: 12px;
}
.stats-health-grid {
grid-template-columns: 1fr !important;
gap: 10px;
}
.stats-health-label {
font-size: 11px;
}
.stats-health-value {
font-size: 16px;
}
.stats-format-bar {
min-height: 20px;
border-radius: 6px;
}
.stats-format-segment {
font-size: 9px;
}
/* Enrichment coverage bars */
.stats-enrichment {
gap: 6px;
}
.stats-enrich-item {
gap: 6px;
}
.stats-enrich-name {
font-size: 10px;
min-width: 55px;
}
.stats-enrich-pct {
font-size: 10px;
min-width: 28px;
}
/* DB storage chart */
.stats-db-storage-wrap {
flex-direction: column;
align-items: center;
gap: 12px;
}
.stats-db-chart-container {
width: 140px;
height: 140px;
}
.stats-db-chart-container canvas {
width: 140px !important;
height: 140px !important;
}
.stats-db-total-value {
font-size: 16px;
}
.stats-db-legend {
width: 100%;
}
/* Empty state */
.stats-empty-icon {
font-size: 40px;
}
.stats-empty h3 {
font-size: 16px;
}
.stats-empty p {
font-size: 13px;
}
}
/* ======================================
ARTIST ENRICHMENT RINGS Mobile
====================================== */

View file

@ -600,6 +600,7 @@ const HYBRID_SOURCES = [
{ id: 'torrent', name: 'Torrent', icon: null, emoji: '🧲' },
{ id: 'usenet', name: 'Usenet', icon: null, emoji: '📰' },
];
const ALBUM_LEVEL_HYBRID_SOURCES = new Set(['soulseek', 'torrent', 'usenet']);
let _hybridSourceOrder = ['soulseek', 'youtube'];
let _hybridSourceEnabled = { soulseek: true, youtube: true, tidal: false, qobuz: false, hifi: false, deezer_dl: false, amazon: false, lidarr: false, soundcloud: false, torrent: false, usenet: false };
@ -625,6 +626,13 @@ function buildHybridSourceList() {
const enabled = _hybridSourceEnabled[srcId] !== false;
const isInOrder = _hybridSourceOrder.includes(srcId);
const priorityNum = isInOrder && enabled ? _hybridSourceOrder.indexOf(srcId) + 1 : '';
const canOwnAlbum = enabled && priorityNum === 1 && ALBUM_LEVEL_HYBRID_SOURCES.has(srcId);
const sourceLevel = canOwnAlbum ? 'Album-level' : 'Track-level';
const sourceLevelClass = canOwnAlbum ? 'album' : 'track';
const sourceLevelTitle = canOwnAlbum
? 'This first source can download a whole album release before per-track fallback.'
: 'This source runs as per-track fallback in the current hybrid order.';
const sourceLevelBadge = `<span class="hybrid-source-badge hybrid-source-badge-${sourceLevelClass}" title="${sourceLevelTitle}">${sourceLevel}</span>`;
const item = document.createElement('div');
item.className = `hybrid-source-item${enabled ? '' : ' disabled'}`;
@ -641,6 +649,7 @@ function buildHybridSourceList() {
: `<span class="hybrid-source-icon emoji-icon">${src.emoji}</span>`
}
<span class="hybrid-source-name">${src.name}</span>
${sourceLevelBadge}
<span class="hybrid-source-priority">${priorityNum}</span>
<label class="hybrid-source-toggle">
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="toggleHybridSource('${srcId}', this.checked)">

View file

@ -1092,7 +1092,7 @@ async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlis
onchange="updateTrackSelectionCount('${virtualPlaylistId}')">
</td>
<td class="track-number">${index + 1}</td>
<td class="track-name" title="${escapeHtml(track.name)}">${escapeHtml(track.name)}</td>
<td class="track-name" title="${escapeHtml(track.name)}">${renderModalTrackPlayButton(virtualPlaylistId, index)}${escapeHtml(track.name)}</td>
<td class="track-artist" title="${escapeHtml(formatArtists(track.artists))}">${escapeHtml(formatArtists(track.artists))}</td>
<td class="track-duration">${formatDuration(track.duration_ms)}</td>
<td class="track-match-status match-checking" id="match-${virtualPlaylistId}-${index}">🔍 Pending</td>
@ -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';

View file

@ -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;
@ -165,7 +168,6 @@ window.SoulSyncWebShellBridge = {
activateLegacyPath(pathname) {
activateLegacyPath(pathname);
},
navigateToArtistDetail,
cancelSimilarArtistsLoad() {
if (typeof cancelSimilarArtistsLoad === 'function') {
cancelSimilarArtistsLoad();
@ -174,6 +176,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) {
@ -181,35 +198,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,
});
}

View file

@ -21,614 +21,6 @@ const importPageState = {
_albumLookup: {}, // { albumId: { id, name, artist, source } }
};
// ===============================
// STATS PAGE
// ===============================
let _statsRange = '7d';
let _statsTimelineChart = null;
let _statsGenreChart = null;
let _statsDbStorageChart = null;
let _statsInitialized = false;
function initializeStatsPage() {
if (_statsInitialized) {
loadStatsData();
return;
}
_statsInitialized = true;
// Time range buttons
const rangeContainer = document.getElementById('stats-time-range');
if (rangeContainer) {
rangeContainer.addEventListener('click', (e) => {
const btn = e.target.closest('.stats-range-btn');
if (!btn) return;
_statsRange = btn.dataset.range;
rangeContainer.querySelectorAll('.stats-range-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
loadStatsData();
});
}
loadStatsData();
_updateStatsLastSynced();
}
async function triggerStatsSync() {
const btn = document.getElementById('stats-sync-btn');
if (btn) btn.classList.add('syncing');
try {
const resp = await fetch('/api/listening-stats/sync', { method: 'POST' });
const data = await resp.json();
if (data.success) {
showToast('Syncing listening data...', 'info');
// Wait a few seconds for the sync to complete, then reload
setTimeout(async () => {
await loadStatsData();
_updateStatsLastSynced();
if (btn) btn.classList.remove('syncing');
showToast('Listening stats updated', 'success');
}, 5000);
} else {
showToast(data.error || 'Sync failed', 'error');
if (btn) btn.classList.remove('syncing');
}
} catch (e) {
showToast('Sync failed', 'error');
if (btn) btn.classList.remove('syncing');
}
}
async function _updateStatsLastSynced() {
const el = document.getElementById('stats-last-synced');
if (!el) return;
try {
const resp = await fetch('/api/listening-stats/status');
const data = await resp.json();
if (data.stats && data.stats.last_poll) {
el.textContent = `Last synced: ${data.stats.last_poll}`;
} else {
el.textContent = 'Not synced yet';
}
} catch {
el.textContent = '';
}
}
async function loadStatsData() {
// Show loading state
document.querySelectorAll('.stats-card-value').forEach(el => el.style.opacity = '0.3');
// Single cached endpoint — instant response
let data;
try {
const resp = await fetch(`/api/stats/cached?range=${_statsRange}`);
data = await resp.json();
} catch {
data = {};
}
if (!data.success) {
// Cache not available — show empty state, user should hit Sync
data = {
overview: {}, top_artists: [], top_albums: [], top_tracks: [],
timeline: [], genres: [], recent: [], health: {}
};
}
const overview = data.overview || {};
const emptyEl = document.getElementById('stats-empty');
const hasData = (overview.total_plays || 0) > 0;
if (emptyEl) {
emptyEl.classList.toggle('hidden', hasData);
}
// Hide main content sections when no data
const mainSections = document.querySelectorAll('.stats-overview, .stats-main-grid, .stats-full-width');
mainSections.forEach(el => el.style.display = hasData ? '' : 'none');
// Overview cards
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.toLocaleString();
};
const _fmtTime = (ms) => {
if (!ms) return '0h';
const hours = Math.floor(ms / 3600000);
const mins = Math.floor((ms % 3600000) / 60000);
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
};
// Restore opacity
document.querySelectorAll('.stats-card-value').forEach(el => el.style.opacity = '1');
_setText('stats-total-plays', _fmt(overview.total_plays));
_setText('stats-listening-time', _fmtTime(overview.total_time_ms));
_setText('stats-unique-artists', _fmt(overview.unique_artists));
_setText('stats-unique-albums', _fmt(overview.unique_albums));
_setText('stats-unique-tracks', _fmt(overview.unique_tracks));
// Top Artists — visual bubbles
_renderTopArtistsVisual(data.top_artists || []);
// Top Artists — ranked list
_renderRankedList('stats-top-artists', data.top_artists || [], (item, i) => `
<div class="stats-ranked-item">
<span class="stats-ranked-num">${i + 1}</span>
${item.image_url ? `<img class="stats-ranked-img" src="${item.image_url}" alt="" onerror="this.style.display='none'">` : ''}
<div class="stats-ranked-info">
<div class="stats-ranked-name">${item.id ? `<a class="stats-artist-link" href="${buildArtistDetailPath(item.id)}">${_esc(item.name)}</a>` : _esc(item.name)}${item.soul_id && !String(item.soul_id).startsWith('soul_unnamed_') ? ' <img src="/static/trans2.png" style="width:12px;height:12px;vertical-align:middle;opacity:0.5;" title="SoulID">' : ''}</div>
<div class="stats-ranked-meta">${item.global_listeners ? _fmt(item.global_listeners) + ' global listeners' : ''}</div>
</div>
<span class="stats-ranked-count">${_fmt(item.play_count)} plays</span>
</div>
`);
// Top Albums
_renderRankedList('stats-top-albums', data.top_albums || [], (item, i) => `
<div class="stats-ranked-item">
<span class="stats-ranked-num">${i + 1}</span>
${item.image_url ? `<img class="stats-ranked-img" src="${item.image_url}" alt="" onerror="this.style.display='none'">` : ''}
<div class="stats-ranked-info">
<div class="stats-ranked-name">${_esc(item.name)}</div>
<div class="stats-ranked-meta">${item.artist_id ? `<a class="stats-artist-link" href="${buildArtistDetailPath(item.artist_id)}">${_esc(item.artist || '')}</a>` : _esc(item.artist || '')}</div>
</div>
<span class="stats-ranked-count">${_fmt(item.play_count)} plays</span>
</div>
`);
// Top Tracks
_renderRankedList('stats-top-tracks', data.top_tracks || [], (item, i) => `
<div class="stats-ranked-item">
<span class="stats-ranked-num">${i + 1}</span>
${item.image_url ? `<img class="stats-ranked-img" src="${item.image_url}" alt="" onerror="this.style.display='none'">` : ''}
<div class="stats-ranked-info">
<div class="stats-ranked-name">${_esc(item.name)}</div>
<div class="stats-ranked-meta">${item.artist_id ? `<a class="stats-artist-link" href="${buildArtistDetailPath(item.artist_id)}">${_esc(item.artist || '')}</a>` : _esc(item.artist || '')}${item.album ? ' · ' + _esc(item.album) : ''}</div>
</div>
<button class="stats-play-btn" onclick="event.stopPropagation();playStatsTrack('${_esc(item.name).replace(/'/g, "\\'")}','${_esc(item.artist || '').replace(/'/g, "\\'")}','${_esc(item.album || '').replace(/'/g, "\\'")}')" title="Play"></button>
<span class="stats-ranked-count">${_fmt(item.play_count)} plays</span>
</div>
`);
// 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 = `<div class="stats-artist-bubbles">
${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 class="stats-artist-bubble" href="${a.id ? buildArtistDetailPath(a.id, a.source || null) : '#'}" style="cursor:${a.id ? 'pointer' : 'default'};text-decoration:none;color:inherit;">
<div class="stats-bubble-img" style="width:${size}px;height:${size}px;${a.image_url ? `background-image:url('${a.image_url}')` : ''}">
${!a.image_url ? `<span>${(a.name || '?')[0]}</span>` : ''}
</div>
<div class="stats-bubble-bar-container">
<div class="stats-bubble-bar" style="width:${pct}%"></div>
</div>
<div class="stats-bubble-name">${_esc(a.name)}</div>
<div class="stats-bubble-count">${_fmt(a.play_count)}</div>
</a>`;
}).join('')}
</div>`;
}
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('')
: '<div style="color:rgba(255,255,255,0.3);font-size:0.85em;padding:12px;">No data yet</div>';
}
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) => `
<div class="stats-genre-legend-item">
<span class="stats-genre-dot" style="background:${colors[i]}"></span>
<span>${g.genre}</span>
<span class="stats-genre-pct">${g.percentage}%</span>
</div>
`).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 `<div class="stats-format-segment" style="flex:${count};background:${fmtColors[fmt] || '#555'}" title="${fmt}: ${count} tracks (${pct}%)">${pct > 8 ? fmt : ''}</div>`;
}).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 => `
<div class="stats-enrich-item">
<span class="stats-enrich-name">${s.name}</span>
<div class="stats-enrich-bar"><div class="stats-enrich-fill" style="width:${s.pct}%;background:${s.color}"></div></div>
<span class="stats-enrich-pct">${s.pct}%</span>
</div>
`).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 `
<div class="stats-disk-format-row">
<span class="stats-disk-format-name">${ext.toUpperCase()}</span>
<div class="stats-disk-format-bar">
<div class="stats-disk-format-fill" style="width:${pct}%"></div>
</div>
<span class="stats-disk-format-size">${_formatBytes(bytes)}</span>
</div>
`;
}).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 = `<div class="stats-db-total-value">${sizeStr}</div><div class="stats-db-total-label">Total Size</div>`;
}
// 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 `<div class="stats-db-legend-item">
<span class="stats-db-legend-dot" style="background:${colors[i]}"></span>
<span class="stats-db-legend-name">${t.name}</span>
<span class="stats-db-legend-size">${sizeLabel}</span>
</div>`;
}).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 = '<div style="color:rgba(255,255,255,0.3);font-size:0.85em;padding:12px;">No recent plays</div>';
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 => `
<div class="stats-recent-item">
<button class="stats-play-btn stats-play-btn-sm" onclick="event.stopPropagation();playStatsTrack('${_esc(t.title).replace(/'/g, "\\'")}','${_esc(t.artist || '').replace(/'/g, "\\'")}','${_esc(t.album || '').replace(/'/g, "\\'")}')" title="Play"></button>
<span class="stats-recent-title">${_esc(t.title)}</span>
<span class="stats-recent-artist">${_esc(t.artist || '')}</span>
<span class="stats-recent-time">${_ago(t.played_at)}</span>
</div>
`).join('');
}
// --- Initialization ---
function initializeImportPage() {
@ -1197,7 +589,8 @@ async function importPageMatchAutoGroup(groupIdx) {
importPageState._autoGroupFilePaths = group.file_paths;
// Render results — user picks the right album
grid.innerHTML = data.albums.map(a => _renderSuggestionCard(a)).join('');
const banner = _renderImportFallbackBanner(data.albums, data.primary_source);
grid.innerHTML = banner + data.albums.map(a => _renderSuggestionCard(a, data.primary_source)).join('');
} else {
grid.innerHTML = '<div style="color:#888;text-align:center;padding:20px;">No albums found — try searching manually</div>';
}
@ -1233,28 +626,51 @@ async function importPageLoadSuggestions() {
}
section.style.display = '';
grid.innerHTML = data.suggestions.map(a => _renderSuggestionCard(a)).join('');
const banner = _renderImportFallbackBanner(data.suggestions, data.primary_source);
grid.innerHTML = banner + data.suggestions.map(a => _renderSuggestionCard(a, data.primary_source)).join('');
} catch (err) {
// Network error or server not ready — fail silently
console.warn('Failed to load import suggestions:', err);
}
}
function _renderSuggestionCard(a) {
function _renderSuggestionCard(a, primarySource) {
// Cache the album lookup so importPageSelectAlbum can pull source +
// name + artist on click (the onclick can only carry the ID string
// — see github issue #524 root cause).
importPageState._albumLookup[a.id] = {
id: a.id, name: a.name || '', artist: a.artist || '', source: a.source || '',
};
// Surface the served source when it differs from the user's configured
// primary — the search route silently falls through to the next source
// in METADATA_SOURCE_PRIORITY when the primary returns nothing
// (intentional design, see core/auto_import_worker.py:1316). Without
// this badge the user has no idea their MusicBrainz / Discogs choice
// got bypassed (github issue #681).
const sourceBadge = (a.source && primarySource && a.source !== primarySource)
? `<div class="import-page-album-card-source">via ${_esc((SOURCE_LABELS[a.source] || {}).text || a.source)}</div>`
: '';
return `<div class="import-page-album-card" onclick="importPageSelectAlbum('${_escAttr(a.id)}')">
<img src="${a.image_url || '/static/placeholder.png'}" alt="${_escAttr(a.name)}" loading="lazy" onerror="this.src='/static/placeholder.png'">
<img src="${a.image_url || '/static/placeholder-album.png'}" alt="${_escAttr(a.name)}" loading="lazy" onerror="this.src='/static/placeholder-album.png'">
<div class="import-page-album-card-title" title="${_escAttr(a.name)}">${_esc(a.name)}</div>
<div class="import-page-album-card-artist" title="${_escAttr(a.artist)}">${_esc(a.artist)}</div>
<div class="import-page-album-card-meta">${a.total_tracks} tracks · ${a.release_date ? a.release_date.substring(0, 4) : ''}</div>
${sourceBadge}
</div>`;
}
function _renderImportFallbackBanner(albums, primarySource) {
if (!primarySource || !albums || !albums.length) return '';
const allFallback = albums.every(a => a.source && a.source !== primarySource);
if (!allFallback) return '';
const servedSource = albums[0].source;
const primaryLabel = (SOURCE_LABELS[primarySource] || {}).text || primarySource;
const servedLabel = (SOURCE_LABELS[servedSource] || {}).text || servedSource;
// Neutral wording — covers both live-search fallback (primary returned 0)
// and cache-stale suggestions (primary changed since cache was built).
return `<div class="import-page-fallback-banner">Showing ${_esc(servedLabel)} results — not from your primary source (${_esc(primaryLabel)}).</div>`;
}
// --- Album Tab: Search ---
async function importPageSearchAlbum() {
@ -1274,20 +690,8 @@ async function importPageSearchAlbum() {
grid.innerHTML = '<div style="color:#888;text-align:center;padding:20px;">No albums found</div>';
return;
}
grid.innerHTML = data.albums.map(a => {
// Cache album lookup so the click handler can include source
// + name + artist on the match POST (see #524).
importPageState._albumLookup[a.id] = {
id: a.id, name: a.name || '', artist: a.artist || '', source: a.source || '',
};
return `
<div class="import-page-album-card" onclick="importPageSelectAlbum('${_escAttr(a.id)}')">
<img src="${a.image_url || '/static/placeholder.png'}" alt="${_escAttr(a.name)}" loading="lazy" onerror="this.src='/static/placeholder.png'">
<div class="import-page-album-card-title" title="${_escAttr(a.name)}">${_esc(a.name)}</div>
<div class="import-page-album-card-artist" title="${_escAttr(a.artist)}">${_esc(a.artist)}</div>
<div class="import-page-album-card-meta">${a.total_tracks} tracks · ${a.release_date ? a.release_date.substring(0, 4) : ''}</div>
</div>`;
}).join('');
const banner = _renderImportFallbackBanner(data.albums, data.primary_source);
grid.innerHTML = banner + data.albums.map(a => _renderSuggestionCard(a, data.primary_source)).join('');
document.getElementById('import-page-album-clear-btn').classList.remove('hidden');
} catch (err) {
grid.innerHTML = `<div style="color:#ef4444;text-align:center;padding:20px;">Error: ${err.message}</div>`;
@ -1341,7 +745,7 @@ async function importPageSelectAlbum(albumId) {
// Render hero
const album = data.album;
document.getElementById('import-page-album-hero').innerHTML = `
<img src="${album.image_url || '/static/placeholder.png'}" alt="${_escAttr(album.name)}" loading="lazy" onerror="this.src='/static/placeholder.png'">
<img src="${album.image_url || '/static/placeholder-album.png'}" alt="${_escAttr(album.name)}" loading="lazy" onerror="this.src='/static/placeholder-album.png'">
<div class="import-page-album-hero-info">
<div class="import-page-album-hero-title">${_esc(album.name)}</div>
<div class="import-page-album-hero-artist">${_esc(album.artist)}</div>
@ -1761,7 +1165,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 `
<div class="import-page-single-result-item" onclick="importPageSelectSingleMatch(${fileIdx}, ${tIdx})">
${t.image_url ? `<img class="import-page-single-result-img" src="${t.image_url}" onerror="this.src='/static/placeholder.png'">` : ''}
${t.image_url ? `<img class="import-page-single-result-img" src="${t.image_url}" onerror="this.src='/static/placeholder-album.png'">` : ''}
<div class="import-page-single-result-info">
<div class="import-page-single-result-name">${_esc(t.name)} - ${_esc(t.artist)}</div>
<div class="import-page-single-result-detail">${_esc(t.album)}${dur ? ' · ' + dur : ''}</div>
@ -1923,7 +1327,7 @@ function _importQueueRender() {
return `
<div class="import-page-queue-item">
${j.imageUrl
? `<img class="import-page-queue-art" src="${j.imageUrl}" onerror="this.src='/static/placeholder.png'">`
? `<img class="import-page-queue-art" src="${j.imageUrl}" onerror="this.src='/static/placeholder-album.png'">`
: `<div class="import-page-queue-art" style="background:rgba(255,255,255,0.06);display:flex;align-items:center;justify-content:center;font-size:18px;color:rgba(255,255,255,0.3);">&#9834;</div>`}
<div class="import-page-queue-info">
<div class="import-page-queue-name">${_esc(j.label)}</div>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -2314,7 +2314,7 @@ async function openDownloadMissingModal(playlistId) {
onchange="updateTrackSelectionCount('${playlistId}')">
</td>
<td class="track-number">${index + 1}</td>
<td class="track-name" title="${escapeHtml(track.name)}">${escapeHtml(track.name)}</td>
<td class="track-name" title="${escapeHtml(track.name)}">${renderModalTrackPlayButton(playlistId, index)}${escapeHtml(track.name)}</td>
<td class="track-artist" title="${escapeHtml(formatArtists(track.artists))}">${escapeHtml(formatArtists(track.artists))}</td>
<td class="track-duration">${formatDuration(track.duration_ms)}</td>
<td class="track-match-status match-checking" id="match-${playlistId}-${index}">🔍 Pending</td>

View file

@ -883,10 +883,16 @@ function generateWishlistTrackList(tracks, trackOwnership) {
const badge = isOwned
? '<div class="wishlist-track-badge owned"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg></div>'
: '';
// 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 = `<button class="modal-track-play-btn wishlist-track-play-btn" onclick="event.stopPropagation(); playWishlistModalTrack(${index})" title="${isOwned ? 'Play from library' : 'Play (stream fallback)'}">&#9654;</button>`;
return `
<div class="wishlist-track-item ${ownershipClass}">
<div class="wishlist-track-number">${trackNumber}</div>
${playButton}
<div class="wishlist-track-info">
<div class="wishlist-track-name">${trackName}</div>
<div class="wishlist-track-artists">${artistsString}</div>
@ -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 = '&#9654;';
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
*/

View file

@ -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 '';
}
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;
@ -95,7 +101,17 @@ 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 +141,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');

View file

@ -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();
});