commit
0af66b1bb4
71 changed files with 8621 additions and 2286 deletions
4
.github/workflows/docker-publish.yml
vendored
4
.github/workflows/docker-publish.yml
vendored
|
|
@ -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
299
core/discovery/qobuz.py
Normal 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')
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
81
tests/test_orphan_file_detector.py
Normal file
81
tests/test_orphan_file_detector.py
Normal 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 == []
|
||||
456
tests/test_qobuz_playlists.py
Normal file
456
tests/test_qobuz_playlists.py
Normal 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']
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
|
|
|||
766
web_server.py
766
web_server.py
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>/`.
|
||||
|
|
|
|||
46
webui/docs/migration/README.md
Normal file
46
webui/docs/migration/README.md
Normal 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
|
||||
|
|
@ -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.
|
||||
408
webui/docs/migration/stats-migration-plan.md
Normal file
408
webui/docs/migration/stats-migration-plan.md
Normal 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
|
||||
241
webui/index.html
241
webui/index.html
|
|
@ -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
372
webui/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
16
webui/src/platform/shell/globals.d.ts
vendored
16
webui/src/platform/shell/globals.d.ts
vendored
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
19
webui/src/platform/shell/status.test.ts
Normal file
19
webui/src/platform/shell/status.test.ts
Normal 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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
25
webui/src/platform/shell/status.ts
Normal file
25
webui/src/platform/shell/status.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: () => (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
169
webui/src/routes/stats/-route.test.tsx
Normal file
169
webui/src/routes/stats/-route.test.tsx
Normal 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'));
|
||||
});
|
||||
});
|
||||
143
webui/src/routes/stats/-stats.api.test.ts
Normal file
143
webui/src/routes/stats/-stats.api.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
157
webui/src/routes/stats/-stats.api.ts
Normal file
157
webui/src/routes/stats/-stats.api.ts
Normal 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 });
|
||||
}
|
||||
67
webui/src/routes/stats/-stats.helpers.test.ts
Normal file
67
webui/src/routes/stats/-stats.helpers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
162
webui/src/routes/stats/-stats.helpers.ts
Normal file
162
webui/src/routes/stats/-stats.helpers.ts
Normal 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$/, '');
|
||||
}
|
||||
148
webui/src/routes/stats/-stats.types.ts
Normal file
148
webui/src/routes/stats/-stats.types.ts
Normal 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>;
|
||||
}
|
||||
938
webui/src/routes/stats/-ui/stats-page.module.css
Normal file
938
webui/src/routes/stats/-ui/stats-page.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
929
webui/src/routes/stats/-ui/stats-page.tsx
Normal file
929
webui/src/routes/stats/-ui/stats-page.tsx
Normal 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 "Listening Stats" 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';
|
||||
}
|
||||
33
webui/src/routes/stats/route.tsx
Normal file
33
webui/src/routes/stats/route.tsx
Normal 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,
|
||||
});
|
||||
24
webui/src/test/shell-bridge.ts
Normal file
24
webui/src/test/shell-bridge.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">▶</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 || '')
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])) || ' ';
|
||||
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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(); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
====================================== */
|
||||
|
|
|
|||
|
|
@ -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)">
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);">♪</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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)'}">▶</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 = '▶';
|
||||
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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue