diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 3ac9c655..1ea28575 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -9,9 +9,9 @@ on: workflow_dispatch: inputs: version_tag: - description: 'Version tag (e.g. 2.6.1)' + description: 'Version tag (e.g. 2.6.2)' required: true - default: '2.6.1' + default: '2.6.2' jobs: build-and-push: diff --git a/core/automation/api.py b/core/automation/api.py index a6f78afe..0e05dd13 100644 --- a/core/automation/api.py +++ b/core/automation/api.py @@ -172,9 +172,11 @@ def create_automation( return {'error': f'Signal cycle detected: {cycle_path}. This would cause an infinite loop.'}, 400 group_name = data.get('group_name') or None + owned_by = data.get('owned_by') or None auto_id = database.create_automation( name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions_json, group_name, + owned_by=owned_by, ) if auto_id is None: return {'error': 'Failed to create automation'}, 500 @@ -217,6 +219,8 @@ def update_automation( update_fields['notify_config'] = json.dumps(data['notify_config']) if 'group_name' in data: update_fields['group_name'] = data['group_name'] or None + if 'owned_by' in data: + update_fields['owned_by'] = data['owned_by'] or None if not update_fields: return {'error': 'No fields to update'}, 400 @@ -225,6 +229,12 @@ def update_automation( if cycle_path: return {'error': f'Signal cycle detected: {cycle_path}. This would cause an infinite loop.'}, 400 + # Schedule-shape changes must invalidate the stored next_run so the + # scheduler recomputes it; otherwise restart-survival logic keeps the + # leftover timestamp from the previous interval. + if {'trigger_type', 'trigger_config'} & update_fields.keys(): + update_fields['next_run'] = None + success = database.update_automation(automation_id, **update_fields) if not success: return {'error': 'Automation not found'}, 404 diff --git a/core/automation/deps.py b/core/automation/deps.py index a521a496..08d73d47 100644 --- a/core/automation/deps.py +++ b/core/automation/deps.py @@ -56,6 +56,14 @@ class AutomationState: with self.lock: return self.pipeline_running + def try_start_pipeline(self) -> bool: + """Atomically mark the shared playlist pipeline as running.""" + with self.lock: + if self.pipeline_running: + return False + self.pipeline_running = True + return True + def set_scan_library_id(self, automation_id: Optional[str]) -> None: with self.lock: self.scan_library_automation_id = automation_id @@ -144,3 +152,10 @@ class AutomationDeps: # PersonalizedPlaylistManager per run (cheap accessors inside, # no caching needed yet). build_personalized_manager: Callable[[], Any] + + # --- Unified PlaylistSource registry --- + # Optional so test fixtures that don't exercise refresh_mirrored + # can keep their existing scaffolding. Production wiring in + # ``web_server.py`` always populates it via + # ``core.playlists.sources.bootstrap.build_playlist_source_registry``. + playlist_source_registry: Optional[Any] = None diff --git a/core/automation/handlers/_pipeline_shared.py b/core/automation/handlers/_pipeline_shared.py index c7427f8a..560ae3c3 100644 --- a/core/automation/handlers/_pipeline_shared.py +++ b/core/automation/handlers/_pipeline_shared.py @@ -80,9 +80,11 @@ def run_sync_and_wishlist( if sync_status == 'started': sync_id = sync_id_for_fn(pl) sync_poll_start = time.time() + timed_out = True while time.time() - sync_poll_start < _SYNC_PER_PLAYLIST_TIMEOUT_SECONDS: if (sync_id in sync_states and sync_states[sync_id].get('status') in _SYNC_TERMINAL_STATUSES): + timed_out = False break time.sleep(2) elapsed = int(time.time() - sync_poll_start) @@ -94,6 +96,25 @@ def run_sync_and_wishlist( ) ss = sync_states.get(sync_id, {}) + final_status = ss.get('status') + if timed_out: + sync_errors += 1 + deps.update_progress( + automation_id, + log_line=f'Sync timed out "{pl_name}" after {_SYNC_PER_PLAYLIST_TIMEOUT_SECONDS}s', + log_type='error', + ) + continue + if final_status in ('error', 'failed'): + sync_errors += 1 + reason = ss.get('error') or ss.get('reason') or 'background sync failed' + deps.update_progress( + automation_id, + log_line=f'Sync error "{pl_name}": {reason}', + log_type='error', + ) + continue + ss_result = ss.get('result', ss.get('progress', {})) matched = ss_result.get('matched_tracks', 0) if isinstance(ss_result, dict) else 0 total_synced += int(matched) if matched else 0 diff --git a/core/automation/handlers/playlist_pipeline.py b/core/automation/handlers/playlist_pipeline.py index 8fc98df2..eacea479 100644 --- a/core/automation/handlers/playlist_pipeline.py +++ b/core/automation/handlers/playlist_pipeline.py @@ -1,227 +1,27 @@ -"""Automation handler: ``playlist_pipeline`` action. +"""Automation adapter for the mirrored playlist pipeline. -Lifted from ``web_server._register_automation_handlers`` (the -``_auto_playlist_pipeline`` closure). Runs the full playlist -lifecycle in a single trigger: - - Phase 1: REFRESH -- pull fresh track lists from sources - Phase 2: DISCOVER -- look up official Spotify/iTunes metadata - Phase 3: SYNC -- push the result to the active media server - Phase 4: WISHLIST -- queue any missing tracks for download - -Each phase emits its own progress range so the trigger card shows -useful per-phase percentages instead of "loading...". Phase 4 is -optional via ``skip_wishlist`` config. - -Composition: this handler invokes ``auto_refresh_mirrored`` and -``auto_sync_playlist`` directly (passing ``_automation_id: None`` so -the sub-handlers don't hijack pipeline progress) instead of going -through the engine — keeps the four phases observable as one -trigger from the user's perspective. Pipeline-level guard -(``state.pipeline_running``) prevents overlapping runs. +The actual all-in-one playlist lifecycle lives in +``core.playlists.pipeline`` so it can be reused by non-automation UI actions. +This module only wires automation-specific dependencies and handlers. """ from __future__ import annotations -import threading -import time from typing import Any, Dict from core.automation.deps import AutomationDeps from core.automation.handlers._pipeline_shared import run_sync_and_wishlist from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored from core.automation.handlers.sync_playlist import auto_sync_playlist - - -# Per-playlist sync poll cap inside Phase 3. -# Discovery poll cap inside Phase 2. -_DISCOVERY_TIMEOUT_SECONDS = 3600 +from core.playlists.pipeline import run_mirrored_playlist_pipeline def auto_playlist_pipeline(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]: - """Run REFRESH → DISCOVER → SYNC → WISHLIST in sequence. - - Sets / clears ``deps.state.pipeline_running`` around the whole - run so the registration guard can short-circuit overlapping - triggers. - """ - deps.state.set_pipeline_running(True) - automation_id = config.get('_automation_id') - pipeline_start = time.time() - - try: - db = deps.get_database() - playlist_id = config.get('playlist_id') - process_all = config.get('all', False) - skip_wishlist = config.get('skip_wishlist', False) - - # Resolve playlists. - if process_all: - playlists = db.get_mirrored_playlists() - elif playlist_id: - p = db.get_mirrored_playlist(int(playlist_id)) - playlists = [p] if p else [] - else: - deps.state.set_pipeline_running(False) - return {'status': 'error', 'error': 'No playlist specified'} - - playlists = [pl for pl in playlists if pl.get('source', '') not in ('file', 'beatport')] - if not playlists: - deps.state.set_pipeline_running(False) - return {'status': 'error', 'error': 'No refreshable playlists found'} - - pl_names = ', '.join(p.get('name', '?') for p in playlists[:3]) - if len(playlists) > 3: - pl_names += f' (+{len(playlists) - 3} more)' - - deps.update_progress( - automation_id, - progress=2, - phase=f'Pipeline: {len(playlists)} playlist(s)', - log_line=f'Starting pipeline for: {pl_names}', - log_type='info', - ) - - # ── PHASE 1: REFRESH ────────────────────────────────────────── - deps.update_progress( - automation_id, - progress=3, - phase='Phase 1/4: Refreshing playlists...', - log_line='Phase 1: Refresh', - log_type='info', - ) - - refresh_config = dict(config) - refresh_config['_automation_id'] = None # Don't let sub-handler hijack pipeline progress. - refresh_result = auto_refresh_mirrored(refresh_config, deps) - refreshed = int(refresh_result.get('refreshed', 0)) - refresh_errors = int(refresh_result.get('errors', 0)) - - deps.update_progress( - automation_id, - progress=25, - phase='Phase 1/4: Refresh complete', - log_line=f'Phase 1 done: {refreshed} refreshed, {refresh_errors} errors', - log_type='success' if refresh_errors == 0 else 'warning', - ) - - # ── PHASE 2: DISCOVER ───────────────────────────────────────── - deps.update_progress( - automation_id, - progress=26, - phase='Phase 2/4: Discovering metadata...', - log_line='Phase 2: Discover', - log_type='info', - ) - - # Reload playlists (refresh may have updated them). - if process_all: - disc_playlists = db.get_mirrored_playlists() - else: - disc_playlists = [db.get_mirrored_playlist(int(playlist_id))] - disc_playlists = [p for p in disc_playlists if p] - - # Run discovery in a thread and wait for it. - disc_done = threading.Event() - - def _disc_wrapper(pls): - try: - # The worker updates automation_progress internally, - # but we pass None so it doesn't conflict with our - # pipeline progress. - deps.run_playlist_discovery_worker(pls, automation_id=None) - except Exception as e: - deps.logger.error(f"[Pipeline] Discovery error: {e}") - finally: - disc_done.set() - - threading.Thread( - target=_disc_wrapper, args=(disc_playlists,), - daemon=True, name='pipeline-discover', - ).start() - - # Poll for completion with progress updates. - poll_start = time.time() - while not disc_done.wait(timeout=3): - elapsed = int(time.time() - poll_start) - deps.update_progress( - automation_id, - progress=min(26 + elapsed // 4, 54), - phase=f'Phase 2/4: Discovering... ({elapsed}s)', - ) - if elapsed > _DISCOVERY_TIMEOUT_SECONDS: - deps.update_progress( - automation_id, - log_line='Discovery timed out after 1 hour', - log_type='warning', - ) - break - - deps.update_progress( - automation_id, - progress=55, - phase='Phase 2/4: Discovery complete', - log_line='Phase 2 done: discovery complete', - log_type='success', - ) - - # ── PHASE 3 + 4: SYNC + WISHLIST (delegated to shared helper) ── - # Each mirrored playlist payload only needs `id` + `name` for - # the helper; `auto_sync_playlist` reads the rest from the - # mirrored DB by id. - sync_summary = run_sync_and_wishlist( - deps, - automation_id, - [pl for pl in playlists if pl.get('id')], - sync_one_fn=lambda pl: auto_sync_playlist( - {'playlist_id': str(pl['id']), '_automation_id': None}, - deps, - ), - sync_id_for_fn=lambda pl: f"auto_mirror_{pl['id']}", - skip_wishlist=skip_wishlist, - progress_start=56, - progress_end=85, - sync_phase_label='Phase 3/4: Syncing to server...', - sync_phase_start_log='Phase 3: Sync', - wishlist_phase_label='Phase 4/4: Processing wishlist...', - wishlist_phase_start_log='Phase 4: Wishlist', - ) - total_synced = sync_summary['synced'] - total_skipped = sync_summary['skipped'] - sync_errors = sync_summary['errors'] - wishlist_queued = sync_summary['wishlist_queued'] - - # ── COMPLETE ────────────────────────────────────────────────── - duration = int(time.time() - pipeline_start) - deps.update_progress( - automation_id, - status='finished', - progress=100, - phase='Pipeline complete', - log_line=f'Pipeline finished in {duration // 60}m {duration % 60}s', - log_type='success', - ) - - deps.state.set_pipeline_running(False) - return { - 'status': 'completed', - '_manages_own_progress': True, - 'playlists_refreshed': str(refreshed), - 'tracks_discovered': 'completed', - 'tracks_synced': str(total_synced), - 'sync_skipped': str(total_skipped), - 'wishlist_queued': str(wishlist_queued), - 'duration_seconds': str(duration), - } - - except Exception as e: - deps.state.set_pipeline_running(False) - deps.update_progress( - automation_id, - status='error', - progress=100, - phase='Pipeline error', - log_line=f'Pipeline failed: {e}', - log_type='error', - ) - return {'status': 'error', 'error': str(e), '_manages_own_progress': True} + """Run REFRESH -> DISCOVER -> SYNC -> WISHLIST for mirrored playlists.""" + return run_mirrored_playlist_pipeline( + config, + deps, + refresh_fn=auto_refresh_mirrored, + sync_one_fn=auto_sync_playlist, + sync_and_wishlist_fn=run_sync_and_wishlist, + ) diff --git a/core/automation/handlers/refresh_mirrored.py b/core/automation/handlers/refresh_mirrored.py index 76ea32ce..12eccb8d 100644 --- a/core/automation/handlers/refresh_mirrored.py +++ b/core/automation/handlers/refresh_mirrored.py @@ -1,23 +1,43 @@ """Automation handler: ``refresh_mirrored`` action. -Lifted from ``web_server._register_automation_handlers`` (the -``_auto_refresh_mirrored`` closure). Re-pulls track lists from each -mirrored playlist's source (Spotify / Tidal / Deezer / YouTube), -updates the local mirror DB, and emits a ``playlist_changed`` -automation event when the track set actually shifts. +Re-pulls track lists from each mirrored playlist's source via the +unified ``PlaylistSourceRegistry`` (Phase 1 of the Discover-to-Sync +unification). The pre-extraction handler had ~190 lines of per-source +if/elif branches; this version delegates to the adapter for each +source, leaving the handler responsible only for: -Source-specific branches (Spotify auth + public-embed fallback, -``spotify_public`` URL→ID resolution, Deezer / Tidal / YouTube) -remain identical to the pre-extraction closure — this is a -mechanical lift, not a redesign. +- filtering sources that can't be refreshed (``file``, ``beatport``), +- extracting upstream URLs from the stored ``description`` for URL- + backed sources (``spotify_public``, ``youtube``), +- the Spotify-public → authenticated-Spotify fallback (uses the + ``spotify`` adapter when the user is signed in so the mirror keeps + album art), +- the Tidal-not-authenticated skip log type (vs error), +- preserving existing per-track ``extra_data`` on tracks that survive + the refresh, and +- emitting the ``playlist_changed`` automation event when the track + set actually shifts. """ from __future__ import annotations -import json -from typing import Any, Dict +from typing import Any, Dict, List, Optional from core.automation.deps import AutomationDeps +from core.playlists.source_refs import require_refresh_url +from core.playlists.sources import PlaylistDetail, to_mirror_track_dict +from core.playlists.sources.base import ( + SOURCE_SPOTIFY, + SOURCE_SPOTIFY_PUBLIC, + SOURCE_TIDAL, + SOURCE_YOUTUBE, +) + + +# Sources that store the upstream URL in ``description`` (because their +# ``source_playlist_id`` is a deterministic hash, not the native ID). +# The refresh path has to recover the URL before calling the adapter. +_URL_BACKED_SOURCES = {SOURCE_SPOTIFY_PUBLIC, SOURCE_YOUTUBE} def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]: @@ -44,7 +64,7 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[ playlists = [pl for pl in playlists if pl.get('source', '') not in ('file', 'beatport')] refreshed = 0 - errors = [] + errors: List[str] = [] for idx, pl in enumerate(playlists): try: source = pl.get('source', '') @@ -55,249 +75,32 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[ phase=f'Refreshing: "{pl.get("name", "")}"', current_item=pl.get('name', ''), ) - tracks = None - if source == 'spotify': - # Try authenticated API first, fall back to public embed scraper. - if deps.spotify_client and deps.spotify_client.is_spotify_authenticated(): - playlist_obj = deps.spotify_client.get_playlist_by_id(source_id) - if playlist_obj and playlist_obj.tracks: - tracks = [] - for t in playlist_obj.tracks: - artist_name = t.artists[0] if t.artists else '' - track_dict = { - 'track_name': t.name or '', - 'artist_name': str(artist_name), - 'album_name': t.album or '', - 'duration_ms': t.duration_ms or 0, - 'source_track_id': t.id or '', - } - # Spotify data IS official — auto-mark as discovered. - if t.id: - _album_obj = {'name': t.album or ''} - if getattr(t, 'image_url', None): - _album_obj['images'] = [{'url': t.image_url, 'height': 600, 'width': 600}] - track_dict['extra_data'] = json.dumps({ - 'discovered': True, - 'provider': 'spotify', - 'confidence': 1.0, - 'matched_data': { - 'id': t.id, - 'name': t.name or '', - 'artists': [{'name': str(a)} for a in (t.artists or [])], - 'album': _album_obj, - 'duration_ms': t.duration_ms or 0, - 'image_url': getattr(t, 'image_url', None), - } - }) - tracks.append(track_dict) - - # Fallback: public embed scraper (no auth needed). - if tracks is None: - try: - from core.spotify_public_scraper import scrape_spotify_embed - embed_data = scrape_spotify_embed('playlist', source_id) - if embed_data and not embed_data.get('error') and embed_data.get('tracks'): - embed_album = embed_data.get('name', '') if embed_data.get('type') == 'album' else '' - tracks = [] - for t in embed_data['tracks']: - artist_names = [a['name'] for a in t.get('artists', [])] - artist_name = artist_names[0] if artist_names else '' - track_dict = { - 'track_name': t.get('name', ''), - 'artist_name': artist_name, - 'album_name': embed_album, - 'duration_ms': t.get('duration_ms', 0), - 'source_track_id': t.get('id', ''), - } - # Store Spotify track ID hint but don't mark discovered — - # Discover step needs to run for proper album art. - if t.get('id'): - track_dict['extra_data'] = json.dumps({ - 'discovered': False, - 'spotify_hint': { - 'id': t['id'], - 'name': t.get('name', ''), - 'artists': t.get('artists', []), - } - }) - tracks.append(track_dict) - except Exception as e: - deps.logger.warning(f"Spotify public scraper fallback failed for {source_id}: {e}") - - elif source == 'spotify_public': - # source_playlist_id is an MD5 hash; extract actual Spotify ID from stored description (URL). - try: - from core.spotify_public_scraper import parse_spotify_url, scrape_spotify_embed - spotify_url = pl.get('description', '') - parsed = parse_spotify_url(spotify_url) if spotify_url else None - - # If Spotify is authenticated, use the full API (auto-discovers with album art). - if (parsed and parsed.get('type') == 'playlist' - and deps.spotify_client and deps.spotify_client.is_spotify_authenticated()): - playlist_obj = deps.spotify_client.get_playlist_by_id(parsed['id']) - if playlist_obj and playlist_obj.tracks: - tracks = [] - for t in playlist_obj.tracks: - artist_name = t.artists[0] if t.artists else '' - track_dict = { - 'track_name': t.name or '', - 'artist_name': str(artist_name), - 'album_name': t.album or '', - 'duration_ms': t.duration_ms or 0, - 'source_track_id': t.id or '', - } - if t.id: - _album_obj = {'name': t.album or ''} - if getattr(t, 'image_url', None): - _album_obj['images'] = [{'url': t.image_url, 'height': 600, 'width': 600}] - track_dict['extra_data'] = json.dumps({ - 'discovered': True, - 'provider': 'spotify', - 'confidence': 1.0, - 'matched_data': { - 'id': t.id, - 'name': t.name or '', - 'artists': [{'name': str(a)} for a in (t.artists or [])], - 'album': _album_obj, - 'duration_ms': t.duration_ms or 0, - 'image_url': getattr(t, 'image_url', None), - } - }) - tracks.append(track_dict) - - # Fallback: public embed scraper (no auth or album-type URL). - if tracks is None and parsed: - embed_data = scrape_spotify_embed(parsed['type'], parsed['id']) - if embed_data and not embed_data.get('error') and embed_data.get('tracks'): - embed_album = embed_data.get('name', '') if embed_data.get('type') == 'album' else '' - tracks = [] - for t in embed_data['tracks']: - artist_names = [a['name'] for a in t.get('artists', [])] - artist_name = artist_names[0] if artist_names else '' - tracks.append({ - 'track_name': t.get('name', ''), - 'artist_name': artist_name, - 'album_name': embed_album, - 'duration_ms': t.get('duration_ms', 0), - 'source_track_id': t.get('id', ''), - }) - # No extra_data — let preservation code keep existing discovery data. - except Exception as e: - deps.logger.warning(f"Spotify public playlist refresh failed for {source_id}: {e}") - - elif source == 'deezer': - try: - deezer = deps.get_deezer_client() - playlist_data = deezer.get_playlist(source_id) - if playlist_data and playlist_data.get('tracks'): - tracks = [] - for t in playlist_data['tracks']: - artist_name = t['artists'][0] if t.get('artists') else '' - tracks.append({ - 'track_name': t.get('name', ''), - 'artist_name': str(artist_name), - 'album_name': t.get('album', ''), - 'duration_ms': t.get('duration_ms', 0), - 'source_track_id': str(t.get('id', '')), - }) - except Exception as e: - deps.logger.warning(f"Deezer playlist refresh failed for {source_id}: {e}") - - elif source == 'tidal': - if not deps.tidal_client or not deps.tidal_client.is_authenticated(): - deps.logger.warning(f"Tidal not authenticated — skipping refresh for '{pl.get('name', '')}'") - deps.update_progress( - auto_id, - log_line=f'Skipped "{pl.get("name", "")}" — Tidal not authenticated', - log_type='skip', - ) - continue - full_playlist = deps.tidal_client.get_playlist(source_id) - if full_playlist and full_playlist.tracks: - tracks = [] - for t in full_playlist.tracks: - artist_name = t.artists[0] if t.artists else '' - tracks.append({ - 'track_name': t.name or '', - 'artist_name': str(artist_name), - 'album_name': t.album or '', - 'duration_ms': t.duration_ms or 0, - 'source_track_id': t.id or '', - }) - - elif source == 'youtube': - # source_playlist_id is now a deterministic hash; use stored description (original URL) for refresh. - yt_url = pl.get('description', '') or f"https://www.youtube.com/playlist?list={source_id}" - playlist_data = deps.parse_youtube_playlist(yt_url) - if playlist_data and playlist_data.get('tracks'): - tracks = [] - for t in playlist_data['tracks']: - artist_name = t['artists'][0] if t.get('artists') else '' - tracks.append({ - 'track_name': t.get('name', ''), - 'artist_name': str(artist_name), - 'album_name': '', - 'duration_ms': t.get('duration_ms', 0), - 'source_track_id': t.get('id', ''), - }) - - if tracks is not None: - # Compare old vs new track IDs to detect changes. - old_tracks = db.get_mirrored_playlist_tracks(pl['id']) if pl.get('id') else [] - old_ids = {t.get('source_track_id') for t in old_tracks if t.get('source_track_id')} - new_ids = {t.get('source_track_id') for t in tracks if t.get('source_track_id')} - - # Preserve existing discovery extra_data for tracks that still exist. - old_extra_map = db.get_mirrored_tracks_extra_data_map(pl['id']) if pl.get('id') else {} - for t in tracks: - sid = t.get('source_track_id', '') - if sid and sid in old_extra_map and 'extra_data' not in t: - t['extra_data'] = old_extra_map[sid] - - db.mirror_playlist( - source=source, - source_playlist_id=source_id, - name=pl['name'], - tracks=tracks, - profile_id=pl.get('profile_id', 1), - owner=pl.get('owner'), - image_url=pl.get('image_url'), + detail = _fetch_detail(source, source_id, pl, deps, auto_id) + if detail is None: + # _fetch_detail already logged the specific failure; + # mark the playlist as a generic refresh error so the + # automation result tally matches the legacy handler. + errors.append(f"{pl.get('name', '?')}: no tracks returned from source") + deps.update_progress( + auto_id, + log_line=f'Refresh failed: "{pl.get("name", "")}" - no tracks returned from source', + log_type='error', ) - refreshed += 1 + continue - # Emit playlist_changed if tracks actually changed. - if old_ids != new_ids: - added_count = len(new_ids - old_ids) - removed_count = len(old_ids - new_ids) - deps.logger.info( - f"[AUTOMATION] Playlist changed: '{pl.get('name', '')}' — " - f"{added_count} added, {removed_count} removed (old={len(old_ids)}, new={len(new_ids)})" - ) - deps.update_progress( - auto_id, - log_line=f'"{pl.get("name", "")}" — {added_count} added, {removed_count} removed', - log_type='success', - ) - try: - if deps.engine: - deps.engine.emit('playlist_changed', { - 'playlist_name': pl.get('name', ''), - 'playlist_id': str(pl.get('id', '')), - 'old_count': str(len(old_ids)), - 'new_count': str(len(new_ids)), - 'added': str(added_count), - 'removed': str(removed_count), - }) - except Exception as e: - deps.logger.debug("playlist_synced automation emit failed: %s", e) - else: - deps.logger.warning(f"[AUTOMATION] No changes: '{pl.get('name', '')}' (tracks={len(old_ids)})") - deps.update_progress( - auto_id, - log_line=f'No changes: "{pl.get("name", "")}"', - log_type='skip', - ) + # Sources that return MB-metadata-only tracks (LB, Last.fm) + # mark them ``needs_discovery=True``. Hand them to the + # adapter's matcher so the resulting mirror rows carry + # provider IDs + matched_data, ready for the sync pipeline. + detail_tracks = _maybe_discover(detail.tracks, source, deps) + + tracks = [to_mirror_track_dict(t) for t in detail_tracks] + refreshed += _commit_refresh(pl, source, source_id, tracks, db, deps, auto_id) + except _SkipPlaylist: + # Source-specific soft-skip (e.g. Tidal not authenticated). + # Logging was already emitted; do not count as error. + continue except Exception as e: errors.append(f"{pl.get('name', '?')}: {str(e)}") deps.update_progress( @@ -306,3 +109,204 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[ log_type='error', ) return {'status': 'completed', 'refreshed': str(refreshed), 'errors': str(len(errors))} + + +def _maybe_discover( + tracks: List[Any], + source: str, + deps: AutomationDeps, +) -> List[Any]: + """Run the adapter's ``discover_tracks`` when any track needs it. + + Most sources are no-ops here (their tracks have provider IDs + already). LB / Last.fm are the ones that actually do work.""" + if not tracks: + return tracks + if not any(getattr(t, "needs_discovery", False) for t in tracks): + return tracks + registry = deps.playlist_source_registry + if registry is None: + return tracks + adapter = registry.get_source(source) + if adapter is None: + return tracks + try: + return adapter.discover_tracks(tracks) + except Exception as exc: + deps.logger.warning(f"{source} discover_tracks failed: {exc}") + return tracks + + +class _SkipPlaylist(Exception): + """Internal sentinel: source-specific soft-skip (e.g. not authed). + + The per-playlist loop catches it specifically so the skip isn't + counted in the error tally — matches the pre-extraction behavior + where ``continue`` was used inline.""" + + +def _fetch_detail( + source: str, + source_id: str, + pl: Dict[str, Any], + deps: AutomationDeps, + auto_id: Optional[str], +) -> Optional[PlaylistDetail]: + """Resolve the playlist's tracks through the registry. + + Handler-level branches (URL extraction, Spotify-public→authed + fallback, Tidal not-authed skip) live here; everything else + delegates to the adapter.""" + registry = deps.playlist_source_registry + if registry is None: + return None + + # URL-backed sources: pull the upstream URL out of `description`. + playlist_input = source_id + if source in _URL_BACKED_SOURCES: + # ``require_refresh_url`` raises ValueError on missing URL. + # The outer try/except in the loop catches it and reports as + # an error — matching the pre-extraction behavior. + playlist_input = require_refresh_url( + source, pl.get('description', ''), pl.get('name', '') + ) + + # Spotify-public refresh: prefer the authenticated Spotify API + # when the user is signed in. Better album art, matches the + # pre-extraction handler. Falls through to the public scraper on + # auth failure or non-playlist URL types (e.g. album URLs). + if source == SOURCE_SPOTIFY_PUBLIC: + detail = _try_spotify_authed_for_public(playlist_input, deps) + if detail is not None: + return detail + + # Tidal not-authed: soft-skip with a 'skip' log line, not an error. + if source == SOURCE_TIDAL: + tidal_source = registry.get_source(SOURCE_TIDAL) + if tidal_source is None or not tidal_source.is_authenticated(): + deps.logger.warning( + f"Tidal not authenticated — skipping refresh for '{pl.get('name', '')}'" + ) + deps.update_progress( + auto_id, + log_line=f'Skipped "{pl.get("name", "")}" — Tidal not authenticated', + log_type='skip', + ) + raise _SkipPlaylist + + adapter = registry.get_source(source) + if adapter is None: + return None + try: + return adapter.refresh_playlist(playlist_input) + except Exception as exc: + deps.logger.warning( + f"{source} playlist refresh failed for {playlist_input}: {exc}" + ) + return None + + +def _try_spotify_authed_for_public( + spotify_url: str, deps: AutomationDeps +) -> Optional[PlaylistDetail]: + """Best-effort: use the authenticated Spotify adapter on a public URL. + + Returns ``None`` to signal "fall through to the public-scraper + adapter" — never raises. Only applies to ``playlist``-type URLs; + album URLs fall through unconditionally.""" + if not spotify_url: + return None + spotify_client = deps.spotify_client + if spotify_client is None or not spotify_client.is_spotify_authenticated(): + return None + try: + from core.spotify_public_scraper import parse_spotify_url + parsed = parse_spotify_url(spotify_url) + except Exception: + return None + if not parsed or parsed.get('type') != 'playlist': + return None + adapter = deps.playlist_source_registry.get_source(SOURCE_SPOTIFY) + if adapter is None: + return None + try: + return adapter.refresh_playlist(parsed['id']) + except Exception as exc: + deps.logger.debug(f"Spotify authed fallback for public mirror failed: {exc}") + return None + + +def _commit_refresh( + pl: Dict[str, Any], + source: str, + source_id: str, + tracks: List[Dict[str, Any]], + db: Any, + deps: AutomationDeps, + auto_id: Optional[str], +) -> int: + """Persist the refreshed track list + emit playlist_changed when delta. + + Returns 1 when a refresh successfully landed, 0 otherwise. The + caller is responsible for incrementing the running tally.""" + old_tracks = db.get_mirrored_playlist_tracks(pl['id']) if pl.get('id') else [] + old_ids = {t.get('source_track_id') for t in old_tracks if t.get('source_track_id')} + new_ids = {t.get('source_track_id') for t in tracks if t.get('source_track_id')} + + # Preserve existing extra_data (matched_data + discovery state) + # for tracks that still exist in the refreshed snapshot, unless + # the adapter already provided fresh extra_data for that track. + old_extra_map = ( + db.get_mirrored_tracks_extra_data_map(pl['id']) if pl.get('id') else {} + ) + for t in tracks: + sid = t.get('source_track_id', '') + if sid and sid in old_extra_map and 'extra_data' not in t: + t['extra_data'] = old_extra_map[sid] + + db.mirror_playlist( + source=source, + source_playlist_id=source_id, + name=pl['name'], + tracks=tracks, + profile_id=pl.get('profile_id', 1), + description=pl.get('description'), + owner=pl.get('owner'), + image_url=pl.get('image_url'), + ) + + if old_ids != new_ids: + added = len(new_ids - old_ids) + removed = len(old_ids - new_ids) + deps.logger.info( + f"[AUTOMATION] Playlist changed: '{pl.get('name', '')}' — " + f"{added} added, {removed} removed (old={len(old_ids)}, new={len(new_ids)})" + ) + deps.update_progress( + auto_id, + log_line=f'"{pl.get("name", "")}" — {added} added, {removed} removed', + log_type='success', + ) + try: + if deps.engine: + deps.engine.emit('playlist_changed', { + 'playlist_name': pl.get('name', ''), + 'playlist_id': str(pl.get('id', '')), + 'old_count': str(len(old_ids)), + 'new_count': str(len(new_ids)), + 'added': str(added), + 'removed': str(removed), + }) + except Exception as e: + deps.logger.debug("playlist_synced automation emit failed: %s", e) + else: + deps.logger.warning( + f"[AUTOMATION] No changes: '{pl.get('name', '')}' (tracks={len(old_ids)})" + ) + deps.update_progress( + auto_id, + log_line=f'No changes: "{pl.get("name", "")}"', + log_type='skip', + ) + + return 1 diff --git a/core/automation_engine.py b/core/automation_engine.py index d19cbb58..c04c1760 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -617,7 +617,7 @@ class AutomationEngine: self._progress_finish_fn(automation_id, result) except Exception as e: logger.debug("scheduled progress finish (skipped): %s", e) - self._finish_run(auto, automation_id, result, error=None) + self._finish_run(auto, automation_id, result, error=None, retry_delay_seconds=300) return # Initialize progress tracking (skip if already done during delay) @@ -664,7 +664,7 @@ class AutomationEngine: self._finish_run(auto, automation_id, result, error) - def _finish_run(self, auto, automation_id, result, error): + def _finish_run(self, auto, automation_id, result, error, retry_delay_seconds=None): """Update DB with run stats and reschedule.""" next_run_str = None trigger_type = auto.get('trigger_type', '') @@ -672,7 +672,9 @@ class AutomationEngine: if trigger_type in self._trigger_handlers: try: trigger_config = json.loads(auto.get('trigger_config') or '{}') - if trigger_type == 'daily_time': + if retry_delay_seconds: + next_run_str = _utc_after(retry_delay_seconds) + elif trigger_type == 'daily_time': # Next run is tomorrow at the configured time (compute delay from local time, store as UTC) time_str = trigger_config.get('time', '00:00') hour, minute = map(int, time_str.split(':')) @@ -997,6 +999,8 @@ class AutomationEngine: """Send message via Telegram Bot API.""" bot_token = config.get('bot_token', '').strip() chat_id = config.get('chat_id', '').strip() + thread_id = config.get('thread_id', '').strip() + if not bot_token or not chat_id: raise ValueError("Bot token and chat ID are required for Telegram") @@ -1005,9 +1009,16 @@ class AutomationEngine: for key, value in variables.items(): message = message.replace('{' + key + '}', value) + payload = {"chat_id": chat_id, "text": message, "parse_mode": "HTML"} + if thread_id: + try: + payload["message_thread_id"] = int(thread_id) + except ValueError: + pass # invalid — fall back to main chat + resp = requests.post( f'https://api.telegram.org/bot{bot_token}/sendMessage', - json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"}, + json=payload, timeout=10, ) data = resp.json() if resp.status_code == 200 else {} diff --git a/core/discovery/manual_match.py b/core/discovery/manual_match.py new file mode 100644 index 00000000..8c56bbdf --- /dev/null +++ b/core/discovery/manual_match.py @@ -0,0 +1,70 @@ +"""Helpers for Fix-popup manual match persistence. + +When the user manually fixes a mirrored-playlist discovery via the Fix +popup, two questions land at the web_server route layer that are easier +to test in isolation: + +1. *Which metadata source did the manual match come from?* — the popup + cascade queries the user's primary source first, then Spotify / + Deezer / iTunes / MusicBrainz as fallbacks; each search endpoint + stamps `source` on its rows but the MBID-paste lookup uses a lean + flat shape that doesn't carry it. `derive_manual_match_provider` + collapses the fallback chain into a single string. + +2. *Should the discovery layer re-run for this track when the current + active provider differs from the cached one?* — re-running silently + overwrites the user's deliberate pick with whatever the auto-search + ranks first, so manual matches are exempt regardless of provider + drift. `is_drifted_for_redo` encapsulates the decision. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + + +def derive_manual_match_provider( + payload_track: Dict[str, Any], + active_provider: Optional[str], +) -> str: + """Return the provider string to stamp on a manually-fixed match. + + Resolution order: + 1. ``payload_track['source']`` — every *_search_tracks endpoint + sets this; the MBID-paste path doesn't. + 2. ``active_provider`` — what the user has configured as their + primary discovery source. + 3. ``'spotify'`` — last-ditch default matching the historic + hardcode (so behaviour is identical when both upstream + signals are absent). + """ + if not isinstance(payload_track, dict): + payload_track = {} + source = payload_track.get('source') + if source: + return source + if active_provider: + return active_provider + return 'spotify' + + +def is_drifted_for_redo( + extra_data: Optional[Dict[str, Any]], + active_provider: Optional[str], +) -> bool: + """Return True when a cached discovery entry should be treated as + stale because the user's active provider has changed since it was + cached AND the entry isn't a manual match. + + Manual matches are *always* considered fresh: re-running discovery + against the current source would overwrite the user's deliberate + pick with whatever auto-search ranks first. The first Playlist + Pipeline run after a manual fix used to clobber it for exactly + this reason — the check lives here now so it's pinned by tests. + """ + if not isinstance(extra_data, dict): + return False + if extra_data.get('manual_match'): + return False + cached_provider = extra_data.get('provider', 'spotify') + return cached_provider != active_provider diff --git a/core/discovery/matching.py b/core/discovery/matching.py new file mode 100644 index 00000000..4b89da4b --- /dev/null +++ b/core/discovery/matching.py @@ -0,0 +1,134 @@ +"""Pure helper for matching raw MusicBrainz-metadata tracks against +Spotify / iTunes. + +Used by the PlaylistSource adapters whose ``get_playlist`` returns +tracks with ``needs_discovery=True`` (ListenBrainz, Last.fm radio). +Phase 1b ships Strategy 1 only (matching-engine queries → search → +score → pick best ≥0.9). The richer multi-strategy + +discovery-cache flow stays in +``core.discovery.listenbrainz.run_listenbrainz_discovery_worker`` +for the Discover-page state-machine UI; this helper is the slimmer +version used by the auto-refresh pipeline. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Optional + +logger = logging.getLogger(__name__) + + +@dataclass +class MBMatchDeps: + """Bundle of primitives the matcher needs. + + Wired up at bootstrap. Tests pass stub callables / clients.""" + + matching_engine: Any + score_candidates: Callable[..., Any] + spotify_client_getter: Callable[[], Any] + itunes_client_getter: Callable[[], Any] + prefer_spotify_getter: Callable[[], bool] + min_confidence: float = 0.9 + + +def match_mb_track( + track: Dict[str, Any], deps: MBMatchDeps +) -> Optional[Dict[str, Any]]: + """Try to match a single MB-metadata track. + + Input shape: + ``{'track_name', 'artist_name', 'album_name', 'duration_ms'}`` + + Returns the matched_data dict (Spotify/iTunes track projection) + or ``None`` when no candidate cleared the confidence threshold. + """ + title = track.get("track_name") or "" + artist = track.get("artist_name") or "" + album = track.get("album_name") or "" + duration_ms = int(track.get("duration_ms") or 0) + if not title or not artist: + return None + + spotify_client = deps.spotify_client_getter() + itunes_client = deps.itunes_client_getter() + use_spotify = bool( + deps.prefer_spotify_getter() + and spotify_client is not None + and getattr(spotify_client, "is_spotify_authenticated", lambda: False)() + ) + if not use_spotify and itunes_client is None: + return None + + # Strategy 1 — matching-engine query generation. + try: + temp_track = type("_TempTrack", (), { + "name": title, + "artists": [artist], + "album": album or None, + })() + queries = deps.matching_engine.generate_download_queries(temp_track) + except Exception as exc: + logger.debug(f"matching_engine query-gen failed: {exc}") + queries = [f"{artist} {title}", title] + + best_match: Any = None + best_confidence = 0.0 + for query in queries: + try: + if use_spotify: + results = spotify_client.search_tracks(query, limit=10) + else: + results = itunes_client.search_tracks(query, limit=10) + except Exception as exc: + logger.debug(f"search failed for query={query!r}: {exc}") + continue + if not results: + continue + try: + match, confidence, _ = deps.score_candidates( + title, artist, duration_ms, results + ) + except Exception as exc: + logger.debug(f"score_candidates failed: {exc}") + continue + if match and confidence > best_confidence and confidence >= deps.min_confidence: + best_match = match + best_confidence = confidence + if best_confidence >= deps.min_confidence: + break + + if not best_match: + return None + + provider = "spotify" if use_spotify else "itunes" + image_url = getattr(best_match, "image_url", None) or "" + album_data: Dict[str, Any] = { + "name": getattr(best_match, "album", "") or "", + } + if image_url: + album_data["images"] = [{"url": image_url}] + return { + "id": getattr(best_match, "id", "") or "", + "name": getattr(best_match, "name", "") or "", + "artists": list(getattr(best_match, "artists", []) or []), + "album": album_data, + "duration_ms": int(getattr(best_match, "duration_ms", 0) or 0), + "image_url": image_url, + "source": provider, + "_provider": provider, + "_confidence": float(best_confidence), + } + + +def match_mb_tracks( + tracks: List[Dict[str, Any]], deps: MBMatchDeps +) -> List[Optional[Dict[str, Any]]]: + """Vectorized variant — runs ``match_mb_track`` per track. + + Phase 1b is sequential. If profiling shows it's too slow on big + LB playlists, this becomes the natural spot to thread-pool the + per-track searches.""" + return [match_mb_track(t, deps) for t in tracks] diff --git a/core/discovery/playlist.py b/core/discovery/playlist.py index 4bbbf1c7..f25e2999 100644 --- a/core/discovery/playlist.py +++ b/core/discovery/playlist.py @@ -125,6 +125,19 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD if existing_extra.get('wing_it_fallback'): # Wing It stub — always re-attempt to find a real match undiscovered_tracks.append(track) + elif existing_extra.get('manual_match'): + # User explicitly picked this match via the Fix popup. + # Manual fixes are authoritative: they may lack + # track_number / album.id / release_date (the Fix-popup + # save shape is intentionally lean — search-result rows + # don't include track_number, and the MBID-lookup flat + # shape doesn't carry album.id), but re-running discovery + # against the active source would overwrite the user's + # deliberate pick with whatever the auto-search ranks + # first. Skip — pipeline only re-discovers when the user + # has cleared the match. + pl_skipped += 1 + total_skipped += 1 else: # Check if matched_data is complete — old discoveries may be missing # track_number/release_date due to the Track dataclass stripping them. diff --git a/core/discovery/spotify_public.py b/core/discovery/spotify_public.py index 6df4732b..1fd86d1d 100644 --- a/core/discovery/spotify_public.py +++ b/core/discovery/spotify_public.py @@ -55,13 +55,17 @@ class SpotifyPublicDiscoveryDeps: search_spotify_for_tidal_track: Callable build_discovery_wing_it_stub: Callable add_activity_item: Callable + source_label: str = "Spotify Public" + activity_label: str = "Spotify Link" + original_track_key: str = "spotify_public_track" def run_spotify_public_discovery_worker(url_hash, deps: SpotifyPublicDiscoveryDeps): """Background worker for Spotify Public discovery process (Spotify preferred, iTunes fallback)""" _ew_state = {} try: - _ew_state = deps.pause_enrichment_workers('Spotify Public discovery') + worker_label = f"{deps.source_label} discovery" + _ew_state = deps.pause_enrichment_workers(worker_label) state = deps.spotify_public_discovery_states[url_hash] playlist = state['playlist'] @@ -74,7 +78,7 @@ def run_spotify_public_discovery_worker(url_hash, deps: SpotifyPublicDiscoveryDe if not use_spotify: itunes_client_instance = deps.get_metadata_fallback_client() - logger.info(f"Starting Spotify Public discovery for: {playlist['name']} (using {discovery_source.upper()})") + logger.info(f"Starting {deps.source_label} discovery for: {playlist['name']} (using {discovery_source.upper()})") # Store discovery source in state for frontend state['discovery_source'] = discovery_source @@ -126,7 +130,7 @@ def run_spotify_public_discovery_worker(url_hash, deps: SpotifyPublicDiscoveryDe cached_album = cached_album.get('name', '') result = { - 'spotify_public_track': { + deps.original_track_key: { 'id': track_id, 'name': track_name, 'artists': track_artists or [], @@ -170,7 +174,7 @@ def run_spotify_public_discovery_worker(url_hash, deps: SpotifyPublicDiscoveryDe # Create result entry result = { - 'spotify_public_track': { + deps.original_track_key: { 'id': track_id, 'name': track_name, 'artists': track_artists or [], @@ -270,7 +274,7 @@ def run_spotify_public_discovery_worker(url_hash, deps: SpotifyPublicDiscoveryDe # Auto Wing It fallback for unmatched tracks if result['status_class'] == 'not-found': - sp_t = result.get('spotify_public_track', {}) + sp_t = result.get(deps.original_track_key, {}) stub = deps.build_discovery_wing_it_stub( sp_t.get('name', ''), ', '.join(sp_t.get('artists', [])), @@ -299,7 +303,7 @@ def run_spotify_public_discovery_worker(url_hash, deps: SpotifyPublicDiscoveryDe logger.error(f"Error processing track {i+1}: {e}") # Add error result result = { - 'spotify_public_track': { + deps.original_track_key: { 'name': sp_track.get('name', 'Unknown'), 'artists': sp_track.get('artists', []), }, @@ -324,14 +328,14 @@ def run_spotify_public_discovery_worker(url_hash, deps: SpotifyPublicDiscoveryDe # Add activity for discovery completion source_label = discovery_source.upper() - deps.add_activity_item("", f"Spotify Link Discovery Complete ({source_label})", f"'{playlist['name']}' - {successful_discoveries}/{len(tracks)} tracks found", "Now") + deps.add_activity_item("", f"{deps.activity_label} Discovery Complete ({source_label})", f"'{playlist['name']}' - {successful_discoveries}/{len(tracks)} tracks found", "Now") - logger.info(f"Spotify Public discovery complete ({source_label}): {successful_discoveries}/{len(tracks)} tracks found") + logger.info(f"{deps.source_label} discovery complete ({source_label}): {successful_discoveries}/{len(tracks)} tracks found") except Exception as e: - logger.error(f"Error in Spotify Public discovery worker: {e}") + logger.error(f"Error in {deps.source_label} discovery worker: {e}") if url_hash in deps.spotify_public_discovery_states: deps.spotify_public_discovery_states[url_hash]['phase'] = 'error' deps.spotify_public_discovery_states[url_hash]['status'] = f'error: {str(e)}' finally: - deps.resume_enrichment_workers(_ew_state, 'Spotify Public discovery') + deps.resume_enrichment_workers(_ew_state, f"{deps.source_label} discovery") diff --git a/core/downloads/album_bundle_dispatch.py b/core/downloads/album_bundle_dispatch.py index 09ec1a46..734d35a9 100644 --- a/core/downloads/album_bundle_dispatch.py +++ b/core/downloads/album_bundle_dispatch.py @@ -179,6 +179,8 @@ def try_dispatch( 'phase': 'analysis', 'album_bundle_state': 'fallback', 'album_bundle_error': err, + 'album_bundle_private_staging': False, + 'album_bundle_staging_path': None, }) return False logger.error("[Album Bundle] %s flow failed for '%s': %s", diff --git a/core/downloads/candidates.py b/core/downloads/candidates.py index 52700ca9..dd85455a 100644 --- a/core/downloads/candidates.py +++ b/core/downloads/candidates.py @@ -84,6 +84,11 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None, if not task: return False used_sources = task.get('used_sources', set()) + # User-initiated manual picks (candidates modal) bypass quarantine + # gates downstream. The user already accepted the risk by choosing + # the file; we trust their selection over AcoustID disagreement so + # repeated manual picks don't loop back into quarantine. + user_manual_pick = bool(task.get('_user_manual_pick', False)) # Try each candidate until one succeeds (like GUI's fallback logic) for candidate_index, candidate in enumerate(candidates): @@ -331,6 +336,20 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None, "track_info": track_info, # Add track_info for playlist folder mode "_download_username": username, # Source username for AcoustID skip logic } + if user_manual_pick: + # The user explicitly picked this candidate via the + # candidates modal — trust their metadata judgement + # over AcoustID disagreement so manual picks don't + # loop back into quarantine. Integrity + bit-depth + # gates still run because those check the new file's + # actual condition, not its identity. + matched_downloads_context[context_key]['_skip_quarantine_check'] = 'acoustid' + matched_downloads_context[context_key]['_user_manual_pick'] = True + logger.info( + "[Context] User manual pick — bypassing AcoustID for " + "task=%s username=%s filename=%s", + task_id, username, os.path.basename(filename), + ) logger.info(f"[Context] Set is_album_download: {is_album_context} (has clean data: {has_clean_spotify_data})") logger.debug(f"[Debug] Context creation - track_info: {track_info is not None}, playlist_folder_mode: {track_info.get('_playlist_folder_mode', False) if track_info else False}") diff --git a/core/downloads/master.py b/core/downloads/master.py index 83842f17..f80d46b6 100644 --- a/core/downloads/master.py +++ b/core/downloads/master.py @@ -356,26 +356,6 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma if force_download_all: logger.warning(f"[Force Download] Force download mode enabled for batch {batch_id} - treating all tracks as missing") - # Album-bundle gate for torrent / usenet single-source mode. - # See ``core/downloads/album_bundle_dispatch`` for the full - # narrow-gate rationale. Returns True iff the master worker - # should stop (gate fired and failed); False = engaged-and- - # succeeded OR didn't engage, both fall through to per-track. - _bundle_state = _BatchStateAccessImpl() - _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 allow_duplicates = deps.config_manager.get('wishlist.allow_duplicate_tracks', True) @@ -671,6 +651,25 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma batch_playlist_folder_mode = batch.get('playlist_folder_mode', False) batch_playlist_name = batch.get('playlist_name', 'Unknown Playlist') + # Album-bundle sources download a whole release into private staging, + # then the normal per-track workers claim those staged files. Run this + # only after analysis has found missing tracks; otherwise an already + # owned album would still trigger a release download. + _bundle_state = _BatchStateAccessImpl() + _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 + # === ALBUM PRE-FLIGHT: Search for complete album folder before track-by-track === # Only run pre-flight when Soulseek is the download source (or hybrid with soulseek) preflight_source = None diff --git a/core/downloads/staging.py b/core/downloads/staging.py index d1d4e3c3..bd115cc6 100644 --- a/core/downloads/staging.py +++ b/core/downloads/staging.py @@ -78,6 +78,30 @@ def _extract_explicit_track_number(filename: str) -> int: return 0 +def _extract_release_filename_title(stem: str) -> str: + """Extract the trailing title segment from a release-style filename stem. + + Slskd album bundles often arrive untagged with stems like + 'Artist - Album - 03 - Title' or '03 - Title'. The full stem is too + noisy to fuzzy-match against a clean Spotify title, so when we + detect a bare track-number segment between ' - ' delimiters we + return the trailing segments as an extra match candidate. + + Returns '' when no clear track-number signal is present so we don't + accidentally extract tails from real song titles that legitimately + contain ' - ' (e.g. 'Hold Me - Live'). + """ + if not stem: + return '' + parts = [p.strip() for p in stem.split(' - ') if p.strip()] + if len(parts) < 2: + return '' + for i, part in enumerate(parts): + if re.fullmatch(r'\d{1,3}', part) and i < len(parts) - 1: + return ' - '.join(parts[i + 1:]).strip() + return '' + + def _staging_title_variants(title: Any, normalize: Callable[[str], str]) -> list[str]: """Return conservative title variants for release-file matching. @@ -112,8 +136,10 @@ def _staging_title_variants(title: Any, normalize: Callable[[str], str]) -> list flags=re.IGNORECASE, ) + release_tail = _extract_release_filename_title(compacted_separators) + variants: list[str] = [] - for candidate in (raw, compacted_separators, without_feat, without_bonus): + for candidate in (raw, compacted_separators, without_feat, without_bonus, release_tail): normalized = normalize(candidate) if normalized and normalized not in variants: variants.append(normalized) diff --git a/core/downloads/status.py b/core/downloads/status.py index 7f2230bd..41229e37 100644 --- a/core/downloads/status.py +++ b/core/downloads/status.py @@ -476,7 +476,16 @@ def build_single_batch_status(batch_id: str, deps: StatusDeps) -> tuple[Optional def build_batched_status(requested_batch_ids: list, deps: StatusDeps) -> dict: - """For /api/download_status/batch. Returns the full response dict (always 200).""" + """For /api/download_status/batch. Returns the full response dict (always 200). + + When a requested batch carries a ``wishlist_run_id`` (Phase 1c.2.1 + per-album split), the response merges in every sibling sub-batch + of the same run via ``merge_wishlist_run_status``. The merged view + lands keyed under the originally-requested ``batch_id`` so the + frontend modal (which polls one batch id) sees every sibling's + tasks + progress without needing to know about the split.""" + from core.downloads.wishlist_aggregator import merge_wishlist_run_status + live_transfers_lookup = deps.get_cached_transfer_data() response: dict[str, Any] = {"batches": {}} @@ -489,11 +498,49 @@ def build_batched_status(requested_batch_ids: list, deps: StatusDeps) -> dict: else: target_batches = download_batches.copy() + # Pre-index sibling batch ids by wishlist_run_id so the per- + # batch loop below can find them in O(1). Snapshot under the + # held lock; subsequent dict mutations don't matter for this + # build. + run_id_to_batch_ids: dict[str, list[str]] = {} + for bid, batch_row in download_batches.items(): + run_id = (batch_row or {}).get('wishlist_run_id') if isinstance(batch_row, dict) else None + if run_id: + run_id_to_batch_ids.setdefault(str(run_id), []).append(bid) + for batch_id, batch in target_batches.items(): try: - response["batches"][batch_id] = build_batch_status_data( + primary_status = build_batch_status_data( batch_id, batch, live_transfers_lookup, deps, ) + + # Wishlist-run merge — kicks in only when this batch + # has a run_id AND at least one sibling exists. Falls + # through to legacy single-batch shape otherwise. + run_id = batch.get('wishlist_run_id') if isinstance(batch, dict) else None + sibling_ids = run_id_to_batch_ids.get(str(run_id), []) if run_id else [] + if run_id and len(sibling_ids) > 1: + sibling_statuses = [] + for sib_id in sibling_ids: + if sib_id == batch_id: + continue + sib_batch = download_batches.get(sib_id) + if not isinstance(sib_batch, dict): + continue + try: + sibling_statuses.append( + build_batch_status_data( + sib_id, sib_batch, live_transfers_lookup, deps, + ) + ) + except Exception as sib_err: + logger.warning( + f"[Wishlist Run] Sibling status build failed for {sib_id}: {sib_err}" + ) + merged = merge_wishlist_run_status(primary_status, sibling_statuses) + response["batches"][batch_id] = merged + else: + response["batches"][batch_id] = primary_status except Exception as batch_error: logger.error(f"Error processing batch {batch_id}: {batch_error}") response["batches"][batch_id] = {"error": str(batch_error)} diff --git a/core/downloads/wishlist_aggregator.py b/core/downloads/wishlist_aggregator.py new file mode 100644 index 00000000..13092cc5 --- /dev/null +++ b/core/downloads/wishlist_aggregator.py @@ -0,0 +1,198 @@ +"""Merge sibling download_batches statuses into one view for the +wishlist-run model. + +When the wishlist runs are split into per-album sub-batches +(Phase 1c.2.1), the frontend modal polls the ORIGINAL batch id +allocated by ``start_manual_wishlist_download_batch`` / +``process_wishlist_automatically``. That batch id is now just one +sibling among N. Without merging, the modal goes blank after the +first sibling finishes because subsequent siblings live under +fresh batch ids the modal never learned about. + +This module is the merge layer: pure function, no IO, no runtime +state. ``build_batched_status`` in ``core/downloads/status.py`` +calls into it when a requested batch has ``wishlist_run_id`` set +and at least one sibling exists. + +Design notes: + +- ``track_index`` re-indexed to a global 0..N-1 across the merged + results so the modal's ``data-track-index`` DOM keys don't + collide between siblings (each sibling locally starts at 0). + Tasks reference their analysis result via track_index, so the + remap is applied to tasks too. +- ``task_id`` is a uuid per task — no collision concern across + siblings. +- Phase aggregation surfaces the MOST advanced live phase so the + modal renders task rows the moment any sibling reaches the task + stage. The earlier "least-complete" rule made the modal appear + frozen during parallel album_downloading because the task table + is phase-gated to ``downloading``/``complete``/``error``. Sticky + ``error`` so failures don't get hidden by a running sibling. +- ``album_bundle`` is picked from whichever sibling currently has + an active bundle download — gives the user a useful progress + bar even when the primary sibling is past its bundle stage. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + + +_ACTIVE_BUNDLE_STATES = frozenset({ + 'searching', + 'downloading', + 'downloading_release', + 'staging', +}) + + +def _aggregate_phases(phases: List[str]) -> str: + """Pick the merged phase for a multi-sibling wishlist run. + + Surfaces the MOST advanced live phase across the run so the + modal renders task progress the moment any sibling reaches + the task stage. Earlier ("least complete") aggregation hid + downloading tasks behind the bundle progress UI when any + sibling was still in ``album_downloading``, so the modal + appeared frozen for the entire duration of the slowest + sibling's bundle phase. + + Rules: + + - ``error`` is sticky — if any sibling errored, surface error + so the user notices the failure even mid-run. + - ``downloading``/``complete`` (the phases that produce a + task table on the modal side) WIN over earlier phases. + Any sibling at this stage means "show tasks now". All- + complete returns ``complete``; mixed complete + downloading + stays ``downloading`` so the modal keeps polling. + - ``album_downloading`` wins over ``analysis`` only when no + sibling has reached the task stage. + - ``analysis`` is the last resort. + """ + phases = [p for p in phases if p] + if not phases: + return 'unknown' + if 'error' in phases: + return 'error' + if all(p == 'complete' for p in phases): + return 'complete' + if any(p in ('downloading', 'complete') for p in phases): + return 'downloading' + if 'album_downloading' in phases: + return 'album_downloading' + if 'analysis' in phases: + return 'analysis' + return phases[0] + + +def _pick_active_album_bundle(statuses: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """Return the album_bundle of whichever sibling is currently + staging or downloading. Falls back to the first non-empty + bundle when nothing is active (so a completed bundle still + shows up vs. a totally empty progress bar).""" + fallback = None + for s in statuses: + bundle = s.get('album_bundle') + if not bundle: + continue + if fallback is None: + fallback = bundle + state = (bundle.get('state') or '').lower() + if state in _ACTIVE_BUNDLE_STATES: + return bundle + return fallback + + +def merge_wishlist_run_status( + primary: Dict[str, Any], + siblings: List[Dict[str, Any]], +) -> Dict[str, Any]: + """Return a status dict that merges ``siblings`` into ``primary``. + + Empty ``siblings`` is the legacy single-batch case — primary + is returned unchanged. + + The returned dict has the same shape as a single-batch status + response from ``build_batch_status_data`` so the frontend + modal needs no changes to consume it. Tracks and tasks are + re-indexed globally; phase + progress + active_count + aggregated across the run. + """ + if not siblings: + return primary + + all_statuses = [primary] + list(siblings) + + # Phase aggregation. + merged_phase = _aggregate_phases([s.get('phase', '') for s in all_statuses]) + + # Analysis progress — sum across siblings. + total = 0 + processed = 0 + has_progress = False + for s in all_statuses: + ap = s.get('analysis_progress') + if isinstance(ap, dict): + total += int(ap.get('total') or 0) + processed += int(ap.get('processed') or 0) + has_progress = True + + # Analysis results — concat + re-index. Build a (batch_obj_id, + # old_track_index) -> new_track_index map so tasks can be + # re-indexed consistently. + merged_results: List[Dict[str, Any]] = [] + track_index_remap: Dict[tuple, int] = {} + next_index = 0 + for s in all_statuses: + batch_ref = id(s) + for r in (s.get('analysis_results') or []): + old_idx = int(r.get('track_index') or 0) + track_index_remap[(batch_ref, old_idx)] = next_index + new_r = dict(r) + new_r['track_index'] = next_index + merged_results.append(new_r) + next_index += 1 + + # Tasks — concat + re-index using the remap above. Tasks + # without a remapped entry keep their original track_index + # (defensive — shouldn't happen if analysis_results is + # consistent with the task list). + merged_tasks: List[Dict[str, Any]] = [] + for s in all_statuses: + batch_ref = id(s) + for t in (s.get('tasks') or []): + old_idx = int(t.get('track_index') or 0) + new_t = dict(t) + new_t['track_index'] = track_index_remap.get((batch_ref, old_idx), old_idx) + merged_tasks.append(new_t) + merged_tasks.sort(key=lambda x: x.get('track_index', 0)) + + # Album bundle — pick the active sibling's, fall back to first + # bundle present, omit if none. + merged_bundle = _pick_active_album_bundle(all_statuses) + + # Worker accounting — sum active_count across siblings so the + # modal's overall download progress display reflects total + # in-flight work; max_concurrent stays from primary as + # representative. + active_total = sum(int(s.get('active_count') or 0) for s in all_statuses) + + merged = dict(primary) # keeps playlist_id, playlist_name, error, etc. + merged['phase'] = merged_phase + if has_progress: + merged['analysis_progress'] = {'total': total, 'processed': processed} + merged['analysis_results'] = merged_results + if merged_tasks or 'tasks' in primary: + merged['tasks'] = merged_tasks + if merged_bundle: + merged['album_bundle'] = merged_bundle + elif 'album_bundle' in primary: + merged['album_bundle'] = primary['album_bundle'] + merged['active_count'] = active_total + + return merged + + +__all__ = ['merge_wishlist_run_status'] diff --git a/core/downloads/wishlist_failed.py b/core/downloads/wishlist_failed.py index 1b1f30bb..d99750da 100644 --- a/core/downloads/wishlist_failed.py +++ b/core/downloads/wishlist_failed.py @@ -19,6 +19,7 @@ from core.wishlist.processing import ( build_wishlist_source_context as _build_wishlist_source_context, recover_uncaptured_failed_tracks as _recover_uncaptured_failed_tracks, remove_completed_tracks_from_wishlist as _remove_completed_tracks_from_wishlist, + resolve_wishlist_source_type_for_batch as _resolve_wishlist_source_type_for_batch, ) from core.wishlist.resolution import ( check_and_remove_from_wishlist as _check_and_remove_from_wishlist, @@ -107,10 +108,11 @@ def _process_failed_tracks_to_wishlist_exact(batch_id): if permanently_failed_tracks: try: wishlist_service = get_wishlist_service() - + # Create source_context identical to sync.py source_context = _build_wishlist_source_context(batch) - + batch_source_type = _resolve_wishlist_source_type_for_batch(batch) + # Process each failed track (matching sync.py's loop) with safety limit max_failed_tracks = min(len(permanently_failed_tracks), 50) # Safety limit wing_it_skipped = 0 @@ -129,10 +131,10 @@ def _process_failed_tracks_to_wishlist_exact(batch_id): continue logger.error(f"[Wishlist Processing] Adding track {i+1}/{max_failed_tracks}: {track_name}") - + success = wishlist_service.add_failed_track_from_modal( track_info=failed_track_info, - source_type='playlist', + source_type=batch_source_type, source_context=source_context, profile_id=batch.get('profile_id', 1) ) diff --git a/core/imports/filename.py b/core/imports/filename.py index 92ff026a..7fa9c420 100644 --- a/core/imports/filename.py +++ b/core/imports/filename.py @@ -15,8 +15,32 @@ _TRACK_PATTERNS = ( def extract_track_number_from_filename(filename: str, title: str = None) -> int: - """Extract track number from a filename. Returns 1 if not found.""" - basename = os.path.splitext(os.path.basename(filename))[0].strip() + """Extract track number from a filename. Returns 1 if not found. + + Use ``extract_explicit_track_number`` instead when the caller needs + to distinguish "track 1" from "unknown" — staging-file readers in + particular MUST NOT conflate a bare title (no numeric prefix) with + track 1, or every untagged album-bundle file gets imported as + ``track_number=1`` and downstream callers can't recover the real + number from authoritative metadata (Spotify track list, etc.). + """ + num = extract_explicit_track_number(filename) + return num if num > 0 else 1 + + +def extract_explicit_track_number(filename: str) -> int: + """Extract a track number only when the filename visibly carries one. + + Returns the parsed track number when the basename starts with a + recognizable numeric prefix (``"01 - Title"``, ``"1-03 Title"``, + ``"(01) Title"``, ``"[01] Title"``); returns ``0`` when no such + prefix is present. This is the contract staging readers want — + "unknown" must stay unknown so a downstream consumer with better + info (Spotify metadata, MusicBrainz, etc.) can fill it in. + """ + basename = os.path.splitext(os.path.basename(str(filename or "")))[0].strip() + if not basename: + return 0 match = re.match(r"^\d[\-\.](\d{1,2})\s*[\-\.]\s*", basename) if match: @@ -30,7 +54,7 @@ def extract_track_number_from_filename(filename: str, title: str = None) -> int: if 1 <= num <= 999: return num - return 1 + return 0 def parse_filename_metadata(filename: str) -> Dict[str, Any]: diff --git a/core/imports/staging.py b/core/imports/staging.py index 748cbb11..76a42beb 100644 --- a/core/imports/staging.py +++ b/core/imports/staging.py @@ -7,7 +7,10 @@ import threading from typing import Any, Dict, Iterable, List, Optional, Tuple from core.imports.paths import docker_resolve_path -from core.imports.filename import extract_track_number_from_filename +from core.imports.filename import ( + extract_explicit_track_number, + extract_track_number_from_filename, +) from utils.logging_config import get_logger logger = get_logger("imports.staging") @@ -103,12 +106,19 @@ def read_staging_file_metadata(file_path: str, filename: Optional[str] = None) - if not albumartist: albumartist = artist - track_number = extract_track_number_from_filename(filename or file_path) + # Use the strict extractor here: when the filename has no visible + # track-number prefix, return 0 instead of pretending it's track 1. + # Downstream consumers (staging match in core/downloads/staging.py) + # will then fall through to authoritative metadata (track_info from + # the original Spotify / API source) rather than locking the import + # to track_number=1 for every file in the bundle. + track_number = extract_explicit_track_number(filename or file_path) try: - # Preserve tag-based numbers when present, but still fall back to the filename parser. tag_track_number = _first_tag("tracknumber", "track_number") if tag_track_number: - track_number = int(str(tag_track_number).split("/")[0].strip() or track_number) + parsed_tag = int(str(tag_track_number).split("/")[0].strip()) + if parsed_tag > 0: + track_number = parsed_tag except (TypeError, ValueError): pass diff --git a/core/listenbrainz_manager.py b/core/listenbrainz_manager.py index 914a85ce..efa16158 100644 --- a/core/listenbrainz_manager.py +++ b/core/listenbrainz_manager.py @@ -166,6 +166,12 @@ class ListenBrainzManager: # Skip if track count hasn't changed (playlist content likely the same) if db_track_count == track_count: logger.debug(f"Playlist '{title}' unchanged, skipping") + # Even on the skip path, make sure the rolling-series + # mirror placeholder exists — otherwise users whose LB + # cache never has "changed" updates would never see the + # rolling Auto-Sync entries appear. + self._ensure_rolling_series_mirror(cursor, title) + conn.commit() conn.close() return "skipped" @@ -209,11 +215,82 @@ class ListenBrainzManager: if tracks: self._cache_tracks(playlist_id, playlist_mbid, tracks, cursor) + # Ensure a rolling-series mirror row exists for known LB series + # (Weekly Jams / Weekly Exploration / Top Discoveries / Top + # Missed Recordings). The Auto-Sync sidebar then surfaces the + # rolling entry as schedulable even before the user has + # explicitly discovered any per-period card — first scheduled + # refresh fills tracks via the LB adapter's synthetic-id + # resolution. + self._ensure_rolling_series_mirror(cursor, title) + conn.commit() conn.close() return result_type + def _ensure_rolling_mirrors_from_cache(self, cursor): + """Walk every cached LB playlist row + ensure its rolling + series mirror exists. Catch-all that runs regardless of which + ``_update_playlist`` paths fired (skipped vs updated vs new). + + Cheap — one SELECT + per-row helper call, helper is + idempotent INSERT OR IGNORE.""" + try: + cursor.execute( + """ + SELECT DISTINCT title FROM listenbrainz_playlists + WHERE profile_id = ? + """, + (self.profile_id,), + ) + titles = [row[0] for row in cursor.fetchall() if row[0]] + for title in titles: + self._ensure_rolling_series_mirror(cursor, title) + except Exception as exc: + logger.debug(f"Bulk rolling-mirror ensure skipped: {exc}") + + def _ensure_rolling_series_mirror(self, cursor, playlist_title: str): + """Upsert a placeholder ``mirrored_playlists`` row for the + rolling series this title belongs to. + + Idempotent — uses ``INSERT OR IGNORE``, so existing rolling + mirrors (which may already have discovered tracks) are not + touched. No-op for non-series titles (Last.fm radios, + user-created playlists, collaborative playlists).""" + try: + # Defer import to avoid a top-level dependency loop — the + # series detector lives in core.playlists which itself + # transitively imports manager-flavor helpers. + from core.playlists.lb_series import detect_series + match = detect_series(playlist_title or "") + if match is None: + return + cursor.execute( + """ + INSERT OR IGNORE INTO mirrored_playlists + (source, source_playlist_id, name, description, owner, image_url, track_count, profile_id, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, + ( + match.source_for_mirror, + match.series_id, + match.canonical_name, + "Rolling ListenBrainz series — refresh resolves to the latest period automatically.", + "ListenBrainz", + "", + 0, + self.profile_id, + ), + ) + if cursor.rowcount: + logger.info( + f"Pre-created rolling mirror placeholder '{match.canonical_name}' " + f"(series id: {match.series_id})" + ) + except Exception as exc: + logger.debug(f"Rolling-series mirror ensure skipped: {exc}") + def _cache_tracks(self, playlist_id: int, playlist_mbid: str, tracks: List[Dict], cursor): """ Cache tracks for a playlist, including fetching cover art URLs in parallel @@ -326,31 +403,131 @@ class ListenBrainzManager: covers_found = sum(1 for t in track_data_list if t.get('album_cover_url')) logger.info(f"Fetched {covers_found}/{len(track_data_list)} cover art URLs") + def _cleanup_per_period_series_mirrors(self, cursor): + """Delete mirrored_playlists rows that belong to a rotating LB + series but were created under the per-period MBID instead of + the new synthetic series id. + + Background: pre-Phase-1c.2.1 the auto-mirror hook keyed mirrors + by the per-week (or per-year) MBID, so users accumulated one + mirror per period. The new flow collapses them into a single + rolling mirror per series. This sweeper removes the legacy + per-period rows so the Mirrored / Auto-Sync UIs only show the + consolidated rolling mirror. Idempotent — only matches titles + that were once per-period.""" + # Each pattern's WHERE clause matches per-period titles + # ("Weekly Jams for X, week of YYYY-MM-DD ...") but NOT the + # canonical rolling-mirror titles ("ListenBrainz Weekly Jams"). + per_period_title_patterns = [ + ('listenbrainz', 'Weekly Jams for %, week of %'), + ('listenbrainz', 'Weekly Exploration for %, week of %'), + ('listenbrainz', 'Top Discoveries of % for %'), + ('listenbrainz', 'Top Missed Recordings of % for %'), + ] + try: + total = 0 + for source, like in per_period_title_patterns: + cursor.execute( + """ + SELECT id FROM mirrored_playlists + WHERE source = ? AND name LIKE ? + """, + (source, like), + ) + mirror_ids = [row[0] for row in cursor.fetchall()] + if not mirror_ids: + continue + ph = ','.join('?' * len(mirror_ids)) + cursor.execute( + f"DELETE FROM mirrored_playlist_tracks WHERE playlist_id IN ({ph})", + mirror_ids, + ) + cursor.execute( + f"DELETE FROM mirrored_playlists WHERE id IN ({ph})", + mirror_ids, + ) + total += len(mirror_ids) + if total: + logger.info( + f"Removed {total} legacy per-period LB series mirrors " + "(consolidated into rolling series mirrors)" + ) + except Exception as exc: + logger.debug(f"Per-period series mirror cleanup skipped: {exc}") + + def _retag_misrouted_lastfm_radio_mirrors(self, cursor): + """Re-tag mirrored_playlists rows that should be 'lastfm' but + were inserted as 'listenbrainz'. + + Backfill for the Phase 1c.1 bug where the auto-mirror helper + hardcoded ``source='listenbrainz'`` regardless of playlist + origin. Last.fm Radio playlists carry a consistent + "Last.fm Radio: " title prefix from + ``save_lastfm_radio_playlist``, so any mirror row matching + that prefix should sit under the Last.fm group instead of + the ListenBrainz one. Idempotent — only updates rows that + are still misrouted.""" + try: + cursor.execute( + """ + UPDATE mirrored_playlists + SET source = 'lastfm' + WHERE source = 'listenbrainz' + AND name LIKE 'Last.fm Radio:%' + """ + ) + if cursor.rowcount: + logger.info( + f"Re-tagged {cursor.rowcount} Last.fm Radio mirror rows " + "from source='listenbrainz' to source='lastfm'" + ) + except Exception as exc: + logger.debug(f"Last.fm radio mirror retag skipped: {exc}") + def _cleanup_old_playlists(self): """Remove old playlists, keeping only the 25 most recent per type""" conn = self._get_db_connection() cursor = conn.cursor() - # For each playlist type, keep only the N most recent - # lastfm_radio keeps fewer since they're auto-regenerated weekly + # One-shot backfill for legacy misrouting (see method docstring). + self._retag_misrouted_lastfm_radio_mirrors(cursor) + # Consolidate legacy per-week / per-year LB series mirrors into + # the new rolling series mirrors (Phase 1c.2.1). + self._cleanup_per_period_series_mirrors(cursor) + # Safety net: ensure rolling mirror placeholders exist for every + # series with at least one cached LB playlist row. Catches the + # case where every ``_update_playlist`` call took the "skipped" + # short-circuit (unchanged track count) and so the ensure-hook + # in the per-playlist path never fired on first run after the + # rolling feature shipped. + self._ensure_rolling_mirrors_from_cache(cursor) + + # For each playlist type, keep only the N most recent. + # Last.fm radios are per-seed-track snapshots that don't update + # on the Last.fm side — capping the cache (and via the cascade + # below, the matching mirror rows) keeps the Mirrored tab from + # accumulating one row per random seed track the user ever + # picked. 10 is the user-facing limit. playlist_type_limits = { 'created_for': 25, 'user': 25, 'collaborative': 25, - 'lastfm_radio': 5, + 'lastfm_radio': 10, } for playlist_type, keep_count in playlist_type_limits.items(): try: # Get IDs of playlists to delete (all except keep_count most recent) cursor.execute(""" - SELECT id FROM listenbrainz_playlists + SELECT id, playlist_mbid FROM listenbrainz_playlists WHERE playlist_type = ? AND profile_id = ? ORDER BY last_updated DESC LIMIT -1 OFFSET ? """, (playlist_type, self.profile_id, keep_count)) - old_playlist_ids = [row[0] for row in cursor.fetchall()] + stale_rows = cursor.fetchall() + old_playlist_ids = [row[0] for row in stale_rows] + old_mbids = [row[1] for row in stale_rows if row[1]] if old_playlist_ids: # Delete tracks for old playlists @@ -362,12 +539,66 @@ class ListenBrainzManager: logger.info(f"Removed {len(old_playlist_ids)} old {playlist_type} playlists") + # Cascade delete: matching mirrored_playlists rows go too. + # LB Weekly Jams / Weekly Exploration get new MBIDs every + # week — without this, the user accumulates dead mirror + # rows that point at LB playlists the cache already pruned. + # Downloaded tracks stay in the library; only the mirror + # row + its track refs are removed. + if old_mbids: + mirror_source = ( + 'lastfm' if playlist_type == 'lastfm_radio' else 'listenbrainz' + ) + self._cascade_delete_mirrored_for_mbids(cursor, old_mbids, mirror_source) + except Exception as e: logger.error(f"Error cleaning up {playlist_type} playlists: {e}") conn.commit() conn.close() + def _cascade_delete_mirrored_for_mbids(self, cursor, mbids, source): + """Delete mirrored_playlists rows whose source_playlist_id matches + any of ``mbids`` for this profile + source. + + Runs on the same cursor as the caller so the cleanup lands in + the same transaction. Silent on failure (cleanup is best-effort + — losing the cache-prune-mirror link in rare edge cases is + preferable to crashing the LB update loop).""" + if not mbids: + return + try: + placeholders = ','.join('?' * len(mbids)) + # Find matching mirror IDs first so we can delete tracks + + # row in two well-defined steps. ``mirrored_playlist_tracks`` + # has no ON DELETE CASCADE constraint enforced unless PRAGMA + # foreign_keys is on, so do it explicitly. + cursor.execute( + f""" + SELECT id FROM mirrored_playlists + WHERE source = ? AND profile_id = ? + AND source_playlist_id IN ({placeholders}) + """, + (source, self.profile_id, *mbids), + ) + mirror_ids = [row[0] for row in cursor.fetchall()] + if not mirror_ids: + return + mid_ph = ','.join('?' * len(mirror_ids)) + cursor.execute( + f"DELETE FROM mirrored_playlist_tracks WHERE playlist_id IN ({mid_ph})", + mirror_ids, + ) + cursor.execute( + f"DELETE FROM mirrored_playlists WHERE id IN ({mid_ph})", + mirror_ids, + ) + logger.info( + f"Cascade-removed {len(mirror_ids)} stale {source} mirrored playlists" + ) + except Exception as exc: + logger.warning(f"Cascade delete of mirrored {source} rows failed: {exc}") + def save_lastfm_radio_playlist(self, seed_track: str, seed_artist: str, similar_tracks: List[Dict]) -> str: """ Persist a Last.fm similar-tracks playlist to the DB under playlist_type='lastfm_radio'. @@ -500,6 +731,21 @@ class ListenBrainzManager: """Delete a cached playlist and its tracks (CASCADE handles tracks via FK)""" conn = self._get_db_connection() cursor = conn.cursor() + + # Figure out the source flavor before deleting the row — the + # cascade below needs to know whether the matching mirror is + # ``source='listenbrainz'`` or ``source='lastfm'``. + playlist_type = '' + try: + cursor.execute( + "SELECT playlist_type FROM listenbrainz_playlists WHERE playlist_mbid = ? AND profile_id = ?", + (playlist_mbid, self.profile_id), + ) + row = cursor.fetchone() + playlist_type = row[0] if row else '' + except Exception: # noqa: S110 — best-effort lookup, delete proceeds either way + pass + # Delete tracks first (SQLite FK CASCADE requires PRAGMA foreign_keys=ON) cursor.execute(""" DELETE FROM listenbrainz_tracks WHERE playlist_id IN ( @@ -510,6 +756,12 @@ class ListenBrainzManager: "DELETE FROM listenbrainz_playlists WHERE playlist_mbid = ? AND profile_id = ?", (playlist_mbid, self.profile_id) ) + + # Cascade the delete into mirrored_playlists so the user's + # Mirrored tab doesn't accumulate dead LB rows. + mirror_source = 'lastfm' if playlist_type == 'lastfm_radio' else 'listenbrainz' + self._cascade_delete_mirrored_for_mbids(cursor, [playlist_mbid], mirror_source) + conn.commit() conn.close() diff --git a/core/metadata/relevance.py b/core/metadata/relevance.py index 37aed5e0..43ab430b 100644 --- a/core/metadata/relevance.py +++ b/core/metadata/relevance.py @@ -295,6 +295,7 @@ def rerank_tracks( *, expected_title: str, expected_artist: str, + prefer_known_duration: bool = False, ) -> List[Track]: """Return a copy of ``tracks`` sorted by descending relevance score against the expected title + artist. @@ -304,6 +305,22 @@ def rerank_tracks( fallback when two candidates score identically — the source's popularity signal is still useful as a tiebreak). + ``prefer_known_duration``: when True, recordings with non-zero + ``duration_ms`` get a score boost. Used for MusicBrainz, which + often has several recordings per song (single edition, album + edition, compilations, remasters) where some carry length data + and some don't. The boost is set above the album_type weight + spread so length-known recordings can beat length-less + siblings even when the sibling sits on a higher-weighted + album-type — real case: Zeds Dead "Coffee Break" canonical + recording lives on the Single release (album_type='single', + weight 0.85) while a length-less sibling lives on an Album + release (weight 1.0). Without the boost, the length-less album + edition wins and the user sees 0:00 instead of 3:04. Cover / + karaoke penalties dominate the boost (their penalty is 0.05) + so a length-known tribute still loses to a length-less + canonical match. + No-op when both ``expected_title`` and ``expected_artist`` are empty (no signal to rank against — return input order).""" if not expected_title and not expected_artist: @@ -312,6 +329,17 @@ def rerank_tracks( (score_track(t, expected_title=expected_title, expected_artist=expected_artist), idx, t) for idx, t in enumerate(tracks) ] + if prefer_known_duration: + # Multiplier sized above the album-type weight spread (album 1.0 + # vs single 0.85 = ~18%) so length-known recordings can overcome + # the album-vs-single penalty when scores would otherwise tie on + # title + artist match. Penalty multipliers (cover/karaoke=0.05, + # variant=0.85) still dominate, so this only flips order among + # close-relevance siblings — exactly the MB-duplicate case. + scored = [ + (score * 1.25 if (t.duration_ms or 0) > 0 else score, idx, t) + for score, idx, t in scored + ] # Sort by score desc; idx asc as tiebreaker preserves stable order. scored.sort(key=lambda x: (-x[0], x[1])) return [t for _score, _idx, t in scored] diff --git a/core/musicbrainz_search.py b/core/musicbrainz_search.py index 47e95a84..8e7bf9bd 100644 --- a/core/musicbrainz_search.py +++ b/core/musicbrainz_search.py @@ -757,19 +757,43 @@ class MusicBrainzSearchClient: `search_tracks`'s structured-query dispatch (`Artist - Track` splitting, bare-name artist-first browse). - Uses bare-query mode (`strict=False`) — diacritic-folded, hits - alias/sortname indexes, no `AND`-clause that kills recall when - either side mis-matches. Score floor lowered to 20 (vs the search - tab's 80) so MB recordings whose title doesn't literally contain - the artist name still enter the candidate pool — the endpoint's - `rerank_tracks` pass then sorts by artist-match relevance. Without - this, queries like `Army of Me` + `Bjork` only surface covers - (score 73-100) and miss Björk's canonical recording (score 28). + When both fields are present: strict-first, bare-as-fallback. The + strict pass builds a field-scoped Lucene query (`recording:"" + AND artist:""`) which anchors the artist and prunes title- + collision covers — fixes the "Coffee Break" + "Zeds Dead" case + where MB's title-text-biased scorer surfaced Emapea / Vidalias / + West One Orchestra ahead of the canonical Zeds Dead recording. + `min_score=0` on strict because the field-scoped query is itself + precise, and the endpoint's `rerank_tracks` pass does the final + ordering. Bare query runs only when strict returns nothing — + catches diacritic / alias mismatches (`Bjork` query vs canonical + `Björk` artist) where strict phrase match never hits. + + When only one field is present: bare-query mode directly — same + recall-over-precision tradeoff the old single-path took. + + Callers wanting MB-specific length-preference ordering (multi- + edition recordings where some lack length data) should pass + ``prefer_known_duration=True`` to ``rerank_tracks`` downstream + — a stable sort here would just be re-sorted away by the rerank + pass anyway. """ if not track and not artist: return [] - return self._search_tracks_text(track, artist or None, limit, - strict=False, min_score=20) + + if track and artist: + results = self._search_tracks_text( + track, artist, limit, strict=True, min_score=0 + ) + if not results: + results = self._search_tracks_text( + track, artist, limit, strict=False, min_score=20 + ) + return results + + return self._search_tracks_text( + track, artist or None, limit, strict=False, min_score=20 + ) def _pick_representative_release(self, releases: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: """Pick the best release out of a release-group's editions. diff --git a/core/navidrome_client.py b/core/navidrome_client.py index b50582af..7ec9d286 100644 --- a/core/navidrome_client.py +++ b/core/navidrome_client.py @@ -67,6 +67,7 @@ class NavidromeAlbum: self.year = navidrome_data.get('year') self.addedAt = self._parse_date(navidrome_data.get('created')) self._artist_id = navidrome_data.get('artistId', '') + self.thumb = self._get_album_image_url() def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]: if not date_str: @@ -76,6 +77,18 @@ class NavidromeAlbum: except: return None + def _get_album_image_url(self) -> Optional[str]: + """Generate a Subsonic getCoverArt URL for this album. + + Navidrome exposes the stable artwork key as ``coverArt``. Falling + back to the album ID keeps compatibility with older responses while + ensuring library refreshes do not mark albums as artless. + """ + cover_id = self._data.get('coverArt') or self.ratingKey + if not cover_id: + return None + return f"/rest/getCoverArt?id={cover_id}" + def artist(self) -> Optional[NavidromeArtist]: """Get the album artist""" if self._artist_id: @@ -1242,4 +1255,4 @@ class NavidromeClient(MediaServerClient): except Exception as e: logger.error(f"Error searching for tracks: {e}") - return [] \ No newline at end of file + return [] diff --git a/core/playlists/lb_series.py b/core/playlists/lb_series.py new file mode 100644 index 00000000..611cf16b --- /dev/null +++ b/core/playlists/lb_series.py @@ -0,0 +1,125 @@ +"""ListenBrainz series detection for rolling mirrored playlists. + +ListenBrainz publishes a few playlist families that get a brand new +MBID every period (week or year) — e.g. "Weekly Jams for Nezreka, +week of 2026-05-25 Mon" gets a fresh row each Monday, the previous +Monday's row rotates out of the cache after ~25 weeks. Auto-syncing +the per-period MBID is useless because the underlying ListenBrainz +playlist never updates — only the new period gets new tracks. + +This module lets the auto-mirror code collapse those families into +a single rolling mirror per series. The mirror's +``source_playlist_id`` is a synthetic identifier (e.g. +``lb_weekly_jams_Nezreka``) instead of the per-period MBID, and the +refresh path resolves the synthetic id back to the latest period's +cached playlist at refresh time. + +One-off playlists (user-created, collaborative, Last.fm radios) are +NOT collapsed — they have stable identifiers in their own right. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import List, Optional + + +@dataclass(frozen=True) +class SeriesMatch: + """A playlist whose title matches one of the rotating series.""" + + series_id: str # synthetic id, e.g. "lb_weekly_jams_Nezreka" + canonical_name: str # display name for the rolling mirror + source_for_mirror: str # "listenbrainz" or "lastfm" + title_pattern: str # SQL LIKE pattern for finding members + # (e.g. "Weekly Jams for Nezreka, week of %") + + +# Each series is identified by a regex + a template for the +# canonical mirror name + the source field the resulting mirror +# should sit under. ``user`` is the ListenBrainz username. +_SERIES_PATTERNS = [ + { + "regex": re.compile(r"^Weekly Jams for (?P.+?), week of "), + "series_format": "lb_weekly_jams_{user}", + "canonical_name": "ListenBrainz Weekly Jams", + "source": "listenbrainz", + "like_format": "Weekly Jams for {user}, week of %", + }, + { + "regex": re.compile(r"^Weekly Exploration for (?P.+?), week of "), + "series_format": "lb_weekly_exploration_{user}", + "canonical_name": "ListenBrainz Weekly Exploration", + "source": "listenbrainz", + "like_format": "Weekly Exploration for {user}, week of %", + }, + { + "regex": re.compile(r"^Top Discoveries of (?P\d{4}) for (?P.+)$"), + "series_format": "lb_top_discoveries_{user}", + "canonical_name": "ListenBrainz Top Discoveries (latest year)", + "source": "listenbrainz", + # ``$`` end-anchor on the year means trailing whitespace would + # break the LIKE — but ListenBrainz titles don't have trailing + # whitespace; the % covers the year position. + "like_format": "Top Discoveries of % for {user}", + }, + { + "regex": re.compile(r"^Top Missed Recordings of (?P\d{4}) for (?P.+)$"), + "series_format": "lb_top_missed_{user}", + "canonical_name": "ListenBrainz Top Missed Recordings (latest year)", + "source": "listenbrainz", + "like_format": "Top Missed Recordings of % for {user}", + }, +] + + +def detect_series(title: str) -> Optional[SeriesMatch]: + """Return a ``SeriesMatch`` if ``title`` belongs to a known series, + else ``None``. + + ``title`` is the raw playlist title as stored on the LB cache row + (e.g. ``"Weekly Jams for Nezreka, week of 2026-05-25 Mon"``). + """ + if not title: + return None + for spec in _SERIES_PATTERNS: + m = spec["regex"].match(title) + if not m: + continue + groups = m.groupdict() + # The pattern only ever captures ``user`` (and optionally + # ``year``); ``series_format`` / ``like_format`` reference + # ``user`` so both interpolate cleanly with .format(**groups). + return SeriesMatch( + series_id=spec["series_format"].format(**groups), + canonical_name=spec["canonical_name"], + source_for_mirror=spec["source"], + title_pattern=spec["like_format"].format(**groups), + ) + return None + + +def list_series_synthetic_ids() -> List[str]: + """Return all known series-id PREFIXES (e.g. ``lb_weekly_jams_``). + + Used by callers (e.g. the LB adapter's refresh path) to tell + whether a ``source_playlist_id`` is a synthetic series id and + needs special resolution.""" + return [ + spec["series_format"].format(user="").rstrip("_") + "_" + for spec in _SERIES_PATTERNS + ] + + +def is_series_synthetic_id(source_playlist_id: str) -> bool: + """Cheap check: is the value one of our synthetic series ids? + + All series ids start with ``lb_`` and contain a recognizable + series tag. MusicBrainz MBIDs are 8-4-4-4-12 hex with dashes; no + overlap risk.""" + if not source_playlist_id or not source_playlist_id.startswith("lb_"): + return False + return any( + source_playlist_id.startswith(pref) for pref in list_series_synthetic_ids() + ) diff --git a/core/playlists/pipeline.py b/core/playlists/pipeline.py new file mode 100644 index 00000000..00d76432 --- /dev/null +++ b/core/playlists/pipeline.py @@ -0,0 +1,371 @@ +"""Mirrored playlist lifecycle pipeline. + +This module is the playlist-domain home for the all-in-one mirrored +playlist pipeline: + + refresh source -> discover metadata -> sync to server -> process wishlist + +Automation remains one caller, but the orchestration itself lives here so a +future playlist-card "Run Pipeline" button can call the same command. +""" + +from __future__ import annotations + +import json +import threading +import time +from datetime import datetime, timezone +from typing import Any, Callable, Dict, List + + +DISCOVERY_TIMEOUT_SECONDS = 3600 + + +RefreshFn = Callable[[Dict[str, Any], Any], Dict[str, Any]] +SyncOneFn = Callable[[Dict[str, Any], Any], Dict[str, Any]] +SyncAndWishlistFn = Callable[..., Dict[str, int]] + + +def run_mirrored_playlist_pipeline( + config: Dict[str, Any], + deps: Any, + *, + refresh_fn: RefreshFn, + sync_one_fn: SyncOneFn, + sync_and_wishlist_fn: SyncAndWishlistFn, +) -> Dict[str, Any]: + """Run REFRESH -> DISCOVER -> SYNC -> WISHLIST in sequence. + + ``deps`` intentionally uses duck typing. Today it is ``AutomationDeps``; + a future web/UI runner can provide the same small surface without becoming + an automation. + """ + if hasattr(deps.state, 'try_start_pipeline'): + if not deps.state.try_start_pipeline(): + return { + 'status': 'skipped', + 'reason': 'playlist_pipeline is already running', + '_manages_own_progress': True, + } + else: + deps.state.set_pipeline_running(True) + automation_id = config.get('_automation_id') + trigger_source = config.get('_trigger_source') or ( + 'manual' if str(automation_id or '').startswith('mirrored_') else 'automation' + ) + pipeline_start = time.time() + history_playlists: List[Dict[str, Any]] = [] + before_snapshots: Dict[int, Dict[str, Any]] = {} + + try: + db = deps.get_database() + playlist_id = config.get('playlist_id') + process_all = config.get('all', False) + skip_wishlist = config.get('skip_wishlist', False) + + playlists = _resolve_pipeline_playlists(db, playlist_id, process_all) + if playlists is None: + deps.state.set_pipeline_running(False) + return {'status': 'error', 'error': 'No playlist specified'} + + playlists = _filter_refreshable_playlists(playlists) + if not playlists: + deps.state.set_pipeline_running(False) + return {'status': 'error', 'error': 'No refreshable playlists found'} + history_playlists = list(playlists) + before_snapshots = { + int(pl['id']): _playlist_history_snapshot(db, pl) + for pl in history_playlists + if pl.get('id') + } + + deps.update_progress( + automation_id, + progress=2, + phase=f'Pipeline: {len(playlists)} playlist(s)', + log_line=f'Starting pipeline for: {_summarize_playlist_names(playlists)}', + log_type='info', + ) + + refreshed, refresh_errors = _run_refresh_phase( + config, + deps, + automation_id, + refresh_fn=refresh_fn, + ) + + _run_discovery_phase( + deps, + automation_id, + db=db, + playlist_id=playlist_id, + process_all=process_all, + ) + + sync_summary = sync_and_wishlist_fn( + deps, + automation_id, + [pl for pl in playlists if pl.get('id')], + sync_one_fn=lambda pl: sync_one_fn( + {'playlist_id': str(pl['id']), '_automation_id': None}, + deps, + ), + sync_id_for_fn=lambda pl: f"auto_mirror_{pl['id']}", + skip_wishlist=skip_wishlist, + progress_start=56, + progress_end=85, + sync_phase_label='Phase 3/4: Syncing to server...', + sync_phase_start_log='Phase 3: Sync', + wishlist_phase_label='Phase 4/4: Processing wishlist...', + wishlist_phase_start_log='Phase 4: Wishlist', + ) + + duration = int(time.time() - pipeline_start) + deps.update_progress( + automation_id, + status='finished', + progress=100, + phase='Pipeline complete', + log_line=f'Pipeline finished in {duration // 60}m {duration % 60}s', + log_type='success', + ) + + result = { + 'status': 'completed', + '_manages_own_progress': True, + 'playlists_refreshed': str(refreshed), + 'tracks_discovered': 'completed', + 'tracks_synced': str(sync_summary['synced']), + 'sync_skipped': str(sync_summary['skipped']), + 'wishlist_queued': str(sync_summary['wishlist_queued']), + 'duration_seconds': str(duration), + } + try: + _record_playlist_pipeline_history( + db, + history_playlists, + before_snapshots, + result, + status='completed', + started_at=pipeline_start, + finished_at=time.time(), + trigger_source=trigger_source, + ) + except Exception as history_error: # noqa: BLE001 - history should never fail a successful pipeline + deps.logger.debug(f"[Pipeline] History recording failed: {history_error}") + deps.state.set_pipeline_running(False) + return result + + except Exception as e: # noqa: BLE001 - pipeline callers should receive status dicts + deps.state.set_pipeline_running(False) + try: + if history_playlists: + _record_playlist_pipeline_history( + db, + history_playlists, + before_snapshots, + {'status': 'error', 'error': str(e), '_manages_own_progress': True}, + status='error', + started_at=pipeline_start, + finished_at=time.time(), + trigger_source=trigger_source, + ) + except Exception as history_error: # noqa: BLE001 - history should never mask pipeline errors + deps.logger.debug(f"[Pipeline] History recording failed after error: {history_error}") + deps.update_progress( + automation_id, + status='error', + progress=100, + phase='Pipeline error', + log_line=f'Pipeline failed: {e}', + log_type='error', + ) + return {'status': 'error', 'error': str(e), '_manages_own_progress': True} + + +def _pipeline_history_timestamp(ts: float) -> str: + return datetime.fromtimestamp(ts, timezone.utc).isoformat() + + +def _playlist_history_snapshot(db: Any, playlist: Dict[str, Any]) -> Dict[str, Any]: + playlist_id = int(playlist['id']) + current = db.get_mirrored_playlist(playlist_id) or playlist + counts = db.get_mirrored_playlist_status_counts(playlist_id) + return { + 'playlist_id': playlist_id, + 'name': current.get('name') or playlist.get('name') or '', + 'source': current.get('source') or playlist.get('source') or '', + 'track_count': int(counts.get('total') or current.get('track_count') or 0), + 'discovered_count': int(counts.get('discovered') or 0), + 'wishlisted_count': int(counts.get('wishlisted') or 0), + 'in_library_count': int(counts.get('in_library') or 0), + } + + +def _playlist_history_summary(before: Dict[str, Any], after: Dict[str, Any], status: str) -> str: + before_tracks = int(before.get('track_count') or 0) + after_tracks = int(after.get('track_count') or 0) + track_delta = after_tracks - before_tracks + before_discovered = int(before.get('discovered_count') or 0) + after_discovered = int(after.get('discovered_count') or 0) + discovered_delta = after_discovered - before_discovered + parts = [status.capitalize()] + parts.append(f"{before_tracks} -> {after_tracks} tracks") + if track_delta: + parts.append(f"{track_delta:+d} tracks") + if discovered_delta: + parts.append(f"{discovered_delta:+d} discovered") + return ' | '.join(parts) + + +def _record_playlist_pipeline_history( + db: Any, + playlists: List[Dict[str, Any]], + before_snapshots: Dict[int, Dict[str, Any]], + result: Dict[str, Any], + *, + status: str, + started_at: float, + finished_at: float, + trigger_source: str, +) -> None: + if not hasattr(db, 'insert_playlist_pipeline_run_history'): + return + duration = max(0, finished_at - started_at) + for playlist in playlists: + if not playlist.get('id'): + continue + playlist_id = int(playlist['id']) + before = before_snapshots.get(playlist_id, {}) + after = _playlist_history_snapshot(db, playlist) + db.insert_playlist_pipeline_run_history( + playlist_id=playlist_id, + playlist_name=after.get('name') or playlist.get('name') or '', + source=after.get('source') or playlist.get('source') or '', + profile_id=int(playlist.get('profile_id') or 1), + trigger_source=trigger_source, + started_at=_pipeline_history_timestamp(started_at), + finished_at=_pipeline_history_timestamp(finished_at), + duration_seconds=duration, + status=status, + summary=_playlist_history_summary(before, after, status), + before_json=json.dumps(before), + after_json=json.dumps(after), + result_json=json.dumps(result), + log_lines=None, + ) + + +def _resolve_pipeline_playlists(db: Any, playlist_id: Any, process_all: bool) -> List[Dict[str, Any]] | None: + if process_all: + return db.get_mirrored_playlists() + if playlist_id: + playlist = db.get_mirrored_playlist(int(playlist_id)) + return [playlist] if playlist else [] + return None + + +def _filter_refreshable_playlists(playlists: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + return [pl for pl in playlists if pl.get('source', '') not in ('file', 'beatport')] + + +def _summarize_playlist_names(playlists: List[Dict[str, Any]]) -> str: + pl_names = ', '.join(p.get('name', '?') for p in playlists[:3]) + if len(playlists) > 3: + pl_names += f' (+{len(playlists) - 3} more)' + return pl_names + + +def _run_refresh_phase( + config: Dict[str, Any], + deps: Any, + automation_id: Any, + *, + refresh_fn: RefreshFn, +) -> tuple[int, int]: + deps.update_progress( + automation_id, + progress=3, + phase='Phase 1/4: Refreshing playlists...', + log_line='Phase 1: Refresh', + log_type='info', + ) + + refresh_config = dict(config) + refresh_config['_automation_id'] = None + refresh_result = refresh_fn(refresh_config, deps) + refreshed = int(refresh_result.get('refreshed', 0)) + refresh_errors = int(refresh_result.get('errors', 0)) + + deps.update_progress( + automation_id, + progress=25, + phase='Phase 1/4: Refresh complete', + log_line=f'Phase 1 done: {refreshed} refreshed, {refresh_errors} errors', + log_type='success' if refresh_errors == 0 else 'warning', + ) + return refreshed, refresh_errors + + +def _run_discovery_phase( + deps: Any, + automation_id: Any, + *, + db: Any, + playlist_id: Any, + process_all: bool, +) -> None: + deps.update_progress( + automation_id, + progress=26, + phase='Phase 2/4: Discovering metadata...', + log_line='Phase 2: Discover', + log_type='info', + ) + + if process_all: + disc_playlists = db.get_mirrored_playlists() + else: + disc_playlists = [db.get_mirrored_playlist(int(playlist_id))] + disc_playlists = [p for p in disc_playlists if p] + + disc_done = threading.Event() + + def _disc_wrapper(pls): + try: + deps.run_playlist_discovery_worker(pls, automation_id=None) + except Exception as e: # noqa: BLE001 - logged into pipeline progress + deps.logger.error(f"[Pipeline] Discovery error: {e}") + finally: + disc_done.set() + + threading.Thread( + target=_disc_wrapper, + args=(disc_playlists,), + daemon=True, + name='pipeline-discover', + ).start() + + poll_start = time.time() + while not disc_done.wait(timeout=3): + elapsed = int(time.time() - poll_start) + deps.update_progress( + automation_id, + progress=min(26 + elapsed // 4, 54), + phase=f'Phase 2/4: Discovering... ({elapsed}s)', + ) + if elapsed > DISCOVERY_TIMEOUT_SECONDS: + deps.update_progress( + automation_id, + log_line='Discovery timed out after 1 hour', + log_type='warning', + ) + break + + deps.update_progress( + automation_id, + progress=55, + phase='Phase 2/4: Discovery complete', + log_line='Phase 2 done: discovery complete', + log_type='success', + ) diff --git a/core/playlists/source_refs.py b/core/playlists/source_refs.py new file mode 100644 index 00000000..22909895 --- /dev/null +++ b/core/playlists/source_refs.py @@ -0,0 +1,156 @@ +"""Helpers for mirrored-playlist upstream source references. + +Mirrored playlist rows have two legacy fields: +- ``source_playlist_id``: the stable lookup key used for uniqueness. +- ``description``: for URL-backed mirrors, the original/canonical URL. + +Keeping the normalization here prevents the refresh worker, API endpoint, +and UI repair flow from each inventing a slightly different meaning. +""" + +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass +from typing import Mapping, Optional +from urllib.parse import parse_qs, urlparse + + +_SPOTIFY_ID_RE = re.compile(r"^[A-Za-z0-9]{16,32}$") + + +@dataclass(frozen=True) +class MirroredSourceRef: + source_playlist_id: str + description: Optional[str] + + +@dataclass(frozen=True) +class MirroredSourceRefView: + source_ref: str + source_ref_kind: str + source_ref_status: str + source_ref_error: Optional[str] = None + + +def normalize_mirrored_source_ref( + source: str, + source_ref: str, + existing_description: str = "", +) -> MirroredSourceRef: + """Normalize a user-provided source URL/ID for storage. + + URL-backed sources keep a deterministic hash in ``source_playlist_id`` and + store the canonical URL in ``description``. Direct-ID sources store the ID + directly and preserve the existing description unless a source-specific URL + parser says otherwise. + """ + source = (source or "").strip().lower() + source_ref = (source_ref or "").strip() + existing_description = (existing_description or "").strip() + + if not source_ref: + raise ValueError("Source link or ID is required") + + if source == "spotify_public": + canonical_url = _canonical_spotify_url(source_ref) + return MirroredSourceRef(_short_hash(canonical_url), canonical_url) + + if source == "youtube": + canonical_url = _canonical_youtube_url(source_ref) + return MirroredSourceRef(_short_hash(canonical_url), canonical_url) + + if source == "deezer" and source_ref.startswith(("http://", "https://")): + from core.deezer_client import DeezerClient + + parsed_id = DeezerClient.parse_playlist_url(source_ref) + if not parsed_id: + raise ValueError("Use a valid Deezer playlist URL or playlist ID") + return MirroredSourceRef(str(parsed_id), existing_description or None) + + return MirroredSourceRef(source_ref, existing_description or None) + + +def require_refresh_url(source: str, description: str, playlist_name: str = "") -> str: + """Return a URL required by hash-backed refresh sources, or raise clearly.""" + source = (source or "").strip().lower() + description = (description or "").strip() + if source in {"spotify_public", "youtube"}: + if not description.startswith(("http://", "https://")): + label = f" '{playlist_name}'" if playlist_name else "" + raise ValueError(f"{source} mirror{label} is missing its original source URL") + return description + + +def describe_mirrored_source_ref(playlist: Mapping[str, object]) -> MirroredSourceRefView: + """Build a UI/API friendly view of a mirrored playlist's refresh ref.""" + source = str(playlist.get("source") or "").strip().lower() + source_playlist_id = str(playlist.get("source_playlist_id") or "").strip() + description = str(playlist.get("description") or "").strip() + name = str(playlist.get("name") or "") + + if source in {"spotify_public", "youtube"}: + if description.startswith(("http://", "https://")): + return MirroredSourceRefView(description, "url", "ok") + try: + require_refresh_url(source, description, name) + except ValueError as exc: + return MirroredSourceRefView( + source_playlist_id, + "url", + "missing", + str(exc), + ) + + return MirroredSourceRefView(source_playlist_id, "id", "ok" if source_playlist_id else "missing") + + +def _canonical_spotify_url(source_ref: str) -> str: + parsed = _parse_spotify_ref(source_ref) + if parsed: + return f"https://open.spotify.com/{parsed['type']}/{parsed['id']}" + + # Repair flow convenience: if the user pastes only a Spotify ID, assume + # playlist. Album URLs still need their URL/URI so the type is explicit. + if _SPOTIFY_ID_RE.match(source_ref): + return f"https://open.spotify.com/playlist/{source_ref}" + + raise ValueError("Use a valid open.spotify.com playlist/album URL, Spotify URI, or playlist ID") + + +def _parse_spotify_ref(source_ref: str) -> Optional[dict]: + uri_match = re.match(r"spotify:(playlist|album):([A-Za-z0-9]+)", source_ref) + if uri_match: + return {"type": uri_match.group(1), "id": uri_match.group(2)} + + url_match = re.search( + r"https?://open\.spotify\.com/(?:embed/)?(playlist|album)/([A-Za-z0-9]+)", + source_ref, + ) + if url_match: + return {"type": url_match.group(1), "id": url_match.group(2)} + + return None + + +def _canonical_youtube_url(source_ref: str) -> str: + parsed_url = urlparse(source_ref) + playlist_id = "" + + if parsed_url.scheme and parsed_url.netloc: + host = parsed_url.netloc.lower() + if not ("youtube.com" in host or "music.youtube.com" in host): + raise ValueError("Use a valid YouTube playlist URL") + playlist_id = parse_qs(parsed_url.query).get("list", [""])[0] + else: + playlist_id = source_ref + + if not playlist_id: + raise ValueError("YouTube playlist URL must include a list= playlist id") + + return f"https://youtube.com/playlist?list={playlist_id}" + + +def _short_hash(value: str) -> str: + return hashlib.md5(value.encode()).hexdigest()[:12] diff --git a/core/playlists/sources/__init__.py b/core/playlists/sources/__init__.py new file mode 100644 index 00000000..d1be7ecc --- /dev/null +++ b/core/playlists/sources/__init__.py @@ -0,0 +1,34 @@ +"""Unified playlist-source abstraction. + +Phase 0 of the Discover-to-Sync unification. Each external playlist +provider (Spotify, Tidal, Qobuz, YouTube, Spotify public, iTunes link, +ListenBrainz, Last.fm radio, SoulSync Discovery) gets an adapter that +exposes the same ``PlaylistSource`` Protocol, so callers no longer have +to branch on ``source`` string with an if/elif chain. + +The existing client modules are left untouched — adapters wrap them. +Once every caller speaks the unified interface, the legacy dispatch +sites (``refresh_mirrored.py`` etc.) collapse to a registry lookup. +""" + +from core.playlists.sources.base import ( + PlaylistDetail, + PlaylistMeta, + PlaylistSource, + NormalizedTrack, + to_mirror_track_dict, +) +from core.playlists.sources.registry import ( + PlaylistSourceRegistry, + get_registry, +) + +__all__ = [ + "PlaylistDetail", + "PlaylistMeta", + "PlaylistSource", + "NormalizedTrack", + "PlaylistSourceRegistry", + "get_registry", + "to_mirror_track_dict", +] diff --git a/core/playlists/sources/base.py b/core/playlists/sources/base.py new file mode 100644 index 00000000..dda63416 --- /dev/null +++ b/core/playlists/sources/base.py @@ -0,0 +1,244 @@ +"""PlaylistSource Protocol + normalized data containers. + +These dataclasses define the *single* shape every adapter must return. +The legacy backing clients each return slightly different dicts / +dataclasses; the adapter's job is to project those into ``PlaylistMeta`` +and ``NormalizedTrack`` so callers don't have to know which source they +got the data from. + +Two distinct shapes: + +- ``PlaylistMeta``: cheap, lightweight — used for "list playlists for a + tab" responses. No tracks. +- ``PlaylistDetail``: meta + full normalized track list. Used after the + user selects a playlist to mirror. + +Discovery flag: + +- ``NormalizedTrack.needs_discovery`` is True for sources that return + raw metadata only (ListenBrainz, Last.fm radio) — the caller must run + the match step before the track is usable in the download pipeline. + Sources that already carry a provider ID (Spotify, Tidal, etc.) set + this to False. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +# Canonical source identifiers used as the key in mirrored_playlists.source +# and in the registry. Centralized so a typo in one place doesn't silently +# create a new "source". +SOURCE_SPOTIFY = "spotify" +SOURCE_SPOTIFY_PUBLIC = "spotify_public" +SOURCE_DEEZER = "deezer" +SOURCE_TIDAL = "tidal" +SOURCE_QOBUZ = "qobuz" +SOURCE_YOUTUBE = "youtube" +SOURCE_ITUNES_LINK = "itunes_link" +SOURCE_LISTENBRAINZ = "listenbrainz" +SOURCE_LASTFM = "lastfm" +SOURCE_SOULSYNC_DISCOVERY = "soulsync_discovery" + +ALL_SOURCES = ( + SOURCE_SPOTIFY, + SOURCE_SPOTIFY_PUBLIC, + SOURCE_DEEZER, + SOURCE_TIDAL, + SOURCE_QOBUZ, + SOURCE_YOUTUBE, + SOURCE_ITUNES_LINK, + SOURCE_LISTENBRAINZ, + SOURCE_LASTFM, + SOURCE_SOULSYNC_DISCOVERY, +) + + +@dataclass +class PlaylistMeta: + """Lightweight playlist descriptor — no tracks.""" + + source: str + source_playlist_id: str + name: str + track_count: int = 0 + owner: Optional[str] = None + description: Optional[str] = None + image_url: Optional[str] = None + # Original URL for URL-backed sources (youtube, spotify_public, + # itunes_link). Used by the refresh path to re-fetch. + source_url: Optional[str] = None + # Free-form per-source passthrough — adapter can stash whatever the + # native API returned for downstream consumers that need richer data + # (e.g. ListenBrainz creator/MBID, Spotify snapshot_id). + extra: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class NormalizedTrack: + """A single track in normalized shape. + + ``source_track_id`` is the native ID at the source — Spotify track + ID, Tidal ID, YouTube video ID, ListenBrainz recording MBID, etc. + Empty string is allowed for sources that don't have a stable per- + track ID (rare). + """ + + position: int + track_name: str + artist_name: str + album_name: Optional[str] = None + duration_ms: int = 0 + source_track_id: Optional[str] = None + image_url: Optional[str] = None + # True when the track needs a discovery / match step before it can be + # downloaded (e.g. ListenBrainz returns MB recording metadata only — + # no Spotify/iTunes ID, so the matching engine has to run first). + needs_discovery: bool = False + # Passthrough for source-specific extras (explicit flag, popularity, + # external_urls, recording_mbid, etc.). Adapters decide what to stash. + extra: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class PlaylistDetail: + """Full playlist payload — meta + tracks.""" + + meta: PlaylistMeta + tracks: List[NormalizedTrack] = field(default_factory=list) + + +class PlaylistSource(ABC): + """Contract every playlist source adapter implements. + + Capability flags let callers query the adapter's shape before + invoking it (e.g. ``supports_listing=False`` for URL-only sources + means the Sync page should render a paste-URL input instead of a + playlist picker). + + ABC rather than Protocol so we can ship a concrete default for + ``discover_tracks`` (sources without provider matching just return + the input list unchanged) — only the MB-metadata-only sources + (ListenBrainz, Last.fm radio) need to override. + """ + + # Class-level attributes; subclasses pin them to concrete values. + name: str = "" + supports_listing: bool = True + supports_refresh: bool = True + requires_auth: bool = False + + @abstractmethod + def is_authenticated(self) -> bool: + """Return True if the adapter can currently call its backend. + + For sources without auth (YouTube, Spotify public, iTunes link), + this is always True. For sources where auth check is expensive, + the adapter may cache (existing clients already do this).""" + + @abstractmethod + def list_playlists(self) -> List[PlaylistMeta]: + """Return all playlists the user has access to. + + For ``supports_listing=False`` sources, return ``[]`` and let + the caller use ``get_playlist`` with a URL/ID directly.""" + + @abstractmethod + def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: + """Fetch full playlist (meta + tracks) by source-native ID. + + For URL-backed sources, ``playlist_id`` is the full URL. For ID- + backed sources it's the native ID string. Returns ``None`` if + the playlist isn't reachable (404, auth failure, etc.).""" + + @abstractmethod + def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: + """Re-fetch a playlist for the auto-refresh pipeline. + + Default behavior is usually identical to ``get_playlist``. + Sources whose refresh has side effects (e.g. ListenBrainz cache + update, SoulSync Discovery regeneration) do real work here.""" + + def discover_tracks(self, tracks: List[NormalizedTrack]) -> List[NormalizedTrack]: + """Match raw tracks against a provider (Spotify / iTunes / etc.). + + Default no-op: returns ``tracks`` unchanged. Only the MB- + metadata-only sources (ListenBrainz, Last.fm radio) override + this — every other adapter already returns tracks with + ``needs_discovery=False`` and provider IDs filled in. + + Matched tracks should have ``extra['discovered']=True`` + + ``extra['matched_data']`` populated so ``to_mirror_track_dict`` + produces the canonical ``extra_data`` JSON shape downstream + consumers (mirrored-playlist DB, sync pipeline, wishlist) + already expect. Unmatched tracks should be returned as-is + with ``needs_discovery`` left True so the caller can decide + what to do (mark as wing-it, skip, retry later).""" + return tracks + + +# ─── projection helpers ──────────────────────────────────────────────── +# +# Adapters return NormalizedTrack objects; the mirrored-playlist DB +# writer (``MusicDatabase.mirror_playlist``) accepts a list of dicts +# with a specific shape. ``to_mirror_track_dict`` is the single, +# tested projection between the two — kept here (not in the handler) +# so every caller that writes mirrored tracks uses the same mapping. + + +import json as _json + + +def to_mirror_track_dict(track: NormalizedTrack) -> Dict[str, Any]: + """Project a NormalizedTrack into the shape ``mirror_playlist`` expects. + + Adapter conventions consumed: + + - ``track.extra['discovered']`` (bool) — when True, the adapter has + enough metadata to skip the discovery worker and write a fully- + populated ``matched_data`` block straight into ``extra_data``. + Spotify's authenticated API path sets this. + - ``track.extra['provider']`` (str) — provider name to record on + the matched_data block (e.g. 'spotify'). + - ``track.extra['confidence']`` (float) — 0..1 match confidence; + defaults to 1.0 when ``discovered`` is True. + - ``track.extra['matched_data']`` (dict) — pre-built matched_data + payload. Overrides the auto-derived payload below. + - ``track.extra['spotify_hint']`` (dict) — public-embed scraper + path: the Spotify track ID + artists hint that lets the + discovery worker skip its search and go straight to enrichment. + + When none of the above are present, the result has only the core + fields and no ``extra_data`` — the discovery worker handles the + track from scratch. + """ + result: Dict[str, Any] = { + "track_name": track.track_name or "", + "artist_name": track.artist_name or "", + "album_name": track.album_name or "", + "duration_ms": int(track.duration_ms or 0), + "source_track_id": track.source_track_id or "", + } + + extra = track.extra or {} + matched_data = extra.get("matched_data") + is_discovered = bool(extra.get("discovered")) + spotify_hint = extra.get("spotify_hint") + + if is_discovered and matched_data: + result["extra_data"] = _json.dumps({ + "discovered": True, + "provider": extra.get("provider") or "unknown", + "confidence": float(extra.get("confidence", 1.0)), + "matched_data": matched_data, + }) + elif spotify_hint: + result["extra_data"] = _json.dumps({ + "discovered": False, + "spotify_hint": spotify_hint, + }) + + return result diff --git a/core/playlists/sources/bootstrap.py b/core/playlists/sources/bootstrap.py new file mode 100644 index 00000000..1d2aa77b --- /dev/null +++ b/core/playlists/sources/bootstrap.py @@ -0,0 +1,104 @@ +"""Helper for constructing + populating a PlaylistSourceRegistry. + +Both ``web_server.py`` (at app boot) and the automation test fixtures +build a registry the same way: take the client / parser / manager +getters that already exist as module globals, wire them into the +adapter constructors, register each adapter under its canonical name. + +This module owns that wiring so the two call sites can't drift. +""" + +from __future__ import annotations + +from typing import Any, Callable, Optional + +from core.playlists.sources.base import ( + SOURCE_DEEZER, + SOURCE_ITUNES_LINK, + SOURCE_LASTFM, + SOURCE_LISTENBRAINZ, + SOURCE_QOBUZ, + SOURCE_SOULSYNC_DISCOVERY, + SOURCE_SPOTIFY, + SOURCE_SPOTIFY_PUBLIC, + SOURCE_TIDAL, + SOURCE_YOUTUBE, +) +from core.playlists.sources.deezer import DeezerPlaylistSource +from core.playlists.sources.itunes_link import ITunesLinkPlaylistSource +from core.playlists.sources.lastfm import LastFMPlaylistSource +from core.playlists.sources.listenbrainz import ListenBrainzPlaylistSource +from core.playlists.sources.qobuz import QobuzPlaylistSource +from core.playlists.sources.registry import PlaylistSourceRegistry +from core.playlists.sources.soulsync_discovery import ( + SoulSyncDiscoveryPlaylistSource, +) +from core.playlists.sources.spotify import SpotifyPlaylistSource +from core.playlists.sources.spotify_public import SpotifyPublicPlaylistSource +from core.playlists.sources.tidal import TidalPlaylistSource +from core.playlists.sources.youtube import YouTubePlaylistSource + + +def build_playlist_source_registry( + *, + spotify_client_getter: Callable[[], Any], + tidal_client_getter: Callable[[], Any], + qobuz_client_getter: Callable[[], Any], + deezer_client_getter: Callable[[], Any], + itunes_link_parser: Optional[Callable[[str], Optional[dict]]] = None, + youtube_parser: Optional[Callable[[str], Optional[dict]]] = None, + listenbrainz_manager_getter: Optional[Callable[[], Any]] = None, + lastfm_manager_getter: Optional[Callable[[], Any]] = None, + personalized_manager_getter: Optional[Callable[[], Any]] = None, + profile_id_getter: Optional[Callable[[], int]] = None, + discover_callable: Optional[Callable[..., Any]] = None, +) -> PlaylistSourceRegistry: + """Build a fresh registry with every default adapter registered. + + Each parameter is the getter the corresponding adapter needs. Pass + ``lambda: None`` (or omit) for sources you don't want to expose — + the adapter will simply degrade to empty results when its backing + client is None / its parser is unset. + """ + reg = PlaylistSourceRegistry() + + reg.register(SOURCE_SPOTIFY, lambda: SpotifyPlaylistSource(spotify_client_getter)) + reg.register(SOURCE_SPOTIFY_PUBLIC, lambda: SpotifyPublicPlaylistSource()) + reg.register(SOURCE_DEEZER, lambda: DeezerPlaylistSource(deezer_client_getter)) + reg.register(SOURCE_TIDAL, lambda: TidalPlaylistSource(tidal_client_getter)) + reg.register(SOURCE_QOBUZ, lambda: QobuzPlaylistSource(qobuz_client_getter)) + + _no_url_parser = lambda url: None + reg.register( + SOURCE_YOUTUBE, + lambda: YouTubePlaylistSource(youtube_parser or _no_url_parser), + ) + reg.register( + SOURCE_ITUNES_LINK, + lambda: ITunesLinkPlaylistSource(itunes_link_parser or _no_url_parser), + ) + + _no_manager = lambda: None + reg.register( + SOURCE_LISTENBRAINZ, + lambda: ListenBrainzPlaylistSource( + listenbrainz_manager_getter or _no_manager, + discover_callable=discover_callable, + ), + ) + reg.register( + SOURCE_LASTFM, + lambda: LastFMPlaylistSource( + lastfm_manager_getter or _no_manager, + discover_callable=discover_callable, + ), + ) + reg.register( + SOURCE_SOULSYNC_DISCOVERY, + lambda: SoulSyncDiscoveryPlaylistSource( + personalized_manager_getter or _no_manager, + profile_id_getter=profile_id_getter, + ), + ) + + return reg diff --git a/core/playlists/sources/deezer.py b/core/playlists/sources/deezer.py new file mode 100644 index 00000000..5d8fae1b --- /dev/null +++ b/core/playlists/sources/deezer.py @@ -0,0 +1,108 @@ +"""Deezer playlist source adapter. + +Wraps ``core.deezer_client.DeezerClient.get_playlist``. Deezer's public +API needs no auth, so ``is_authenticated`` always returns True. Listing +the *user's* playlists requires OAuth — surfaced via the underlying +``is_user_authenticated`` flag — but the get-by-id flow works on any +public playlist regardless. +""" + +from __future__ import annotations + +from typing import Any, Callable, Dict, List, Optional + +from core.playlists.sources.base import ( + NormalizedTrack, + PlaylistDetail, + PlaylistMeta, + PlaylistSource, + SOURCE_DEEZER, +) + + +class DeezerPlaylistSource(PlaylistSource): + name = SOURCE_DEEZER + supports_listing = True # user playlists need OAuth; falls back to [] + supports_refresh = True + requires_auth = False + + def __init__(self, client_getter: Callable[[], Any]): + self._client_getter = client_getter + + def _client(self): + return self._client_getter() + + def is_authenticated(self) -> bool: + client = self._client() + if client is None: + return False + # Deezer's `is_authenticated` is True even with no OAuth token — + # the public API works without one. Use that as our liveness signal. + return bool(client.is_authenticated()) + + def list_playlists(self) -> List[PlaylistMeta]: + client = self._client() + if client is None: + return [] + # User playlists need OAuth; `get_user_playlists` returns [] when + # the stub-interface variant is in use. Honor whatever the client + # actually returns. + try: + playlists = client.get_user_playlists() or [] + except Exception: + return [] + return [self._meta_from_playlist(p) for p in playlists] + + def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: + client = self._client() + if client is None: + return None + data = client.get_playlist(playlist_id) + if not data: + return None + meta = self._meta_from_dict(data) + tracks_raw = data.get("tracks") or [] + tracks = [self._track_from_dict(t, idx) for idx, t in enumerate(tracks_raw)] + meta.track_count = len(tracks) + return PlaylistDetail(meta=meta, tracks=tracks) + + def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: + return self.get_playlist(playlist_id) + + # ---- projection helpers ------------------------------------------------ + + def _meta_from_playlist(self, playlist: Any) -> PlaylistMeta: + """Project a ``DeezerClient.Playlist`` dataclass into PlaylistMeta.""" + return PlaylistMeta( + source=self.name, + source_playlist_id=str(getattr(playlist, "id", "")), + name=getattr(playlist, "name", "Deezer Playlist"), + description=getattr(playlist, "description", None), + track_count=int(getattr(playlist, "total_tracks", 0) or 0), + owner=getattr(playlist, "owner", None), + ) + + def _meta_from_dict(self, p: Dict[str, Any]) -> PlaylistMeta: + return PlaylistMeta( + source=self.name, + source_playlist_id=str(p.get("id", "")), + name=p.get("name", "Deezer Playlist"), + description=p.get("description") or None, + owner=p.get("owner") or None, + image_url=p.get("image_url") or None, + track_count=int(p.get("track_count", 0) or 0), + ) + + def _track_from_dict(self, t: Dict[str, Any], position: int) -> NormalizedTrack: + artists = t.get("artists") or [] + artist_name = artists[0] if artists else "Unknown Artist" + return NormalizedTrack( + position=position, + track_name=t.get("name", "Unknown Track"), + artist_name=artist_name, + album_name=t.get("album") or None, + duration_ms=int(t.get("duration_ms", 0) or 0), + source_track_id=str(t.get("id", "")), + needs_discovery=False, + extra={"track_number": t.get("track_number")}, + ) diff --git a/core/playlists/sources/itunes_link.py b/core/playlists/sources/itunes_link.py new file mode 100644 index 00000000..89271d9d --- /dev/null +++ b/core/playlists/sources/itunes_link.py @@ -0,0 +1,109 @@ +"""iTunes / Apple Music link playlist source adapter. + +Wraps the iTunes-link parsing logic that currently lives in +``web_server.py`` (commit 718eb0cb). Phase 0 doesn't move it — adapter +takes a parser callable that returns the parsed-playlist dict matching +the ``parse_itunes_link_endpoint`` response shape:: + + { + 'id', 'type', 'name', 'subtitle', 'url', 'url_hash', + 'track_count', 'image_url', + 'tracks': [{ 'id', 'name', 'artists', 'album', 'duration_ms', ... }], + } + +``supports_listing=False`` — Apple Music has no "user library" listing, +the user pastes a URL. +""" + +from __future__ import annotations + +from typing import Any, Callable, List, Optional + +from core.playlists.sources.base import ( + NormalizedTrack, + PlaylistDetail, + PlaylistMeta, + PlaylistSource, + SOURCE_ITUNES_LINK, +) + + +class ITunesLinkPlaylistSource(PlaylistSource): + name = SOURCE_ITUNES_LINK + supports_listing = False + supports_refresh = True + requires_auth = False + + def __init__(self, parser: Callable[[str], Optional[dict]]): + """``parser(url)`` returns the parsed playlist dict, or ``None`` + if the URL is invalid / unreachable. Injected by ``web_server.py`` + at startup, pointing at the existing module-level helpers.""" + self._parser = parser + + def is_authenticated(self) -> bool: + return True + + def list_playlists(self) -> List[PlaylistMeta]: + return [] + + def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: + """``playlist_id`` is the full Apple Music / iTunes URL.""" + data = self._parser(playlist_id) + if not data: + return None + + source_url = data.get("url") or playlist_id + tracks_raw = data.get("tracks") or [] + + meta = PlaylistMeta( + source=self.name, + source_playlist_id=str(data.get("url_hash") or data.get("id") or ""), + name=data.get("name", "Apple Music Link"), + owner=data.get("subtitle"), + image_url=data.get("image_url") or None, + track_count=int(data.get("track_count", len(tracks_raw))), + source_url=source_url, + extra={ + "itunes_type": data.get("type"), + "itunes_id": data.get("id"), + }, + ) + + tracks = [self._track_from_itunes(t, idx) for idx, t in enumerate(tracks_raw) if t] + return PlaylistDetail(meta=meta, tracks=tracks) + + def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: + return self.get_playlist(playlist_id) + + # ---- projection helpers ------------------------------------------------ + + def _track_from_itunes(self, track: dict, position: int) -> NormalizedTrack: + artists = track.get("artists") or [] + if artists and isinstance(artists[0], dict): + artist_name = artists[0].get("name", "") or "" + elif artists: + artist_name = str(artists[0]) + else: + artist_name = "" + if not artist_name: + artist_name = "Unknown Artist" + album = track.get("album") + album_name: Optional[str] = None + if isinstance(album, dict): + album_name = album.get("name") or None + elif album: + album_name = str(album) + return NormalizedTrack( + position=position, + track_name=track.get("name", "Unknown Track"), + artist_name=artist_name, + album_name=album_name, + duration_ms=int(track.get("duration_ms", 0) or 0), + source_track_id=str(track.get("id", "")), + image_url=track.get("image_url"), + needs_discovery=False, + extra={ + "external_urls": track.get("external_urls"), + "preview_url": track.get("preview_url"), + }, + ) diff --git a/core/playlists/sources/lastfm.py b/core/playlists/sources/lastfm.py new file mode 100644 index 00000000..ee13206c --- /dev/null +++ b/core/playlists/sources/lastfm.py @@ -0,0 +1,134 @@ +"""Last.fm radio playlist source adapter. + +Last.fm radio playlists are persisted by +``ListenBrainzManager.save_lastfm_radio_playlist`` under +``playlist_type='lastfm_radio'`` in the ``listenbrainz_playlists`` +table — they share the same storage as ListenBrainz playlists but +originate from Last.fm's similar-tracks API. + +Like ListenBrainz, tracks are MB metadata only, so ``needs_discovery`` +is True on every track. +""" + +from __future__ import annotations + +from typing import Any, Callable, Dict, List, Optional + +from core.playlists.sources.base import ( + NormalizedTrack, + PlaylistDetail, + PlaylistMeta, + PlaylistSource, + SOURCE_LASTFM, +) +from core.playlists.sources.listenbrainz import ( + DiscoverCallable, + ListenBrainzPlaylistSource, +) + + +LASTFM_PLAYLIST_TYPE = "lastfm_radio" + + +class LastFMPlaylistSource(PlaylistSource): + name = SOURCE_LASTFM + supports_listing = True + supports_refresh = False # Refresh requires re-running the radio generator + requires_auth = True + + def __init__( + self, + manager_getter: Callable[[], Any], + discover_callable: Optional[DiscoverCallable] = None, + ): + """``manager_getter`` returns the profile's ``ListenBrainzManager`` + (Last.fm radio playlists share that storage layer). + + ``discover_callable`` runs matching-engine + provider search; + Last.fm radio tracks are MB-metadata only, so this is needed + for ``discover_tracks`` to do real work.""" + self._manager_getter = manager_getter + self._discover_callable = discover_callable + + def _manager(self): + return self._manager_getter() + + def is_authenticated(self) -> bool: + # Last.fm radio rows exist independently of any auth state — + # they're persisted snapshots. Treat "manager exists" as enough. + return self._manager() is not None + + def list_playlists(self) -> List[PlaylistMeta]: + manager = self._manager() + if manager is None: + return [] + try: + rows = manager.get_cached_playlists(LASTFM_PLAYLIST_TYPE) or [] + except Exception: + rows = [] + return [self._meta_from_cache_row(r) for r in rows] + + def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: + manager = self._manager() + if manager is None: + return None + try: + rows = manager.get_cached_playlists(LASTFM_PLAYLIST_TYPE) or [] + except Exception: + rows = [] + meta_row = next( + (r for r in rows if str(r.get("playlist_mbid")) == str(playlist_id)), + None, + ) + if meta_row is None: + return None + try: + tracks_raw = manager.get_cached_tracks(playlist_id) or [] + except Exception: + tracks_raw = [] + meta = self._meta_from_cache_row(meta_row) + meta.track_count = len(tracks_raw) + tracks = [self._track_from_cache_row(t, idx) for idx, t in enumerate(tracks_raw)] + return PlaylistDetail(meta=meta, tracks=tracks) + + def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: + # Regenerating a Last.fm radio playlist needs the seed track + + # the Last.fm client — that lives outside this adapter. Phase 1 + # wires up the regeneration; Phase 0 just returns the current + # snapshot. + return self.get_playlist(playlist_id) + + # Discovery shares the LB adapter's implementation — same track + # shape (MB metadata), same matching needs. + discover_tracks = ListenBrainzPlaylistSource.discover_tracks + + # ---- projection helpers ------------------------------------------------ + + def _meta_from_cache_row(self, row: Dict[str, Any]) -> PlaylistMeta: + return PlaylistMeta( + source=self.name, + source_playlist_id=str(row.get("playlist_mbid", "")), + name=row.get("title") or "Last.fm Radio", + owner=row.get("creator") or "Last.fm", + track_count=int(row.get("track_count", 0) or 0), + extra={ + "annotation": row.get("annotation") or {}, + "last_updated": row.get("last_updated"), + }, + ) + + def _track_from_cache_row(self, row: Dict[str, Any], position: int) -> NormalizedTrack: + return NormalizedTrack( + position=position, + track_name=row.get("track_name", "Unknown Track"), + artist_name=row.get("artist_name", "Unknown Artist"), + album_name=row.get("album_name") or None, + duration_ms=int(row.get("duration_ms", 0) or 0), + source_track_id=row.get("recording_mbid") or None, + image_url=row.get("album_cover_url") or None, + needs_discovery=True, + extra={ + "recording_mbid": row.get("recording_mbid"), + "release_mbid": row.get("release_mbid"), + }, + ) diff --git a/core/playlists/sources/listenbrainz.py b/core/playlists/sources/listenbrainz.py new file mode 100644 index 00000000..838cda15 --- /dev/null +++ b/core/playlists/sources/listenbrainz.py @@ -0,0 +1,308 @@ +"""ListenBrainz playlist source adapter. + +Wraps ``core.listenbrainz_manager.ListenBrainzManager``. ListenBrainz +playlists carry only MusicBrainz recording metadata — no Spotify / +iTunes IDs — so every track returned by this adapter has +``needs_discovery=True``. Phase 1+ will route those through the +existing ``run_listenbrainz_discovery_worker`` and persist the matched +provider IDs into ``mirrored_playlist_tracks.extra_data``. + +Construction takes a manager getter callable because the manager is +profile-scoped (one instance per profile, built from credentials stored +in the DB) — there is no process-wide singleton to grab at import time. +""" + +from __future__ import annotations + +from typing import Any, Callable, Dict, List, Optional + +from core.playlists.sources.base import ( + NormalizedTrack, + PlaylistDetail, + PlaylistMeta, + PlaylistSource, + SOURCE_LISTENBRAINZ, +) + + +# Type alias for the discovery callable: takes a list of MB-shaped +# track dicts, returns a parallel list of matched_data dicts (or None +# when no match). Kept narrow so test stubs are easy. +DiscoverCallable = Callable[[List[Dict[str, Any]]], List[Optional[Dict[str, Any]]]] + + +class ListenBrainzPlaylistSource(PlaylistSource): + name = SOURCE_LISTENBRAINZ + supports_listing = True + supports_refresh = True + requires_auth = True + + # ListenBrainz manager caches three "playlist types" — surface all + # three under this source. The Sync page can group / filter by + # ``meta.extra['playlist_type']`` if it wants per-type sub-tabs. + PLAYLIST_TYPES = ("created_for_user", "user_created", "collaborative") + + def __init__( + self, + manager_getter: Callable[[], Any], + discover_callable: Optional[DiscoverCallable] = None, + ): + """``manager_getter`` returns a live ``ListenBrainzManager`` for + the current profile. ``None`` is allowed and means "no LB + configured" — adapter degrades to empty results. + + ``discover_callable`` runs the actual matching-engine + provider + search. ``None`` means no discovery is wired (Phase 0 default): + ``discover_tracks`` returns the input list unchanged.""" + self._manager_getter = manager_getter + self._discover_callable = discover_callable + + def _manager(self): + return self._manager_getter() + + def is_authenticated(self) -> bool: + manager = self._manager() + if manager is None: + return False + client = getattr(manager, "client", None) + if client is None or not hasattr(client, "is_authenticated"): + return False + return bool(client.is_authenticated()) + + def list_playlists(self) -> List[PlaylistMeta]: + manager = self._manager() + if manager is None: + return [] + out: List[PlaylistMeta] = [] + for ptype in self.PLAYLIST_TYPES: + try: + rows = manager.get_cached_playlists(ptype) or [] + except Exception: + rows = [] + for row in rows: + out.append(self._meta_from_cache_row(row, ptype)) + return out + + def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: + """``playlist_id`` is the ListenBrainz playlist MBID, OR a + synthetic series id (e.g. ``lb_weekly_jams_``) that + resolves to the newest member of a rotating series.""" + manager = self._manager() + if manager is None: + return None + + # Rolling-series resolution: synthetic ids look up the + # latest matching cache row and continue with that MBID. + from core.playlists.lb_series import is_series_synthetic_id + if is_series_synthetic_id(playlist_id): + resolved_mbid = self._resolve_series_to_latest_mbid(manager, playlist_id) + if not resolved_mbid: + return None + return self._fetch_playlist_by_mbid(manager, resolved_mbid, override_meta_id=playlist_id) + + return self._fetch_playlist_by_mbid(manager, playlist_id) + + def _fetch_playlist_by_mbid( + self, + manager: Any, + playlist_mbid: str, + override_meta_id: Optional[str] = None, + ) -> Optional[PlaylistDetail]: + """Resolve a real LB playlist MBID into a PlaylistDetail. + + ``override_meta_id`` lets the rolling-series path keep the + synthetic id on the meta object so the caller can write the + mirror row back under that id.""" + ptype = "" + try: + ptype = manager.get_playlist_type(playlist_mbid) or "" + except Exception: + ptype = "" + + cached_rows = [] + try: + cached_rows = manager.get_cached_playlists(ptype) if ptype else [] + except Exception: + cached_rows = [] + meta_row = next( + (r for r in cached_rows if str(r.get("playlist_mbid")) == str(playlist_mbid)), + None, + ) + + try: + tracks_raw = manager.get_cached_tracks(playlist_mbid) or [] + except Exception: + tracks_raw = [] + + if meta_row is None and not tracks_raw: + return None + + meta = self._meta_from_cache_row( + meta_row or {"playlist_mbid": playlist_mbid, "track_count": len(tracks_raw)}, + ptype or "listenbrainz", + ) + if override_meta_id: + meta.source_playlist_id = override_meta_id + meta.track_count = len(tracks_raw) + tracks = [self._track_from_cache_row(t, idx) for idx, t in enumerate(tracks_raw)] + return PlaylistDetail(meta=meta, tracks=tracks) + + def _resolve_series_to_latest_mbid(self, manager: Any, series_id: str) -> Optional[str]: + """Find the newest LB cache row matching a series synthetic id. + + Series synthetic ids encode both the series type and the + ListenBrainz username. We query the LB cache (via the + manager's DB connection) for the row whose title matches the + series' LIKE pattern and has the most recent ``last_updated``, + then return that row's MBID for normal fetching downstream.""" + try: + # The synthetic id alone doesn't carry the title pattern, + # so we re-derive it from any per-period sibling that's + # already in the cache. Iterate the known series specs and + # ask which one this synthetic id belongs to. + from core.playlists.lb_series import _SERIES_PATTERNS + spec = None + user_token = "" + for entry in _SERIES_PATTERNS: + series_prefix = entry["series_format"].format(user="").rstrip("_") + "_" + if series_id.startswith(series_prefix): + spec = entry + user_token = series_id[len(series_prefix):] + break + if spec is None or not user_token: + return None + like_pattern = spec["like_format"].format(user=user_token) + + # Query the LB cache for the newest matching row. The + # manager's connection helper returns a plain sqlite3 + # connection — explicit try/finally for close parity with + # the manager's own usage pattern. + conn = manager._get_db_connection() + try: + cur = conn.cursor() + cur.execute( + """ + SELECT playlist_mbid FROM listenbrainz_playlists + WHERE profile_id = ? AND title LIKE ? + ORDER BY last_updated DESC + LIMIT 1 + """, + (manager.profile_id, like_pattern), + ) + row = cur.fetchone() + finally: + conn.close() + return row[0] if row else None + except Exception: + return None + + def discover_tracks(self, tracks: List[NormalizedTrack]) -> List[NormalizedTrack]: + """Run each MB-metadata track through the matching engine. + + Tracks with ``needs_discovery=False`` (e.g. already-matched + survivors of a previous refresh) pass through unchanged. + Matched tracks get ``extra['discovered']=True`` + a + ``matched_data`` block so the projection helper can produce + the canonical ``extra_data`` JSON; ``needs_discovery`` flips + to False on them. + + Unmatched tracks stay ``needs_discovery=True`` so the caller + can decide how to handle them (wing-it stub, skip, retry).""" + if not tracks or self._discover_callable is None: + return tracks + + to_match: List[Dict[str, Any]] = [] + match_indices: List[int] = [] + for idx, t in enumerate(tracks): + if not t.needs_discovery: + continue + to_match.append({ + "track_name": t.track_name, + "artist_name": t.artist_name, + "album_name": t.album_name or "", + "duration_ms": t.duration_ms or 0, + }) + match_indices.append(idx) + + if not to_match: + return tracks + + try: + matched = self._discover_callable(to_match) or [] + except Exception: + return tracks + + out = list(tracks) + for slot_idx, result in zip(match_indices, matched, strict=False): + if not result: + continue + track = out[slot_idx] + provider = result.pop("_provider", None) or "unknown" + confidence = result.pop("_confidence", None) + new_extra = dict(track.extra or {}) + new_extra["discovered"] = True + new_extra["provider"] = provider + if confidence is not None: + new_extra["confidence"] = confidence + new_extra["matched_data"] = result + out[slot_idx] = NormalizedTrack( + position=track.position, + track_name=track.track_name, + artist_name=track.artist_name, + album_name=track.album_name, + duration_ms=track.duration_ms, + source_track_id=result.get("id") or track.source_track_id, + image_url=result.get("image_url") or track.image_url, + needs_discovery=False, + extra=new_extra, + ) + return out + + def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: + """Trigger a manager-side refresh, then return the new snapshot. + + ``update_all_playlists`` is the only refresh entry-point on the + manager — it re-fetches every cached playlist. That's wasteful + for a single-playlist refresh; Phase 1 should add a targeted + ``refresh_playlist(mbid)`` to the manager.""" + manager = self._manager() + if manager is None: + return None + try: + manager.update_all_playlists() + except Exception: # noqa: S110 — caller falls back to last cached playlist on refresh failure + pass + return self.get_playlist(playlist_id) + + # ---- projection helpers ------------------------------------------------ + + def _meta_from_cache_row(self, row: Dict[str, Any], playlist_type: str) -> PlaylistMeta: + return PlaylistMeta( + source=self.name, + source_playlist_id=str(row.get("playlist_mbid", "")), + name=row.get("title") or "ListenBrainz Playlist", + owner=row.get("creator") or None, + track_count=int(row.get("track_count", 0) or 0), + extra={ + "playlist_type": playlist_type, + "annotation": row.get("annotation") or {}, + "last_updated": row.get("last_updated"), + }, + ) + + def _track_from_cache_row(self, row: Dict[str, Any], position: int) -> NormalizedTrack: + return NormalizedTrack( + position=position, + track_name=row.get("track_name", "Unknown Track"), + artist_name=row.get("artist_name", "Unknown Artist"), + album_name=row.get("album_name") or None, + duration_ms=int(row.get("duration_ms", 0) or 0), + source_track_id=row.get("recording_mbid") or None, + image_url=row.get("album_cover_url") or None, + needs_discovery=True, + extra={ + "recording_mbid": row.get("recording_mbid"), + "release_mbid": row.get("release_mbid"), + "additional_metadata": row.get("additional_metadata"), + }, + ) diff --git a/core/playlists/sources/qobuz.py b/core/playlists/sources/qobuz.py new file mode 100644 index 00000000..acc79623 --- /dev/null +++ b/core/playlists/sources/qobuz.py @@ -0,0 +1,94 @@ +"""Qobuz playlist source adapter. + +Wraps ``core.qobuz_client.QobuzClient``. The client already returns +playlist/track dicts in a normalized Sync-page shape (see +``_normalize_qobuz_playlist`` / ``_normalize_qobuz_track``), so the +adapter is mostly a key remap. +""" + +from __future__ import annotations + +from typing import Any, Callable, Dict, List, Optional + +from core.playlists.sources.base import ( + NormalizedTrack, + PlaylistDetail, + PlaylistMeta, + PlaylistSource, + SOURCE_QOBUZ, +) + + +class QobuzPlaylistSource(PlaylistSource): + name = SOURCE_QOBUZ + supports_listing = True + supports_refresh = True + requires_auth = True + + def __init__(self, client_getter: Callable[[], Any]): + self._client_getter = client_getter + + def _client(self): + return self._client_getter() + + def is_authenticated(self) -> bool: + client = self._client() + if client is None: + return False + return bool(client.is_authenticated()) + + def list_playlists(self) -> List[PlaylistMeta]: + client = self._client() + if client is None or not client.is_authenticated(): + return [] + playlists = client.get_user_playlists() or [] + return [self._meta_from_dict(p) for p in playlists] + + def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: + client = self._client() + if client is None or not client.is_authenticated(): + return None + playlist = client.get_playlist(playlist_id) + if not playlist: + return None + meta = self._meta_from_dict(playlist) + tracks_raw = playlist.get("tracks") or [] + tracks = [self._track_from_dict(t, idx) for idx, t in enumerate(tracks_raw)] + meta.track_count = len(tracks) + return PlaylistDetail(meta=meta, tracks=tracks) + + def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: + return self.get_playlist(playlist_id) + + # ---- projection helpers ------------------------------------------------ + + def _meta_from_dict(self, p: Dict[str, Any]) -> PlaylistMeta: + return PlaylistMeta( + source=self.name, + source_playlist_id=str(p.get("id", "")), + name=p.get("name", "Qobuz Playlist"), + description=p.get("description") or None, + image_url=p.get("image_url") or None, + track_count=int(p.get("track_count", 0) or 0), + extra={ + "public": bool(p.get("public", False)), + "external_urls": p.get("external_urls", {}), + }, + ) + + def _track_from_dict(self, t: Dict[str, Any], position: int) -> NormalizedTrack: + artists = t.get("artists") or [] + artist_name = artists[0] if artists else "Unknown Artist" + return NormalizedTrack( + position=position, + track_name=t.get("name", "Unknown Track"), + artist_name=artist_name, + album_name=t.get("album") or None, + duration_ms=int(t.get("duration_ms", 0) or 0), + source_track_id=str(t.get("id", "")), + image_url=t.get("image_url") or None, + needs_discovery=False, + extra={k: v for k, v in t.items() if k not in { + "id", "name", "artists", "album", "duration_ms", "image_url", + }}, + ) diff --git a/core/playlists/sources/registry.py b/core/playlists/sources/registry.py new file mode 100644 index 00000000..e4327f20 --- /dev/null +++ b/core/playlists/sources/registry.py @@ -0,0 +1,77 @@ +"""Registry for playlist source adapters. + +Adapters are registered as zero-arg factories so we can lazy-construct +them. This matters because some adapters need late-binding to globals +that aren't ready at import time (e.g. the YouTube adapter wraps a +parser defined in ``web_server.py`` — importing it eagerly would cause +a circular import). + +Usage:: + + registry = get_registry() + registry.register("spotify", lambda: SpotifyPlaylistSource(...)) + source = registry.get_source("spotify") + +In Phase 0 the registry is set up but not yet consumed by the dispatch +sites. Phase 1+ wires it in. +""" + +from __future__ import annotations + +from threading import Lock +from typing import Callable, Dict, List, Optional + +from core.playlists.sources.base import PlaylistSource + + +class PlaylistSourceRegistry: + """Thread-safe registry mapping source name → cached adapter instance.""" + + def __init__(self) -> None: + self._factories: Dict[str, Callable[[], PlaylistSource]] = {} + self._instances: Dict[str, PlaylistSource] = {} + self._lock = Lock() + + def register(self, name: str, factory: Callable[[], PlaylistSource]) -> None: + """Register an adapter factory under ``name``. + + Re-registering replaces the previous factory and invalidates the + cached instance. Used by tests to swap in stubs.""" + with self._lock: + self._factories[name] = factory + self._instances.pop(name, None) + + def unregister(self, name: str) -> None: + with self._lock: + self._factories.pop(name, None) + self._instances.pop(name, None) + + def get_source(self, name: str) -> Optional[PlaylistSource]: + """Return the adapter for ``name``, building it on first access.""" + with self._lock: + if name in self._instances: + return self._instances[name] + factory = self._factories.get(name) + if factory is None: + return None + instance = factory() + self._instances[name] = instance + return instance + + def known_names(self) -> List[str]: + with self._lock: + return sorted(self._factories.keys()) + + def reset(self) -> None: + """Drop all registrations + cached instances. Test-only.""" + with self._lock: + self._factories.clear() + self._instances.clear() + + +_default_registry = PlaylistSourceRegistry() + + +def get_registry() -> PlaylistSourceRegistry: + """Return the process-wide default registry.""" + return _default_registry diff --git a/core/playlists/sources/soulsync_discovery.py b/core/playlists/sources/soulsync_discovery.py new file mode 100644 index 00000000..d2cfec25 --- /dev/null +++ b/core/playlists/sources/soulsync_discovery.py @@ -0,0 +1,149 @@ +"""SoulSync Discovery (personalized playlists) source adapter. + +Wraps ``core.personalized.manager.PersonalizedPlaylistManager``. Unlike +ListenBrainz / Last.fm, personalized playlists already carry source +IDs (Spotify / iTunes / Deezer track IDs) — they were built from +``discovery_pool`` rows. ``needs_discovery=False`` on every track. + +Playlist IDs here are the integer DB row IDs (``personalized_playlists.id``) +converted to strings so the unified interface stays string-keyed. The +adapter parses them back to ints when calling the manager. +""" + +from __future__ import annotations + +from typing import Any, Callable, List, Optional + +from core.playlists.sources.base import ( + NormalizedTrack, + PlaylistDetail, + PlaylistMeta, + PlaylistSource, + SOURCE_SOULSYNC_DISCOVERY, +) + + +class SoulSyncDiscoveryPlaylistSource(PlaylistSource): + name = SOURCE_SOULSYNC_DISCOVERY + supports_listing = True + supports_refresh = True + requires_auth = False + + def __init__( + self, + manager_getter: Callable[[], Any], + profile_id_getter: Optional[Callable[[], int]] = None, + ): + self._manager_getter = manager_getter + self._profile_id_getter = profile_id_getter or (lambda: 1) + + def _manager(self): + return self._manager_getter() + + def is_authenticated(self) -> bool: + return self._manager() is not None + + def list_playlists(self) -> List[PlaylistMeta]: + manager = self._manager() + if manager is None: + return [] + try: + records = manager.list_playlists(profile_id=self._profile_id_getter()) or [] + except Exception: + return [] + return [self._meta_from_record(r) for r in records] + + def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: + """``playlist_id`` is the stringified ``personalized_playlists.id``.""" + manager = self._manager() + if manager is None: + return None + try: + row_id = int(playlist_id) + except (TypeError, ValueError): + return None + + records = [] + try: + records = manager.list_playlists(profile_id=self._profile_id_getter()) or [] + except Exception: + records = [] + record = next((r for r in records if int(r.id) == row_id), None) + if record is None: + return None + + try: + tracks_raw = manager.get_playlist_tracks(row_id) or [] + except Exception: + tracks_raw = [] + + meta = self._meta_from_record(record) + meta.track_count = len(tracks_raw) + tracks = [self._track_from_record(t, idx) for idx, t in enumerate(tracks_raw)] + return PlaylistDetail(meta=meta, tracks=tracks) + + def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: + manager = self._manager() + if manager is None: + return None + try: + row_id = int(playlist_id) + except (TypeError, ValueError): + return None + + records = manager.list_playlists(profile_id=self._profile_id_getter()) or [] + record = next((r for r in records if int(r.id) == row_id), None) + if record is None: + return None + + try: + manager.refresh_playlist( + kind=record.kind, + variant=record.variant, + profile_id=record.profile_id, + ) + except Exception: # noqa: S110 — manager persists last_generation_error on failure; surface existing snapshot + pass + return self.get_playlist(playlist_id) + + # ---- projection helpers ------------------------------------------------ + + def _meta_from_record(self, record: Any) -> PlaylistMeta: + return PlaylistMeta( + source=self.name, + source_playlist_id=str(record.id), + name=record.name, + track_count=int(record.track_count or 0), + description=record.kind, + extra={ + "kind": record.kind, + "variant": record.variant, + "profile_id": record.profile_id, + "is_stale": bool(record.is_stale), + "last_generated_at": record.last_generated_at, + "last_synced_at": record.last_synced_at, + "last_generation_source": record.last_generation_source, + "last_generation_error": record.last_generation_error, + }, + ) + + def _track_from_record(self, track: Any, position: int) -> NormalizedTrack: + primary_id = track.primary_id() + return NormalizedTrack( + position=position, + track_name=track.track_name, + artist_name=track.artist_name, + album_name=track.album_name or None, + duration_ms=int(track.duration_ms or 0), + source_track_id=primary_id, + image_url=track.album_cover_url or None, + needs_discovery=False, + extra={ + "spotify_track_id": track.spotify_track_id, + "itunes_track_id": track.itunes_track_id, + "deezer_track_id": track.deezer_track_id, + "popularity": track.popularity, + "track_data_json": track.track_data_json, + "source_hint": track.source, + }, + ) diff --git a/core/playlists/sources/spotify.py b/core/playlists/sources/spotify.py new file mode 100644 index 00000000..78bf8e67 --- /dev/null +++ b/core/playlists/sources/spotify.py @@ -0,0 +1,137 @@ +"""Spotify playlist source adapter. + +Wraps ``core.spotify_client.SpotifyClient`` — the authenticated Spotify +client used everywhere else. Adapter projects Spotify's ``Playlist`` / +``Track`` dataclasses into ``PlaylistMeta`` / ``NormalizedTrack``. +""" + +from __future__ import annotations + +from typing import Any, Callable, Dict, List, Optional + +from core.playlists.sources.base import ( + NormalizedTrack, + PlaylistDetail, + PlaylistMeta, + PlaylistSource, + SOURCE_SPOTIFY, +) + + +class SpotifyPlaylistSource(PlaylistSource): + name = SOURCE_SPOTIFY + supports_listing = True + supports_refresh = True + requires_auth = True + + def __init__(self, client_getter: Callable[[], Any]): + """``client_getter`` returns the live ``SpotifyClient`` singleton. + + We accept a getter (not the client itself) so the adapter can be + constructed at import time, before ``web_server.py`` has wired + up the singleton.""" + self._client_getter = client_getter + + def _client(self): + return self._client_getter() + + def is_authenticated(self) -> bool: + client = self._client() + if client is None: + return False + # ``is_spotify_authenticated`` is the Spotify-specific check; + # ``is_authenticated`` on SpotifyClient is a metadata-aware + # superset that returns True even when only the iTunes + # fallback is available. The adapter needs the strict check + # because it calls Spotify-only endpoints (get_user_playlists, + # get_playlist_by_id). + check = getattr(client, "is_spotify_authenticated", None) or client.is_authenticated + return bool(check()) + + def list_playlists(self) -> List[PlaylistMeta]: + client = self._client() + if client is None or not self.is_authenticated(): + return [] + playlists = client.get_user_playlists_metadata_only() + return [self._meta_from_playlist(p) for p in playlists] + + def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: + client = self._client() + if client is None or not self.is_authenticated(): + return None + playlist = client.get_playlist_by_id(playlist_id) + if playlist is None: + return None + meta = self._meta_from_playlist(playlist) + tracks = [self._track_from_spotify(t, idx) for idx, t in enumerate(playlist.tracks)] + meta.track_count = len(tracks) + return PlaylistDetail(meta=meta, tracks=tracks) + + def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: + return self.get_playlist(playlist_id) + + # ---- projection helpers ------------------------------------------------ + + def _meta_from_playlist(self, playlist: Any) -> PlaylistMeta: + return PlaylistMeta( + source=self.name, + source_playlist_id=str(playlist.id), + name=playlist.name, + owner=playlist.owner, + description=playlist.description, + track_count=int(getattr(playlist, "total_tracks", 0) or 0), + extra={ + "public": bool(getattr(playlist, "public", False)), + "collaborative": bool(getattr(playlist, "collaborative", False)), + }, + ) + + def _track_from_spotify(self, track: Any, position: int) -> NormalizedTrack: + artists = getattr(track, "artists", None) or [] + artist_name = artists[0] if artists else "Unknown Artist" + track_id = str(track.id) if getattr(track, "id", None) else "" + track_name = track.name or "" + album_name = getattr(track, "album", "") or "" + duration_ms = int(getattr(track, "duration_ms", 0) or 0) + image_url = getattr(track, "image_url", None) + + # Spotify's authenticated API IS canonical metadata — populate + # the discovered/matched_data block so to_mirror_track_dict emits + # the same extra_data shape downstream consumers (sync, wishlist) + # already expect from this path. + extra: Dict[str, Any] = { + "popularity": getattr(track, "popularity", 0), + "external_urls": getattr(track, "external_urls", None), + "preview_url": getattr(track, "preview_url", None), + } + if track_id: + album_obj: Dict[str, Any] = {"name": album_name} + if image_url: + album_obj["images"] = [{ + "url": image_url, + "height": 600, + "width": 600, + }] + extra["discovered"] = True + extra["provider"] = "spotify" + extra["confidence"] = 1.0 + extra["matched_data"] = { + "id": track_id, + "name": track_name, + "artists": [{"name": str(a)} for a in artists], + "album": album_obj, + "duration_ms": duration_ms, + "image_url": image_url, + } + + return NormalizedTrack( + position=position, + track_name=track_name, + artist_name=str(artist_name), + album_name=album_name or None, + duration_ms=duration_ms, + source_track_id=track_id, + image_url=image_url, + needs_discovery=False, + extra=extra, + ) diff --git a/core/playlists/sources/spotify_public.py b/core/playlists/sources/spotify_public.py new file mode 100644 index 00000000..51189687 --- /dev/null +++ b/core/playlists/sources/spotify_public.py @@ -0,0 +1,114 @@ +"""Spotify public-embed playlist source adapter. + +Wraps ``core.spotify_public_scraper`` — no auth, scrapes the public +embed page. ``supports_listing=False`` because there's no "user +library" to enumerate; the user pastes a URL and the adapter fetches. +""" + +from __future__ import annotations + +import hashlib +from typing import Any, Dict, List, Optional + +from core.playlists.sources.base import ( + NormalizedTrack, + PlaylistDetail, + PlaylistMeta, + PlaylistSource, + SOURCE_SPOTIFY_PUBLIC, +) + + +class SpotifyPublicPlaylistSource(PlaylistSource): + name = SOURCE_SPOTIFY_PUBLIC + supports_listing = False + supports_refresh = True + requires_auth = False + + def is_authenticated(self) -> bool: + return True + + def list_playlists(self) -> List[PlaylistMeta]: + return [] + + def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: + """``playlist_id`` is a Spotify URL or ``open.spotify.com`` URI.""" + from core.spotify_public_scraper import ( + parse_spotify_url, + scrape_spotify_embed, + ) + + parsed = parse_spotify_url(playlist_id) + if not parsed: + return None + + data = scrape_spotify_embed(parsed["type"], parsed["id"]) + if not isinstance(data, dict) or data.get("error"): + return None + + source_url = data.get("url") or playlist_id + url_hash = data.get("url_hash") or hashlib.md5(source_url.encode()).hexdigest()[:12] + tracks_raw = data.get("tracks") or [] + + meta = PlaylistMeta( + source=self.name, + source_playlist_id=url_hash, + name=data.get("name", "Spotify Playlist"), + owner=data.get("subtitle"), + track_count=len(tracks_raw), + source_url=source_url, + extra={ + "spotify_type": data.get("type"), + "spotify_id": data.get("id"), + }, + ) + + tracks = [ + self._track_from_embed(t, idx) + for idx, t in enumerate(tracks_raw) + if t and t.get("id") + ] + return PlaylistDetail(meta=meta, tracks=tracks) + + def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: + return self.get_playlist(playlist_id) + + # ---- projection helpers ------------------------------------------------ + + def _track_from_embed(self, track: dict, position: int) -> NormalizedTrack: + artists = track.get("artists") or [] + if artists and isinstance(artists[0], dict): + artist_name = artists[0].get("name", "") or "Unknown Artist" + elif artists: + artist_name = str(artists[0]) + else: + artist_name = "Unknown Artist" + track_id = str(track.get("id", "")) + track_name = track.get("name", "") + + # Public-embed data isn't canonical (no album art in the embed + # response), so we DON'T set ``discovered=True``. Instead, plant + # a ``spotify_hint`` so the downstream discovery worker can skip + # its search step and go straight to Spotify enrichment for the + # known track ID. Matches the pre-extraction handler behavior. + extra: Dict[str, Any] = { + "explicit": bool(track.get("is_explicit", False)), + "track_number": track.get("track_number"), + } + if track_id: + extra["spotify_hint"] = { + "id": track_id, + "name": track_name, + "artists": artists, + } + + return NormalizedTrack( + position=position, + track_name=track_name, + artist_name=artist_name, + album_name=None, + duration_ms=int(track.get("duration_ms", 0) or 0), + source_track_id=track_id, + needs_discovery=False, + extra=extra, + ) diff --git a/core/playlists/sources/tidal.py b/core/playlists/sources/tidal.py new file mode 100644 index 00000000..b7dfff32 --- /dev/null +++ b/core/playlists/sources/tidal.py @@ -0,0 +1,103 @@ +"""Tidal playlist source adapter. + +Wraps ``core.tidal_client.TidalClient``. Tidal recognizes the virtual +``tidal-favorites`` ID inside ``get_playlist`` already, so the adapter +doesn't need to special-case it. +""" + +from __future__ import annotations + +from typing import Any, Callable, List, Optional + +from core.playlists.sources.base import ( + NormalizedTrack, + PlaylistDetail, + PlaylistMeta, + PlaylistSource, + SOURCE_TIDAL, +) + + +class TidalPlaylistSource(PlaylistSource): + name = SOURCE_TIDAL + supports_listing = True + supports_refresh = True + requires_auth = True + + def __init__(self, client_getter: Callable[[], Any]): + self._client_getter = client_getter + + def _client(self): + return self._client_getter() + + def is_authenticated(self) -> bool: + client = self._client() + if client is None: + return False + return bool(client.is_authenticated()) + + def list_playlists(self) -> List[PlaylistMeta]: + client = self._client() + if client is None or not client.is_authenticated(): + return [] + playlists = client.get_user_playlists_metadata_only() or [] + return [self._meta_from_playlist(p) for p in playlists] + + def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: + client = self._client() + if client is None or not client.is_authenticated(): + return None + playlist = client.get_playlist(playlist_id) + if playlist is None: + return None + meta = self._meta_from_playlist(playlist) + tracks_raw = getattr(playlist, "tracks", None) or [] + tracks = [self._track_from_tidal(t, idx) for idx, t in enumerate(tracks_raw)] + meta.track_count = len(tracks) + return PlaylistDetail(meta=meta, tracks=tracks) + + def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: + return self.get_playlist(playlist_id) + + # ---- projection helpers ------------------------------------------------ + + def _meta_from_playlist(self, playlist: Any) -> PlaylistMeta: + owner_field = getattr(playlist, "owner", None) + owner_name: Optional[str] = None + if isinstance(owner_field, dict): + owner_name = owner_field.get("name") or owner_field.get("id") + elif owner_field: + owner_name = str(owner_field) + tracks_raw = getattr(playlist, "tracks", None) or [] + return PlaylistMeta( + source=self.name, + source_playlist_id=str(playlist.id), + name=playlist.name, + owner=owner_name, + description=getattr(playlist, "description", "") or None, + track_count=len(tracks_raw), + extra={ + "public": bool(getattr(playlist, "public", True)), + "external_urls": getattr(playlist, "external_urls", {}), + }, + ) + + def _track_from_tidal(self, track: Any, position: int) -> NormalizedTrack: + artists = getattr(track, "artists", None) or [] + # First artist only — matches the mirrored_playlist shape the + # legacy refresh_mirrored handler wrote. + artist_name = artists[0] if artists else "Unknown Artist" + return NormalizedTrack( + position=position, + track_name=track.name, + artist_name=artist_name, + album_name=getattr(track, "album", "") or None, + duration_ms=int(getattr(track, "duration_ms", 0) or 0), + source_track_id=str(track.id), + needs_discovery=False, + extra={ + "explicit": bool(getattr(track, "explicit", False)), + "external_urls": getattr(track, "external_urls", {}), + "popularity": getattr(track, "popularity", 0), + }, + ) diff --git a/core/playlists/sources/youtube.py b/core/playlists/sources/youtube.py new file mode 100644 index 00000000..e8b976f9 --- /dev/null +++ b/core/playlists/sources/youtube.py @@ -0,0 +1,87 @@ +"""YouTube playlist source adapter. + +Wraps ``parse_youtube_playlist`` (currently a free function in +``web_server.py`` — Phase 0 doesn't move it, just calls it via an +injected callable to avoid the circular import). ``supports_listing`` +is False — YouTube playlists are URL-input only, no user library. +""" + +from __future__ import annotations + +import hashlib +from typing import Any, Callable, List, Optional + +from core.playlists.sources.base import ( + NormalizedTrack, + PlaylistDetail, + PlaylistMeta, + PlaylistSource, + SOURCE_YOUTUBE, +) + + +class YouTubePlaylistSource(PlaylistSource): + name = SOURCE_YOUTUBE + supports_listing = False + supports_refresh = True + requires_auth = False + + def __init__(self, parser: Callable[[str], Optional[dict]]): + """``parser`` matches the signature of ``parse_youtube_playlist`` + in web_server.py — takes a URL, returns the playlist dict or + ``None``. Injected so adapter can be constructed at import time.""" + self._parser = parser + + def is_authenticated(self) -> bool: + return True + + def list_playlists(self) -> List[PlaylistMeta]: + return [] + + def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: + """``playlist_id`` is the full YouTube playlist URL.""" + data = self._parser(playlist_id) + if not data: + return None + + source_url = data.get("url") or playlist_id + url_hash = hashlib.md5(source_url.encode()).hexdigest()[:12] + tracks_raw = data.get("tracks") or [] + + meta = PlaylistMeta( + source=self.name, + source_playlist_id=url_hash, + name=data.get("name", "YouTube Playlist"), + track_count=int(data.get("track_count", len(tracks_raw))), + image_url=data.get("image_url") or None, + source_url=source_url, + extra={ + "youtube_playlist_id": data.get("id"), + }, + ) + + tracks = [self._track_from_yt(t, idx) for idx, t in enumerate(tracks_raw) if t] + return PlaylistDetail(meta=meta, tracks=tracks) + + def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: + return self.get_playlist(playlist_id) + + # ---- projection helpers ------------------------------------------------ + + def _track_from_yt(self, track: dict, position: int) -> NormalizedTrack: + artists = track.get("artists") or [] + artist_name = artists[0] if artists else "Unknown Artist" + return NormalizedTrack( + position=position, + track_name=track.get("name", "Unknown Track"), + artist_name=artist_name, + album_name=None, + duration_ms=int(track.get("duration_ms", 0) or 0), + source_track_id=str(track.get("id", "")), + needs_discovery=False, + extra={ + "url": track.get("url"), + "raw_title": track.get("raw_title"), + "raw_artist": track.get("raw_artist"), + }, + ) diff --git a/core/text/__init__.py b/core/text/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/core/text/normalize.py b/core/text/normalize.py new file mode 100644 index 00000000..6362ed64 --- /dev/null +++ b/core/text/normalize.py @@ -0,0 +1,41 @@ +"""Shared text-normalization helpers. + +Extracted from `MusicDatabase._normalize_for_comparison` so callers +outside the database layer (matching engine, sync candidate pool, +import comparisons) don't have to reach across the module boundary +into a leading-underscore "private" method. + +Pure functions, no I/O. +""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + +try: + from unidecode import unidecode as _unidecode + _HAS_UNIDECODE = True +except ImportError: + _unidecode = None # type: ignore[assignment] + _HAS_UNIDECODE = False + logger.warning("unidecode not available, accent matching may be limited") + + +def normalize_for_comparison(text: str) -> str: + """Lowercase + strip whitespace + fold accents to ASCII. + + ``é → e``, ``ñ → n``, ``Björk → bjork``. Used as the dictionary key + for the sync candidate pool and for fuzzy library lookups where + diacritic differences must NOT split a single artist into two pool + entries. + + Empty / falsy input returns ``""`` so callers can blindly key dicts + with the result. + """ + if not text: + return "" + if _HAS_UNIDECODE: + text = _unidecode(text) + return text.lower().strip() diff --git a/core/wishlist/album_grouping.py b/core/wishlist/album_grouping.py new file mode 100644 index 00000000..148ea263 --- /dev/null +++ b/core/wishlist/album_grouping.py @@ -0,0 +1,201 @@ +"""Wishlist album grouping for the per-album bundle dispatch. + +When the auto-wishlist cycle is ``'albums'`` the user expects each +album with missing tracks to fire ONE album-bundle search instead +of one per-track search per missing track. Track lists in the +wishlist may span multiple albums in one cycle, so we group them +upfront + emit one sub-batch per album. + +Pure function — no IO, no runtime-state dependency — so it can be +unit-tested without standing up the wishlist runner. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +def _extract_track_data(track: Dict[str, Any]) -> Dict[str, Any]: + """Mirror of ``classification._extract_track_data``: unwrap nested + Spotify payloads regardless of which key the wishlist row chose + to stash them under.""" + for key in ("track_data", "spotify_data", "metadata", "track"): + data = track.get(key) + if isinstance(data, str): + try: + data = json.loads(data) + except Exception: + data = {} + if isinstance(data, dict) and data: + nested = ( + data.get("track_data") + or data.get("spotify_data") + or data.get("metadata") + or data.get("track") + ) + if isinstance(nested, str): + try: + nested = json.loads(nested) + except Exception: + nested = {} + if isinstance(nested, dict) and nested: + return nested + return data + return {} + + +def _album_key(spotify_data: Dict[str, Any]) -> Optional[str]: + """Derive a stable grouping key from a track's Spotify metadata. + + Prefers album id (canonical). Falls back to a name-normalized + key when the album row has no id (older wishlist rows can be + missing it). Returns ``None`` when no album information is + available at all — those tracks can't participate in an + album-bundle search and stay on the residual per-track flow. + """ + album = spotify_data.get('album') or {} + if not isinstance(album, dict): + return None + album_id = album.get('id') + if isinstance(album_id, str) and album_id.strip(): + return album_id.strip() + name = album.get('name') + if isinstance(name, str) and name.strip(): + return f"_name_{name.strip().lower()}" + return None + + +def _artist_name_from_track(spotify_data: Dict[str, Any], track: Dict[str, Any]) -> str: + """Pick a primary artist name from the track's metadata. + + Album-bundle search needs an artist string. Prefer the first + Spotify artist (most accurate), fall back to ``track_info['artist']`` + or ``track['artist_name']`` from the wishlist row, then to empty + string (caller will skip the bundle). + """ + artists = spotify_data.get('artists') or [] + if isinstance(artists, list) and artists: + first = artists[0] + if isinstance(first, dict): + name = first.get('name') + if isinstance(name, str) and name.strip(): + return name.strip() + elif isinstance(first, str) and first.strip(): + return first.strip() + for key in ('artist_name', 'artist'): + val = track.get(key) + if isinstance(val, str) and val.strip(): + return val.strip() + return '' + + +@dataclass +class WishlistAlbumGroup: + """One album's worth of wishlist tracks ready for a sub-batch.""" + + album_key: str + album_context: Dict[str, Any] + artist_context: Dict[str, Any] + tracks: List[Dict[str, Any]] = field(default_factory=list) + + +@dataclass +class WishlistGroupingResult: + """Aggregated grouping output. + + - ``album_groups``: one entry per resolvable album. Each carries + enough context to be submitted as an album-bundle batch. + - ``residual_tracks``: tracks that couldn't be grouped (no + album metadata + no artist). They fall through to the normal + per-track flow. + """ + + album_groups: List[WishlistAlbumGroup] = field(default_factory=list) + residual_tracks: List[Dict[str, Any]] = field(default_factory=list) + + +def group_wishlist_tracks_by_album( + tracks: List[Dict[str, Any]], + *, + min_tracks_per_album: int = 1, +) -> WishlistGroupingResult: + """Group wishlist tracks by their owning album. + + ``min_tracks_per_album`` controls the threshold for promoting an + album to its own sub-batch. Default ``1`` means even a single + missing track gets the album-bundle treatment (which is what the + user wants for releases where they only need one track from the + album). Set higher to require multiple missing tracks before + engaging the bundle search. + """ + result = WishlistGroupingResult() + if not tracks: + return result + + # First pass: bucket by album key. + buckets: Dict[str, WishlistAlbumGroup] = {} + unbucketable: List[Dict[str, Any]] = [] + + for track in tracks: + spotify_data = _extract_track_data(track) + key = _album_key(spotify_data) + if key is None: + unbucketable.append(track) + continue + + artist_name = _artist_name_from_track(spotify_data, track) + if not artist_name: + unbucketable.append(track) + continue + + album = spotify_data.get('album') or {} + if not isinstance(album, dict): + album = {} + album_name = album.get('name', '') + if not (isinstance(album_name, str) and album_name.strip()): + unbucketable.append(track) + continue + + group = buckets.get(key) + if group is None: + album_context = { + 'id': album.get('id') or key, + 'name': album_name.strip(), + 'release_date': album.get('release_date', ''), + 'total_tracks': album.get('total_tracks', 0), + 'album_type': album.get('album_type', 'album'), + 'images': album.get('images', []), + 'artists': album.get('artists', []), + } + artist_context = { + 'id': 'wishlist', + 'name': artist_name, + 'genres': [], + } + group = WishlistAlbumGroup( + album_key=key, + album_context=album_context, + artist_context=artist_context, + ) + buckets[key] = group + group.tracks.append(track) + + # Second pass: promote groups meeting the threshold; demote + # smaller groups to residual. + for group in buckets.values(): + if len(group.tracks) >= min_tracks_per_album: + result.album_groups.append(group) + else: + result.residual_tracks.extend(group.tracks) + + result.residual_tracks.extend(unbucketable) + return result + + +__all__ = [ + 'group_wishlist_tracks_by_album', + 'WishlistAlbumGroup', + 'WishlistGroupingResult', +] diff --git a/core/wishlist/payloads.py b/core/wishlist/payloads.py index df0098b8..50f3a0e7 100644 --- a/core/wishlist/payloads.py +++ b/core/wishlist/payloads.py @@ -106,8 +106,6 @@ def ensure_wishlist_track_format(track_info): else: album = { 'name': str(album_data) if album_data else track_info.get('name', 'Unknown Album'), - 'album_type': 'single', - 'total_tracks': 1, 'release_date': '', } album.setdefault('images', []) @@ -351,7 +349,7 @@ def extract_wishlist_track_from_modal_info(track_info: Dict[str, Any]) -> Option "id": f"reconstructed_{hash(f'{slskd_result.artist}_{slskd_result.title}')}", "name": getattr(slskd_result, "title", "Unknown Track"), "artists": [{"name": getattr(slskd_result, "artist", "Unknown Artist")}], - "album": {"name": album_name, "images": [], "album_type": "single", "total_tracks": 1}, + "album": {"name": album_name, "images": [], "album_type": "album", "total_tracks": 0}, "duration_ms": 0, "reconstructed": True, } diff --git a/core/wishlist/processing.py b/core/wishlist/processing.py index 3a8cbf9d..fb2b9ac4 100644 --- a/core/wishlist/processing.py +++ b/core/wishlist/processing.py @@ -148,15 +148,67 @@ def recover_uncaptured_failed_tracks( return recovered_count +def resolve_wishlist_source_type_for_batch(batch: Dict[str, Any]) -> str: + """Pick the wishlist ``source_type`` for failed tracks coming out of a + download batch. + + Album-context batches must produce ``'album'`` provenance — the legacy + hardcoded ``'playlist'`` mislabels every album-batch failure, breaks + the wishlist UI's source filter, and makes ``by_source_type`` stats + look like all wishlist work came from playlists. + """ + return 'album' if batch.get('is_album_download') else 'playlist' + + def build_wishlist_source_context(batch: Dict[str, Any], current_time: datetime | None = None) -> Dict[str, Any]: """Build the source_context payload used when adding failed tracks back to the wishlist.""" current_time = current_time or datetime.now() - return { + context = { 'playlist_name': batch.get('playlist_name', 'Unknown Playlist'), 'playlist_id': batch.get('playlist_id', None), 'added_from': 'webui_modal', 'timestamp': current_time.isoformat(), } + # Preserve album-batch provenance so wishlist requeue has a real signal + # for album-vs-single routing instead of relying on per-track album dicts + # that may have been mangled by reconstruction fallbacks. + if batch.get('is_album_download'): + context['is_album_download'] = True + album_ctx = batch.get('album_context') + if isinstance(album_ctx, dict): + context['album_context'] = album_ctx + artist_ctx = batch.get('artist_context') + if isinstance(artist_ctx, dict): + context['artist_context'] = artist_ctx + return context + + +def _wishlist_run_has_siblings_still_active( + download_batches: Dict[str, Dict[str, Any]], + run_id: str, + completing_batch_id: str, +) -> bool: + """Return True if any sibling batch sharing ``run_id`` is still + pre-terminal. + + Used by ``finalize_auto_wishlist_completion`` to gate the run- + level cycle toggle. The caller already holds ``tasks_lock``. + + The completing batch may or may not have its phase flipped to + 'complete' yet by the time we land here; either way we skip it + in the sibling scan since we're handling its completion now.""" + terminal_phases = {'complete', 'error', 'cancelled'} + for sibling_id, sibling in download_batches.items(): + if sibling_id == completing_batch_id: + continue + if not isinstance(sibling, dict): + continue + if sibling.get('wishlist_run_id') != run_id: + continue + if sibling.get('phase') in terminal_phases: + continue + return True + return False def finalize_auto_wishlist_completion( @@ -171,7 +223,17 @@ def finalize_auto_wishlist_completion( db_factory: Callable[[], Any], logger=logger, ) -> Dict[str, Any]: - """Finalize auto wishlist processing after a batch finishes.""" + """Finalize auto wishlist processing after a batch finishes. + + For wishlist runs that split into multiple sub-batches (Phase + 1c.2.1: per-album bundle dispatch), the cycle toggle + state + reset only fire when the LAST sibling sub-batch of the same + ``wishlist_run_id`` completes. Earlier completions just record + their per-batch summary and return without toggling. + + Back-compat: legacy single-batch runs (no ``wishlist_run_id`` + field on the batch) keep the original toggle-immediately + behavior — the gate treats a missing run_id as "lone batch".""" tracks_added = completion_summary.get('tracks_added', 0) total_failed = completion_summary.get('total_failed', 0) logger.error( @@ -181,6 +243,22 @@ def finalize_auto_wishlist_completion( if tracks_added > 0: add_activity_item("", "Wishlist Updated", f"{tracks_added} failed tracks added to wishlist", "Now") + # Run-level gate: if siblings of the same wishlist run are still + # active, defer cycle toggle + state reset until they finish. + with tasks_lock: + run_id = '' + if batch_id in download_batches: + run_id = download_batches[batch_id].get('wishlist_run_id') or '' + siblings_active = bool(run_id) and _wishlist_run_has_siblings_still_active( + download_batches, run_id, batch_id, + ) + if siblings_active: + logger.info( + f"[Auto-Wishlist] Sub-batch {batch_id[:8]} done; waiting on sibling sub-batches " + f"of run {run_id[:8]} before toggling cycle" + ) + return completion_summary + try: with tasks_lock: if batch_id in download_batches: @@ -445,15 +523,117 @@ def _prepare_and_run_manual_wishlist_batch( for i, track in enumerate(wishlist_tracks): track['_original_index'] = i - # Update batch with the real track count now that filtering is done - with runtime.tasks_lock: - if batch_id in runtime.download_batches: - runtime.download_batches[batch_id]['analysis_total'] = len(wishlist_tracks) - runtime.add_activity_item("", "Wishlist Download Started", f"{len(wishlist_tracks)} tracks", "Now") - logger.info(f"Starting wishlist batch {batch_id} with {len(wishlist_tracks)} tracks") - runtime.run_full_missing_tracks_process(batch_id, "wishlist", wishlist_tracks) + # Try to split into per-album sub-batches so each album fires + # ONE slskd / torrent / usenet album-bundle search (gates on + # ``is_album_download`` + populated album/artist context). + # When a single category was requested (or no category filter) + # we apply the same grouping the auto-wishlist path uses. + # Tracks the grouper can't bucket fall through to a residual + # batch with the classic per-track flow. + from core.wishlist.album_grouping import group_wishlist_tracks_by_album + grouping = group_wishlist_tracks_by_album(wishlist_tracks) + + # Build the final payload list (batch_id, tracks, album_context, + # artist_context, is_album). The first payload re-uses the + # caller-allocated ``batch_id`` so the frontend's existing poll + # against it keeps working. Subsequent payloads get fresh ids. + payloads = [] + for group in grouping.album_groups: + payloads.append({ + 'tracks': group.tracks, + 'is_album': True, + 'album_context': group.album_context, + 'artist_context': group.artist_context, + 'display_name': f"Wishlist (Album: {group.album_context.get('name', 'Unknown')})", + }) + if grouping.residual_tracks: + payloads.append({ + 'tracks': grouping.residual_tracks, + 'is_album': False, + 'album_context': None, + 'artist_context': None, + 'display_name': "Wishlist (Residual)", + }) + + if not payloads: + # Nothing to download — clear out the original batch. + with runtime.tasks_lock: + if batch_id in runtime.download_batches: + runtime.download_batches[batch_id]['analysis_total'] = 0 + runtime.download_batches[batch_id]['phase'] = 'complete' + return + + # Attach the original batch_id to the first payload; allocate + # fresh batch_ids for the rest. + payloads[0]['batch_id'] = batch_id + for payload in payloads[1:]: + payload['batch_id'] = str(uuid.uuid4()) + + # Reify "wishlist run" — one shared id stamped on every sub- + # batch this manual invocation produces. Mirrors the auto + # path. Note manual wishlist completion currently doesn't + # toggle the cycle (only auto does), but the id is set anyway + # so future code + UI grouping have a consistent hook. + wishlist_run_id = str(uuid.uuid4()) + + # Materialize each sub-batch's row state up-front so the + # frontend's polling can see them all under the original + # batch's flow. + with runtime.tasks_lock: + if batch_id in runtime.download_batches: + # Re-purpose the existing row for the first payload. + first = payloads[0] + runtime.download_batches[batch_id]['analysis_total'] = len(first['tracks']) + runtime.download_batches[batch_id]['wishlist_run_id'] = wishlist_run_id + if first['is_album']: + runtime.download_batches[batch_id]['is_album_download'] = True + runtime.download_batches[batch_id]['album_context'] = first['album_context'] + runtime.download_batches[batch_id]['artist_context'] = first['artist_context'] + runtime.download_batches[batch_id]['playlist_name'] = first['display_name'] + for payload in payloads[1:]: + runtime.download_batches[payload['batch_id']] = { + 'phase': 'analysis', + 'playlist_id': 'wishlist', + 'playlist_name': payload['display_name'], + 'queue': [], + 'active_count': 0, + 'max_concurrent': runtime.get_batch_max_concurrent(), + 'queue_index': 0, + 'analysis_total': len(payload['tracks']), + 'analysis_processed': 0, + 'analysis_results': [], + 'permanently_failed_tracks': [], + 'cancelled_tracks': set(), + 'force_download_all': True, + 'profile_id': runtime.profile_id, + 'is_album_download': bool(payload['is_album']), + 'album_context': payload['album_context'], + 'artist_context': payload['artist_context'], + 'wishlist_run_id': wishlist_run_id, + } + + logger.info( + f"[Manual-Wishlist] Split into {len(payloads)} sub-batch(es) " + f"({sum(1 for p in payloads if p['is_album'])} album + " + f"{sum(1 for p in payloads if not p['is_album'])} residual)" + ) + # Serial dispatch — each album-bundle search happens one at a + # time so the slskd / Prowlarr pipeline doesn't fan out across + # multiple parallel release searches. + for payload in payloads: + label = ( + f"album '{payload['album_context'].get('name')}'" + if payload['is_album'] else 'residual per-track' + ) + logger.info( + f"[Manual-Wishlist] Running sub-batch {payload['batch_id']} " + f"({label}, {len(payload['tracks'])} tracks)" + ) + runtime.run_full_missing_tracks_process( + payload['batch_id'], "wishlist", payload['tracks'], + ) except Exception as exc: logger.error(f"Error preparing manual wishlist batch {batch_id}: {exc}") @@ -615,45 +795,124 @@ def process_wishlist_automatically(runtime: WishlistAutoProcessingRuntime, autom for i, track in enumerate(wishlist_tracks): track['_original_index'] = i - # Create batch for automatic processing - batch_id = str(uuid.uuid4()) - playlist_name = f"Wishlist (Auto - {current_cycle.capitalize()})" + # When the cycle is 'albums', try to split the wishlist + # into per-album sub-batches so each album fires ONE + # album-bundle search (slskd / torrent / usenet) instead + # of N per-track searches. Residual tracks (no resolvable + # album metadata) fall through to a normal per-track + # batch. Singles cycle keeps its original single-batch + # shape — Spotify already classifies them away from + # albums. + _submitted_batches: list[str] = [] + if current_cycle == 'albums': + from core.wishlist.album_grouping import group_wishlist_tracks_by_album + grouping = group_wishlist_tracks_by_album(wishlist_tracks) + else: + grouping = None - # Create task queue - convert wishlist tracks to expected format - with runtime.tasks_lock: - runtime.download_batches[batch_id] = { - 'phase': 'analysis', - 'playlist_id': playlist_id, - 'playlist_name': playlist_name, - 'queue': [], - 'active_count': 0, - 'max_concurrent': runtime.get_batch_max_concurrent(), # Wishlist always does single-track downloads, not folder grabs - 'queue_index': 0, - 'analysis_total': len(wishlist_tracks), - 'analysis_processed': 0, - 'analysis_results': [], - # Track state management (replicating sync.py) - 'permanently_failed_tracks': [], - 'cancelled_tracks': set(), - # Wishlist tracks are already known-missing — skip the expensive library check - 'force_download_all': True, - # Mark as auto-initiated - 'auto_initiated': True, - 'auto_processing_timestamp': runtime.current_time_fn(), - # Store current cycle for toggling after completion - 'current_cycle': current_cycle, - # Profile context for failed track wishlist re-adds (auto = profile 1 default) - 'profile_id': runtime.profile_id, - } + # Reify "wishlist run" — one shared id stamped on every + # sub-batch this invocation produces. The completion + # handler uses it to gate the once-per-run cycle toggle + # (so it doesn't fire N times for N sub-batches). + wishlist_run_id = str(uuid.uuid4()) - logger.info(f"Starting automatic wishlist batch {batch_id} with {len(wishlist_tracks)} tracks") - runtime.update_automation_progress(automation_id, progress=50, phase=f'Downloading {len(wishlist_tracks)} tracks', - log_line=f'Started batch: {len(wishlist_tracks)} {current_cycle}', log_type='success') + if grouping and grouping.album_groups: + for album_idx, group in enumerate(grouping.album_groups): + album_batch_id = str(uuid.uuid4()) + album_batch_name = ( + f"Wishlist (Auto - Album: {group.album_context.get('name', 'Unknown')})" + ) + with runtime.tasks_lock: + runtime.download_batches[album_batch_id] = { + 'phase': 'analysis', + 'playlist_id': playlist_id, + 'playlist_name': album_batch_name, + 'queue': [], + 'active_count': 0, + 'max_concurrent': runtime.get_batch_max_concurrent(), + 'queue_index': 0, + 'analysis_total': len(group.tracks), + 'analysis_processed': 0, + 'analysis_results': [], + 'permanently_failed_tracks': [], + 'cancelled_tracks': set(), + 'force_download_all': True, + 'auto_initiated': True, + 'auto_processing_timestamp': runtime.current_time_fn(), + 'current_cycle': current_cycle, + 'profile_id': runtime.profile_id, + # Album-bundle dispatch gate reads these + # three. With them set, the master worker + # routes through slskd / torrent / usenet + # album-bundle search instead of per-track. + 'is_album_download': True, + 'album_context': group.album_context, + 'artist_context': group.artist_context, + 'wishlist_run_id': wishlist_run_id, + } + logger.info( + f"[Auto-Wishlist] Album sub-batch {album_idx + 1}/{len(grouping.album_groups)}: " + f"'{group.album_context.get('name')}' by '{group.artist_context.get('name')}' " + f"({len(group.tracks)} tracks) → {album_batch_id} [run {wishlist_run_id[:8]}]" + ) + _submitted_batches.append(album_batch_id) + runtime.missing_download_executor.submit( + runtime.run_full_missing_tracks_process, + album_batch_id, playlist_id, group.tracks, + ) - # Submit the wishlist processing job using existing infrastructure - runtime.missing_download_executor.submit(runtime.run_full_missing_tracks_process, batch_id, playlist_id, wishlist_tracks) + # Residual tracks (no album group could be formed, OR + # singles cycle): one classic per-track batch as before. + residual_tracks = ( + grouping.residual_tracks if grouping is not None else wishlist_tracks + ) + if residual_tracks: + batch_id = str(uuid.uuid4()) + playlist_name = f"Wishlist (Auto - {current_cycle.capitalize()})" + with runtime.tasks_lock: + runtime.download_batches[batch_id] = { + 'phase': 'analysis', + 'playlist_id': playlist_id, + 'playlist_name': playlist_name, + 'queue': [], + 'active_count': 0, + 'max_concurrent': runtime.get_batch_max_concurrent(), + 'queue_index': 0, + 'analysis_total': len(residual_tracks), + 'analysis_processed': 0, + 'analysis_results': [], + 'permanently_failed_tracks': [], + 'cancelled_tracks': set(), + 'force_download_all': True, + 'auto_initiated': True, + 'auto_processing_timestamp': runtime.current_time_fn(), + 'current_cycle': current_cycle, + 'profile_id': runtime.profile_id, + 'wishlist_run_id': wishlist_run_id, + } + _submitted_batches.append(batch_id) + runtime.missing_download_executor.submit( + runtime.run_full_missing_tracks_process, + batch_id, playlist_id, residual_tracks, + ) + logger.info( + f"Starting wishlist residual batch {batch_id} with {len(residual_tracks)} tracks " + f"({'singles' if current_cycle == 'singles' else 'unbucketed albums'}) " + f"[run {wishlist_run_id[:8]}]" + ) - # Don't mark auto_processing as False here - let completion handler do it + _summary_parts: list[str] = [] + if grouping and grouping.album_groups: + _summary_parts.append(f"{len(grouping.album_groups)} album batch(es)") + if residual_tracks: + _summary_parts.append(f"{len(residual_tracks)} per-track") + _summary_text = ', '.join(_summary_parts) or 'no batches' + runtime.update_automation_progress( + automation_id, progress=50, + phase=f'Downloading {len(wishlist_tracks)} tracks', + log_line=f'Started: {_summary_text} for cycle {current_cycle}', + log_type='success', + ) except Exception as e: logger.error(f"Error in automatic wishlist processing: {e}") diff --git a/database/music_database.py b/database/music_database.py index 0171d88e..ea8eb98f 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -531,6 +531,30 @@ class MusicDatabase: """) cursor.execute("CREATE INDEX IF NOT EXISTS idx_arh_automation_id ON automation_run_history(automation_id)") + # Playlist pipeline run history table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS playlist_pipeline_run_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + playlist_id INTEGER, + playlist_name TEXT, + source TEXT, + profile_id INTEGER DEFAULT 1, + trigger_source TEXT DEFAULT 'pipeline', + started_at TIMESTAMP, + finished_at TIMESTAMP, + duration_seconds REAL, + status TEXT NOT NULL, + summary TEXT, + before_json TEXT, + after_json TEXT, + result_json TEXT, + log_lines TEXT, + FOREIGN KEY (playlist_id) REFERENCES mirrored_playlists(id) ON DELETE SET NULL + ) + """) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_pprh_playlist_id ON playlist_pipeline_run_history(playlist_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_pprh_profile_id ON playlist_pipeline_run_history(profile_id)") + # Add explored_at to mirrored_playlists (migration) self._add_mirrored_playlist_explored_column(cursor) @@ -539,6 +563,7 @@ class MusicDatabase: self._add_automation_system_column(cursor) self._add_automation_then_actions_column(cursor) self._add_automation_group_name_column(cursor) + self._add_automation_owned_by_column(cursor) # Library issues — user-reported problems with tracks/albums/artists cursor.execute(""" @@ -845,6 +870,28 @@ class MusicDatabase: except Exception as e: logger.error(f"Error adding automation group_name column: {e}") + def _add_automation_owned_by_column(self, cursor): + """Add owned_by column so feature surfaces (Auto-Sync schedule + board, future pipeline groups) can recognize automations they + manage without relying on fragile name-prefix string matches.""" + try: + cursor.execute("PRAGMA table_info(automations)") + cols = [c[1] for c in cursor.fetchall()] + if 'owned_by' not in cols: + cursor.execute("ALTER TABLE automations ADD COLUMN owned_by TEXT DEFAULT NULL") + logger.info("Added owned_by column to automations table") + # Backfill existing Auto-Sync automations created via the + # name/group-prefix convention so the board keeps managing them. + cursor.execute(""" + UPDATE automations + SET owned_by = 'auto_sync' + WHERE (group_name = 'Playlist Auto-Sync' OR name LIKE 'Auto-Sync:%') + AND owned_by IS NULL + """) + logger.info(f"Backfilled {cursor.rowcount} existing Auto-Sync automations with owned_by='auto_sync'") + except Exception as e: + logger.error(f"Error adding automation owned_by column: {e}") + def _add_automation_then_actions_column(self, cursor): """Add then_actions column to automations table and migrate existing notify data.""" try: @@ -5200,7 +5247,7 @@ class MusicDatabase: # Album exists - update it (update server_source if different) cursor.execute(""" UPDATE albums - SET artist_id = ?, title = ?, year = ?, thumb_url = ?, genres = ?, + SET artist_id = ?, title = ?, year = ?, thumb_url = COALESCE(NULLIF(?, ''), thumb_url), genres = ?, track_count = ?, duration = ?, server_source = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? """, (artist_id, title, year, thumb_url, genres_json, track_count, duration, server_source, album_id)) @@ -5233,6 +5280,7 @@ class MusicDatabase: # Read enrichment data from old album cursor.execute("SELECT * FROM albums WHERE id = ?", (old_id,)) old_row = cursor.fetchone() + preserved_thumb_url = thumb_url or (old_row['thumb_url'] if old_row and 'thumb_url' in old_row.keys() else None) # Insert new album with fresh server metadata + preserved created_at old_created = old_row['created_at'] if old_row else None @@ -5240,7 +5288,7 @@ class MusicDatabase: INSERT INTO albums (id, artist_id, title, year, thumb_url, genres, track_count, duration, server_source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) - """, (album_id, artist_id, title, year, thumb_url, genres_json, + """, (album_id, artist_id, title, year, preserved_thumb_url, genres_json, track_count, duration, server_source, old_created)) # Copy enrichment data from old record to new record @@ -6370,6 +6418,54 @@ class MusicDatabase: logger.error(f"Error fetching candidate albums for artist '{artist}': {e}") return candidates + def get_artist_tracks_indexed(self, name: str, server_source: Optional[str] = None, limit: int = 10000) -> List[DatabaseTrack]: + """Indexed two-step lookup: artist_id by exact name (then case-insensitive + fallback), then tracks via `artist_id IN (...)`. Avoids the function-in-WHERE + pattern in search_tracks that defeats the artists.name index. Returns [] + when the artist isn't in the library — caller can decide to fall back to + the slower LIKE-based path for track_artist / diacritic recall.""" + if not name: + return [] + try: + conn = self._get_connection() + cursor = conn.cursor() + + # Step 1: exact case-sensitive match — hits idx_artists_name in O(log n). + # Spotify's canonical artist names match the library 90%+ of the time. + cursor.execute("SELECT id FROM artists WHERE name = ?", (name,)) + artist_ids = [r['id'] for r in cursor.fetchall()] + + # Step 2: case-insensitive fallback if exact missed. Full scan, but only + # runs on the (uncommon) miss path so amortized cost stays low. + if not artist_ids: + cursor.execute("SELECT id FROM artists WHERE LOWER(name) = LOWER(?)", (name,)) + artist_ids = [r['id'] for r in cursor.fetchall()] + + if not artist_ids: + return [] + + placeholders = ','.join('?' for _ in artist_ids) + where = f"t.artist_id IN ({placeholders})" + params: list = list(artist_ids) + if server_source: + where += " AND t.server_source = ?" + params.append(server_source) + params.append(limit) + + cursor.execute(f""" + SELECT t.*, a.name as artist_name, al.title as album_title, + al.thumb_url as album_thumb_url + FROM tracks t + JOIN artists a ON a.id = t.artist_id + JOIN albums al ON al.id = t.album_id + WHERE {where} + LIMIT ? + """, params) + return self._rows_to_tracks(cursor.fetchall()) + except Exception as e: + logger.error(f"Error fetching indexed artist tracks for '{name}': {e}") + return [] + def get_candidate_tracks_for_albums(self, album_ids: List) -> List[DatabaseTrack]: """ Fetch every track belonging to the given set of album IDs in a single query. @@ -6897,22 +6993,12 @@ class MusicDatabase: return unique_variations def _normalize_for_comparison(self, text: str) -> str: - """Normalize text for comparison with Unicode accent handling""" - if not text: - return "" - - # Try to use unidecode for accent normalization, fallback to basic if not available - try: - from unidecode import unidecode - # Convert accents: é→e, ñ→n, ü→u, etc. - normalized = unidecode(text) - except ImportError: - # Fallback: basic normalization without accent handling - normalized = text - logger.warning("unidecode not available, accent matching may be limited") - - # Convert to lowercase and strip - return normalized.lower().strip() + """Delegates to `core.text.normalize.normalize_for_comparison`. + Kept as an instance method so existing internal callers don't need + to be touched — new code should import the public helper directly. + """ + from core.text.normalize import normalize_for_comparison + return normalize_for_comparison(text) def _calculate_track_confidence(self, search_title: str, search_artist: str, db_track: DatabaseTrack) -> float: """Calculate confidence score for track match with enhanced cleaning and Unicode normalization""" @@ -11828,7 +11914,7 @@ class MusicDatabase: VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(source, source_playlist_id, profile_id) DO UPDATE SET name = excluded.name, - description = excluded.description, + description = COALESCE(NULLIF(excluded.description, ''), mirrored_playlists.description), owner = excluded.owner, image_url = excluded.image_url, track_count = excluded.track_count, @@ -11939,6 +12025,39 @@ class MusicDatabase: logger.error(f"Error getting mirrored playlist tracks: {e}") return [] + def update_mirrored_playlist_source_ref( + self, + playlist_id: int, + source_playlist_id: str, + description: Optional[str] = None, + ) -> bool: + """Update a mirrored playlist's upstream source reference. + + This intentionally leaves mirrored tracks and discovery extra_data + untouched; refresh/discovery can use the new source reference on the + next run without losing existing local state. + """ + try: + with self._get_connection() as conn: + cursor = conn.cursor() + if description is None: + cursor.execute(""" + UPDATE mirrored_playlists + SET source_playlist_id = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (source_playlist_id, playlist_id)) + else: + cursor.execute(""" + UPDATE mirrored_playlists + SET source_playlist_id = ?, description = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (source_playlist_id, description, playlist_id)) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"Error updating mirrored playlist source reference: {e}") + return False + def update_mirrored_track_extra_data(self, track_id: int, extra_data_dict: dict) -> bool: """Merge new data into a mirrored track's extra_data JSON field.""" try: @@ -12018,6 +12137,82 @@ class MusicDatabase: logger.error(f"Error getting mirrored playlist discovery counts: {e}") return (0, 0) + def get_all_mirrored_playlist_status_counts(self, profile_id: int = 1) -> dict: + """Return status counts for every mirrored playlist owned by the profile + in a single round-trip. Replaces N×4-query per-playlist loop on the + Auto-Sync modal load path. Result is `{playlist_id: {total, discovered, + wishlisted, in_library}}`.""" + result: dict = {} + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT mp.id as playlist_id + FROM mirrored_playlists mp + WHERE mp.profile_id = ? + """, (profile_id,)) + for row in cursor.fetchall(): + result[row['playlist_id']] = {'total': 0, 'discovered': 0, 'wishlisted': 0, 'in_library': 0} + + # Core counts: total + discovered, grouped per playlist + cursor.execute(""" + SELECT mpt.playlist_id, + COUNT(*) as total, + SUM(CASE WHEN mpt.extra_data LIKE '%"discovered": true%' THEN 1 ELSE 0 END) as discovered + FROM mirrored_playlist_tracks mpt + JOIN mirrored_playlists mp ON mp.id = mpt.playlist_id + WHERE mp.profile_id = ? + GROUP BY mpt.playlist_id + """, (profile_id,)) + for row in cursor.fetchall(): + pid = row['playlist_id'] + if pid not in result: + result[pid] = {'total': 0, 'discovered': 0, 'wishlisted': 0, 'in_library': 0} + result[pid]['total'] = row['total'] or 0 + result[pid]['discovered'] = row['discovered'] or 0 + + # Wishlist counts in one shot + try: + cursor.execute(""" + SELECT mpt.playlist_id, COUNT(*) as wishlisted + FROM mirrored_playlist_tracks mpt + JOIN mirrored_playlists mp ON mp.id = mpt.playlist_id + WHERE mp.profile_id = ? + AND mpt.source_track_id IS NOT NULL AND mpt.source_track_id != '' + AND EXISTS (SELECT 1 FROM wishlist_tracks wt + WHERE wt.spotify_track_id = mpt.source_track_id) + GROUP BY mpt.playlist_id + """, (profile_id,)) + for row in cursor.fetchall(): + pid = row['playlist_id'] + if pid in result: + result[pid]['wishlisted'] = row['wishlisted'] or 0 + except Exception as e: + logger.debug(f"Batch wishlist counts failed: {e}") + + # In-library counts in one shot. Case-sensitive join so + # idx_artists_name + idx_tracks_title kick in. + try: + cursor.execute(""" + SELECT mpt.playlist_id, COUNT(DISTINCT mpt.id) as in_library + FROM mirrored_playlist_tracks mpt + JOIN mirrored_playlists mp ON mp.id = mpt.playlist_id + JOIN artists a ON a.name = mpt.artist_name + JOIN tracks t ON t.artist_id = a.id + AND t.title = mpt.track_name + WHERE mp.profile_id = ? + GROUP BY mpt.playlist_id + """, (profile_id,)) + for row in cursor.fetchall(): + pid = row['playlist_id'] + if pid in result: + result[pid]['in_library'] = row['in_library'] or 0 + except Exception as e: + logger.debug(f"Batch library counts failed: {e}") + except Exception as e: + logger.error(f"Error getting batch mirrored playlist status counts: {e}") + return result + def get_mirrored_playlist_status_counts(self, playlist_id: int) -> dict: """Return discovery, wishlisted, and downloaded counts for a mirrored playlist. Discovery counts are critical (same as old method). Library/wishlist counts are @@ -12039,26 +12234,39 @@ class MusicDatabase: ) result['discovered'] = cursor.fetchone()['discovered'] - # Best-effort extras — won't break if tracks table has issues + # Best-effort extras — won't break if tracks table has issues. + # Wishlisted: indexed via wishlist_tracks.spotify_track_id. try: cursor.execute(""" - SELECT - SUM(CASE WHEN mpt.source_track_id IS NOT NULL AND mpt.source_track_id != '' - AND EXISTS (SELECT 1 FROM wishlist_tracks wt - WHERE wt.spotify_track_id = mpt.source_track_id) - THEN 1 ELSE 0 END) as wishlisted, - SUM(CASE WHEN EXISTS (SELECT 1 FROM tracks t - WHERE t.title = mpt.track_name COLLATE NOCASE - AND t.artist = mpt.artist_name COLLATE NOCASE) - THEN 1 ELSE 0 END) as in_library + SELECT COUNT(*) as wishlisted FROM mirrored_playlist_tracks mpt WHERE mpt.playlist_id = ? + AND mpt.source_track_id IS NOT NULL AND mpt.source_track_id != '' + AND EXISTS (SELECT 1 FROM wishlist_tracks wt + WHERE wt.spotify_track_id = mpt.source_track_id) """, (playlist_id,)) - row = cursor.fetchone() - result['wishlisted'] = row['wishlisted'] or 0 - result['in_library'] = row['in_library'] or 0 + result['wishlisted'] = cursor.fetchone()['wishlisted'] or 0 except Exception as extra_err: - logger.debug(f"Optional status counts failed for playlist {playlist_id}: {extra_err}") + logger.debug(f"Wishlist count failed for playlist {playlist_id}: {extra_err}") + + # In-library: case-sensitive equality so SQLite can use + # `idx_artists_name` and `idx_tracks_title`. COLLATE NOCASE on + # the join columns prevents index usage and takes ~18s per + # playlist on a 300k-track library; the case-sensitive variant + # is ~6ms. Misses purely-case-different matches (rare — Spotify + # canonicalizes artist/track names that match library imports). + try: + cursor.execute(""" + SELECT COUNT(DISTINCT mpt.id) as in_library + FROM mirrored_playlist_tracks mpt + JOIN artists a ON a.name = mpt.artist_name + JOIN tracks t ON t.artist_id = a.id + AND t.title = mpt.track_name + WHERE mpt.playlist_id = ? + """, (playlist_id,)) + result['in_library'] = cursor.fetchone()['in_library'] or 0 + except Exception as extra_err: + logger.debug(f"Library count failed for playlist {playlist_id}: {extra_err}") except Exception as e: logger.error(f"Error getting mirrored playlist status counts: {e}") @@ -12083,15 +12291,22 @@ class MusicDatabase: def create_automation(self, name: str, trigger_type: str, trigger_config: str, action_type: str, action_config: str, profile_id: int = 1, notify_type: str = None, notify_config: str = '{}', - then_actions: str = '[]', group_name: str = None): - """Create a new automation. Returns the new automation ID or None.""" + then_actions: str = '[]', group_name: str = None, + owned_by: str = None): + """Create a new automation. Returns the new automation ID or None. + + ``owned_by`` tags an automation as managed by a feature surface + (e.g. ``'auto_sync'`` for entries the Playlist Auto-Sync board + creates) so that surface can recognize its own rows without + scraping the display name. + """ try: with self._get_connection() as conn: cursor = conn.cursor() cursor.execute(""" - INSERT INTO automations (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions, group_name) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions, group_name)) + INSERT INTO automations (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions, group_name, owned_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions, group_name, owned_by)) conn.commit() return cursor.lastrowid except Exception as e: @@ -12138,7 +12353,7 @@ class MusicDatabase: def update_automation(self, automation_id: int, **kwargs) -> bool: """Update automation fields.""" - allowed = {'name', 'enabled', 'trigger_type', 'trigger_config', 'action_type', 'action_config', 'next_run', 'notify_type', 'notify_config', 'last_result', 'is_system', 'then_actions', 'group_name'} + allowed = {'name', 'enabled', 'trigger_type', 'trigger_config', 'action_type', 'action_config', 'next_run', 'notify_type', 'notify_config', 'last_result', 'is_system', 'then_actions', 'group_name', 'owned_by'} updates = {k: v for k, v in kwargs.items() if k in allowed} if not updates: return False @@ -12316,6 +12531,73 @@ class MusicDatabase: logger.error(f"Error clearing automation run history: {e}") return 0 + def insert_playlist_pipeline_run_history(self, playlist_id, playlist_name, source, + profile_id, trigger_source, started_at, + finished_at, duration_seconds, status, + summary=None, before_json=None, + after_json=None, result_json=None, + log_lines=None): + """Insert a playlist pipeline run history entry and retain recent rows per profile.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO playlist_pipeline_run_history + (playlist_id, playlist_name, source, profile_id, trigger_source, + started_at, finished_at, duration_seconds, status, summary, + before_json, after_json, result_json, log_lines) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + playlist_id, playlist_name, source, profile_id, trigger_source, + started_at, finished_at, duration_seconds, status, summary, + before_json, after_json, result_json, log_lines, + )) + cursor.execute(""" + DELETE FROM playlist_pipeline_run_history + WHERE profile_id = ? AND id NOT IN ( + SELECT id FROM playlist_pipeline_run_history + WHERE profile_id = ? + ORDER BY id DESC LIMIT 300 + ) + """, (profile_id, profile_id)) + conn.commit() + return True + except Exception as e: + logger.error(f"Error inserting playlist pipeline run history for {playlist_id}: {e}") + return False + + def get_playlist_pipeline_run_history(self, profile_id=1, playlist_id=None, limit=50, offset=0): + """Get playlist pipeline run history, newest first.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + where = ["profile_id = ?"] + params = [profile_id] + if playlist_id: + where.append("playlist_id = ?") + params.append(playlist_id) + where_sql = " AND ".join(where) + cursor.execute( + f"SELECT COUNT(*) FROM playlist_pipeline_run_history WHERE {where_sql}", + params, + ) + total = cursor.fetchone()[0] + cursor.execute(f""" + SELECT id, playlist_id, playlist_name, source, profile_id, trigger_source, + started_at, finished_at, duration_seconds, status, summary, + before_json, after_json, result_json, log_lines + FROM playlist_pipeline_run_history + WHERE {where_sql} + ORDER BY id DESC + LIMIT ? OFFSET ? + """, [*params, limit, offset]) + cols = [d[0] for d in cursor.description] + rows = [dict(zip(cols, row, strict=False)) for row in cursor.fetchall()] + return {'history': rows, 'total': total} + except Exception as e: + logger.error(f"Error getting playlist pipeline run history: {e}") + return {'history': [], 'total': 0} + def get_radio_tracks(self, track_id, limit=20, exclude_ids=None) -> Dict[str, Any]: """Find similar tracks for radio mode auto-play queue. diff --git a/services/sync_service.py b/services/sync_service.py index 5177c59d..e51980ef 100644 --- a/services/sync_service.py +++ b/services/sync_service.py @@ -10,6 +10,24 @@ from core.matching_engine import MusicMatchingEngine, MatchResult logger = get_logger("sync_service") +# Per-artist track pool cap. High enough that no plausible artist hits it +# (the largest catalogs in our test libraries sit in the low thousands), low +# enough to avoid pathological pulls if the DB ever returns garbage. +_POOL_FETCH_LIMIT = 10000 + + +def _artist_name(artist) -> str: + """Pull the display name out of a Spotify artist entry — they come back + as bare strings on some endpoints and ``{name: ...}`` dicts on others. + Falls back to ``str(artist)`` so callers never get None.""" + if isinstance(artist, str): + return artist + if isinstance(artist, dict): + name = artist.get('name') + if isinstance(name, str): + return name + return str(artist) if artist is not None else '' + @dataclass class SyncResult: playlist_name: str @@ -209,33 +227,33 @@ class PlaylistSyncService: return self._create_error_result(playlist.name, ["Sync cancelled"]) total_tracks = len(playlist.tracks) - media_client, server_type = self._get_active_media_client() - media_client, server_type = self._get_active_media_client() self._update_progress(playlist.name, f"Matching tracks against {server_type.title()} library", "", 20, 5, 2, total_tracks=total_tracks) - + + # Empty dict (not None) signals pooling is enabled for this sync; + # entries are filled lazily by `_find_track_in_media_server` so warm + # caches pay zero pool cost. + candidate_pool: Dict[str, list] = {} + # Use the same robust matching approach as "Download Missing Tracks" match_results = [] for i, track in enumerate(playlist.tracks): if self._cancelled: return self._create_error_result(playlist.name, ["Sync cancelled"]) - + # Update progress for each track progress_percent = 20 + (40 * (i + 1) / total_tracks) # 20-60% for matching - # Extract artist name from both string and dict formats if track.artists: - first_artist = track.artists[0] - artist_name = first_artist if isinstance(first_artist, str) else (first_artist.get('name', 'Unknown') if isinstance(first_artist, dict) else str(first_artist)) - current_track_name = f"{artist_name} - {track.name}" + current_track_name = f"{_artist_name(track.artists[0]) or 'Unknown'} - {track.name}" else: current_track_name = track.name - self._update_progress(playlist.name, "Matching tracks", current_track_name, progress_percent, 5, 2, + self._update_progress(playlist.name, "Matching tracks", current_track_name, progress_percent, 5, 2, total_tracks=total_tracks, matched_tracks=len([r for r in match_results if r.is_match]), failed_tracks=len([r for r in match_results if not r.is_match])) - + # Use the robust search approach - plex_match, confidence = await self._find_track_in_media_server(track) + plex_match, confidence = await self._find_track_in_media_server(track, candidate_pool=candidate_pool) match_result = MatchResult( spotify_track=track, @@ -469,7 +487,42 @@ class PlaylistSyncService: self.clear_progress_callback(playlist.name) self._cancelled = False - async def _find_track_in_media_server(self, spotify_track: SpotifyTrack) -> Tuple[Optional[TrackInfo], float]: + def _get_or_fetch_artist_candidates(self, candidate_pool: Optional[Dict[str, list]], db, artist_name: str, active_server) -> Optional[list]: + """Lazy per-artist pool fetch. Returns None when pooling is off so the + caller falls back to the per-track SQL loop; otherwise returns the + cached list (possibly empty), fetching on first miss.""" + if candidate_pool is None: + return None + from core.text.normalize import normalize_for_comparison + key = normalize_for_comparison(artist_name) + if key in candidate_pool: + return candidate_pool[key] + try: + # Fast path — indexed artist_id lookup. Hits idx_artists_name + + # idx_tracks_artist_id, returns in milliseconds for both hits and + # misses. Handles the 90%+ exact-name case. + candidates = db.get_artist_tracks_indexed( + artist_name, + server_source=active_server, + limit=_POOL_FETCH_LIMIT, + ) + # Slow-path fallback only when fast path found nothing. Preserves + # recall for diacritic variants and `tracks.track_artist` features + # (compilations / soundtracks where the per-track artist differs + # from the album artist). + if not candidates: + candidates = db.search_tracks( + artist=artist_name, + limit=_POOL_FETCH_LIMIT, + server_source=active_server, + ) + candidate_pool[key] = candidates or [] + return candidate_pool[key] + except Exception as fetch_err: + logger.debug(f"Candidate pool fetch failed for '{artist_name}': {fetch_err}") + return None + + async def _find_track_in_media_server(self, spotify_track: SpotifyTrack, candidate_pool: Optional[Dict[str, list]] = None) -> Tuple[Optional[TrackInfo], float]: """Find a track using the same improved database matching as Download Missing Tracks modal""" try: # Check active media server connection @@ -477,7 +530,7 @@ class PlaylistSyncService: if not media_client or not media_client.is_connected(): logger.warning(f"{server_type.upper()} client not connected") return None, 0.0 - + # Use the SAME improved database matching as PlaylistTrackAnalysisWorker from database.music_database import MusicDatabase from config.settings import config_manager @@ -531,18 +584,19 @@ class PlaylistSyncService: if self._cancelled: return None, 0.0 - # Extract artist name from both string and dict formats - if isinstance(artist, str): - artist_name = artist - elif isinstance(artist, dict) and 'name' in artist: - artist_name = artist['name'] - else: - artist_name = str(artist) - + artist_name = _artist_name(artist) + # Use the improved database check_track_exists method with server awareness try: db = MusicDatabase() - db_track, confidence = db.check_track_exists(original_title, artist_name, confidence_threshold=0.7, server_source=active_server) + artist_candidates = self._get_or_fetch_artist_candidates( + candidate_pool, db, artist_name, active_server, + ) + db_track, confidence = db.check_track_exists( + original_title, artist_name, + confidence_threshold=0.7, server_source=active_server, + candidate_tracks=artist_candidates, + ) if db_track and confidence >= 0.7: logger.debug(f"Database match found for '{original_title}' by '{artist_name}': '{db_track.title}' with confidence {confidence:.2f}") diff --git a/tests/automation/test_automation_api.py b/tests/automation/test_automation_api.py index fea7a032..73373eb3 100644 --- a/tests/automation/test_automation_api.py +++ b/tests/automation/test_automation_api.py @@ -28,7 +28,8 @@ class _FakeDB: return dict(self.automations[automation_id]) if automation_id in self.automations else None def create_automation(self, name, trigger_type, trigger_config, action_type, action_config, - profile_id, notify_type, notify_config, then_actions, group_name): + profile_id, notify_type, notify_config, then_actions, group_name, + owned_by=None): aid = self._next_id self._next_id += 1 self.automations[aid] = { @@ -37,6 +38,7 @@ class _FakeDB: 'action_config': action_config, 'profile_id': profile_id, 'notify_type': notify_type, 'notify_config': notify_config, 'then_actions': then_actions, 'group_name': group_name, + 'owned_by': owned_by, 'enabled': 1, 'is_system': 0, } return aid @@ -289,6 +291,91 @@ def test_update_then_actions_clears_notify_when_empty(): assert db.automations[1]['notify_config'] == '{}' +def test_update_trigger_config_change_resets_next_run(): + """Changing the interval must blank stored next_run so the engine + recomputes from scratch — otherwise dragging a playlist from the 8h + Auto-Sync column to the 1h column keeps firing at the old 8h mark.""" + db = _FakeDB() + db.automations[1] = { + 'id': 1, 'name': 'a', 'enabled': 1, 'is_system': 0, + 'trigger_type': 'schedule', + 'trigger_config': '{"interval": 8, "unit": "hours"}', + 'then_actions': '[]', + 'next_run': '2026-05-25 05:00:00', + } + eng = _FakeEngine() + body, status = api.update_automation(db, eng, automation_id=1, data={ + 'trigger_config': {'interval': 1, 'unit': 'hours'}, + }) + assert status == 200 + assert db.automations[1]['next_run'] is None + assert eng.scheduled == [1] + + +def test_update_trigger_type_change_resets_next_run(): + """Switching from interval to daily_time must also reset next_run.""" + db = _FakeDB() + db.automations[1] = { + 'id': 1, 'name': 'a', 'enabled': 1, 'is_system': 0, + 'trigger_type': 'schedule', 'trigger_config': '{}', + 'then_actions': '[]', + 'next_run': '2026-05-25 05:00:00', + } + api.update_automation(db, _FakeEngine(), automation_id=1, data={ + 'trigger_type': 'daily_time', + }) + assert db.automations[1]['next_run'] is None + + +def test_update_non_trigger_field_preserves_next_run(): + """Renaming an automation must NOT disturb its scheduled clock — + the reset is scoped to schedule-shape changes only.""" + db = _FakeDB() + db.automations[1] = { + 'id': 1, 'name': 'a', 'enabled': 1, 'is_system': 0, + 'trigger_type': 'schedule', 'trigger_config': '{}', + 'then_actions': '[]', + 'next_run': '2026-05-25 05:00:00', + } + api.update_automation(db, _FakeEngine(), automation_id=1, data={'name': 'renamed'}) + assert db.automations[1].get('next_run') == '2026-05-25 05:00:00' + + +def test_create_with_owned_by_persists_marker(): + """Auto-Sync schedule board posts `owned_by: 'auto_sync'` so it can + recognize its own rows on subsequent reads without name-prefix + string scraping.""" + db = _FakeDB() + api.create_automation(db, _FakeEngine(), profile_id=1, data={ + 'name': 'Auto-Sync: Discover Weekly', + 'trigger_type': 'schedule', + 'trigger_config': {'interval': 1, 'unit': 'hours'}, + 'action_type': 'playlist_pipeline', + 'owned_by': 'auto_sync', + }) + assert db.automations[1]['owned_by'] == 'auto_sync' + + +def test_create_without_owned_by_defaults_to_none(): + db = _FakeDB() + api.create_automation(db, _FakeEngine(), profile_id=1, data={ + 'name': 'Plain', 'trigger_type': 'schedule', 'action_type': 'process_wishlist', + }) + assert db.automations[1]['owned_by'] is None + + +def test_update_can_set_or_clear_owned_by(): + db = _FakeDB() + db.automations[1] = { + 'id': 1, 'name': 'a', 'enabled': 1, 'is_system': 0, + 'trigger_type': 'schedule', 'owned_by': None, + } + api.update_automation(db, _FakeEngine(), automation_id=1, data={'owned_by': 'auto_sync'}) + assert db.automations[1]['owned_by'] == 'auto_sync' + api.update_automation(db, _FakeEngine(), automation_id=1, data={'owned_by': None}) + assert db.automations[1]['owned_by'] is None + + # --------------------------------------------------------------------------- # batch_update_group # --------------------------------------------------------------------------- diff --git a/tests/automation/test_handler_error_storage.py b/tests/automation/test_handler_error_storage.py index e7eaeefd..04dc93dd 100644 --- a/tests/automation/test_handler_error_storage.py +++ b/tests/automation/test_handler_error_storage.py @@ -132,3 +132,38 @@ def test_skipped_status_records_no_error(engine_with_handler) -> None: engine.run_automation(1, skip_delay=True) kwargs = db_mock.update_automation_run.call_args.kwargs assert kwargs.get('error') is None + + +def test_guarded_scheduled_skip_retries_soon() -> None: + """A busy guarded action should retry soon instead of waiting a full interval.""" + db_mock = MagicMock() + db_mock.get_automation.return_value = { + 'id': 1, + 'name': 'Playlist Pipeline', + 'enabled': True, + 'action_type': 'playlist_pipeline', + 'action_config': '{}', + 'trigger_type': 'schedule', + 'trigger_config': '{"interval": 6, "unit": "hours"}', + } + db_mock.update_automation_run = MagicMock(return_value=True) + + engine = AutomationEngine(db_mock) + engine._running = True + engine.schedule_automation = MagicMock() + engine._action_handlers['playlist_pipeline'] = { + 'handler': lambda config: {'status': 'completed'}, + 'guard': lambda: True, + } + + engine.run_automation(1, skip_delay=True) + + kwargs = db_mock.update_automation_run.call_args.kwargs + assert kwargs.get('error') is None + assert kwargs.get('next_run') is not None + # It should be scheduled for roughly five minutes, not the configured six hours. + from datetime import datetime, timezone + + next_run = datetime.strptime(kwargs['next_run'], '%Y-%m-%d %H:%M:%S').replace(tzinfo=timezone.utc) + delay = (next_run - datetime.now(timezone.utc)).total_seconds() + assert 0 < delay <= 360 diff --git a/tests/automation/test_handlers_playlist.py b/tests/automation/test_handlers_playlist.py index 1a1548ef..740ff16e 100644 --- a/tests/automation/test_handlers_playlist.py +++ b/tests/automation/test_handlers_playlist.py @@ -26,9 +26,11 @@ import pytest from core.automation.deps import AutomationDeps, AutomationState from core.automation.handlers.discover_playlist import auto_discover_playlist +from core.automation.handlers._pipeline_shared import run_sync_and_wishlist from core.automation.handlers.playlist_pipeline import auto_playlist_pipeline from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored from core.automation.handlers.sync_playlist import auto_sync_playlist +from core.playlists.sources.bootstrap import build_playlist_source_registry # ─── shared scaffolding ────────────────────────────────────────────── @@ -73,6 +75,22 @@ class _StubDB: def _build_deps(**overrides) -> AutomationDeps: + # Build a default registry from whatever clients the test passed. + # The refresh_mirrored handler reads from deps.playlist_source_registry + # exclusively, so the registry must mirror the passed clients to + # preserve the pre-refactor test behavior. + _spotify = overrides.get('spotify_client') + _tidal = overrides.get('tidal_client') + _get_deezer = overrides.get('get_deezer_client', lambda: None) + _parse_youtube = overrides.get('parse_youtube_playlist', lambda url: None) + _registry = build_playlist_source_registry( + spotify_client_getter=lambda: _spotify, + tidal_client_getter=lambda: _tidal, + qobuz_client_getter=lambda: None, + deezer_client_getter=_get_deezer, + youtube_parser=_parse_youtube, + ) + defaults = dict( engine=object(), state=AutomationState(), @@ -93,6 +111,7 @@ def _build_deps(**overrides) -> AutomationDeps: load_sync_status_file=lambda: {}, get_deezer_client=lambda: None, parse_youtube_playlist=lambda url: None, + playlist_source_registry=_registry, get_sync_states=lambda: {}, set_db_update_automation_id=lambda v: None, get_db_update_state=lambda: {}, @@ -189,6 +208,17 @@ class _StubSpotifyTrack: @dataclass class _StubSpotifyPlaylist: tracks: list + # Adapter-side projection reads metadata fields off the playlist + # object (the real ``core.spotify_client.Playlist`` dataclass). + # Provide minimal defaults so the stub stays a one-liner at call + # sites that only care about tracks. + id: str = 'spot-id' + name: str = 'My Spot' + description: Optional[str] = '' + owner: str = 'me' + public: bool = True + collaborative: bool = False + total_tracks: int = 0 class _StubSpotifyClient: @@ -266,6 +296,285 @@ class TestRefreshMirrored: assert result['errors'] == '1' assert result['refreshed'] == '0' + def test_spotify_public_missing_source_url_is_reported_as_error(self): + db = _StubDB(playlists=[ + {'id': 1, 'name': 'No URL', 'source': 'spotify_public', 'source_playlist_id': 'hash'}, + ]) + progress = [] + deps = _build_deps( + get_database=lambda: db, + update_progress=lambda *a, **k: progress.append(k), + ) + + result = auto_refresh_mirrored({'playlist_id': '1'}, deps) + + assert result['status'] == 'completed' + assert result['refreshed'] == '0' + assert result['errors'] == '1' + assert db.mirror_calls == [] + assert any(p.get('log_type') == 'error' and 'missing its original source URL' in p.get('log_line', '') for p in progress) + + def test_tidal_not_authenticated_emits_skip_not_error(self): + """Soft-skip preserves the legacy log_type='skip' contract — the + run still counts as completed with 0 errors so the automation + doesn't surface a Tidal-down condition as a refresh failure.""" + class _UnauthedTidal: + def is_authenticated(self): + return False + + db = _StubDB(playlists=[ + {'id': 7, 'name': 'My Tidal', 'source': 'tidal', + 'source_playlist_id': 'tid-id', 'profile_id': 1}, + ]) + progress = [] + deps = _build_deps( + get_database=lambda: db, + tidal_client=_UnauthedTidal(), + update_progress=lambda *a, **k: progress.append(k), + ) + + result = auto_refresh_mirrored({'playlist_id': '7'}, deps) + + assert result['status'] == 'completed' + assert result['refreshed'] == '0' + assert result['errors'] == '0' # skip, not error + assert db.mirror_calls == [] + assert any( + p.get('log_type') == 'skip' and 'Tidal not authenticated' in p.get('log_line', '') + for p in progress + ) + + def test_deezer_refresh_writes_plain_tracks_no_matched_data(self): + class _StubDeezer: + def is_authenticated(self): + return True + + def get_user_playlists(self): + return [] + + def get_playlist(self, playlist_id): + return { + 'id': playlist_id, + 'name': 'Deez', + 'description': '', + 'track_count': 1, + 'image_url': '', + 'owner': '', + 'tracks': [{ + 'id': 'dz1', + 'name': 'Track', + 'artists': ['Deez Artist'], + 'album': 'Deez Album', + 'duration_ms': 200_000, + }], + } + + db = _StubDB(playlists=[ + {'id': 9, 'name': 'Deez Mix', 'source': 'deezer', + 'source_playlist_id': 'dz-id', 'profile_id': 1}, + ]) + deps = _build_deps( + get_database=lambda: db, + get_deezer_client=lambda: _StubDeezer(), + ) + + result = auto_refresh_mirrored({'playlist_id': '9'}, deps) + + assert result['status'] == 'completed' + assert result['refreshed'] == '1' + assert len(db.mirror_calls) == 1 + call = db.mirror_calls[0] + assert call['source'] == 'deezer' + assert len(call['tracks']) == 1 + # Deezer tracks don't carry discovery state — no extra_data. + assert 'extra_data' not in call['tracks'][0] + assert call['tracks'][0]['source_track_id'] == 'dz1' + + def test_youtube_refresh_reads_url_from_description(self): + """URL-backed sources store the hash in source_playlist_id and + the canonical URL in description. The handler has to pull the + URL out before passing to the adapter.""" + parsed_calls = [] + + def _fake_parser(url): + parsed_calls.append(url) + return { + 'id': 'yt_pl', + 'name': 'YT Mix', + 'url': url, + 'track_count': 1, + 'tracks': [{ + 'id': 'vid1', + 'name': 'Track', + 'artists': ['Channel'], + 'duration_ms': 240_000, + }], + } + + db = _StubDB(playlists=[ + { + 'id': 11, + 'name': 'YT Mix', + 'source': 'youtube', + 'source_playlist_id': 'hashhash', + 'description': 'https://youtube.com/playlist?list=yt_pl', + 'profile_id': 1, + }, + ]) + deps = _build_deps( + get_database=lambda: db, + parse_youtube_playlist=_fake_parser, + ) + + result = auto_refresh_mirrored({'playlist_id': '11'}, deps) + + assert result['refreshed'] == '1' + # Parser was called with the URL from description, not the hash. + assert parsed_calls == ['https://youtube.com/playlist?list=yt_pl'] + assert db.mirror_calls[0]['tracks'][0]['source_track_id'] == 'vid1' + + def test_listenbrainz_refresh_runs_discovery_and_writes_matched_data(self): + """End-to-end: LB cached playlist → adapter projects to + NormalizedTrack with needs_discovery=True → refresh_mirrored + calls source.discover_tracks → matched_data lands in + extra_data on the mirror DB row.""" + from core.playlists.sources.bootstrap import build_playlist_source_registry + from core.playlists.sources.listenbrainz import ListenBrainzPlaylistSource + + class _StubLBManager: + def get_cached_playlists(self, playlist_type): + if playlist_type == 'created_for_user': + return [{ + 'playlist_mbid': 'lb-1', + 'title': 'LB Weekly', + 'creator': 'ListenBrainz', + 'track_count': 1, + 'annotation': {}, + 'last_updated': '2026-05-26', + }] + return [] + + def get_playlist_type(self, mbid): + return 'created_for_user' if mbid == 'lb-1' else '' + + def get_cached_tracks(self, mbid): + if mbid == 'lb-1': + return [{ + 'track_name': 'MB Song', + 'artist_name': 'MB Artist', + 'album_name': 'MB Album', + 'duration_ms': 240_000, + 'recording_mbid': 'rec-1', + 'release_mbid': 'rel-1', + 'album_cover_url': '', + 'additional_metadata': {}, + }] + return [] + + def update_all_playlists(self): + pass + + discovery_calls = [] + + def fake_discover(track_dicts): + discovery_calls.append(list(track_dicts)) + return [{ + 'id': 'sp-matched', + 'name': 'Matched', + 'artists': ['Spotify Artist'], + 'album': {'name': 'Spotify Album'}, + 'duration_ms': 240_000, + 'image_url': 'art', + 'source': 'spotify', + '_provider': 'spotify', + '_confidence': 0.93, + }] + + # Build a registry with the LB adapter pre-wired with discovery. + # The default _build_deps registry won't have discovery wired, + # so we override it for this test. + registry = build_playlist_source_registry( + spotify_client_getter=lambda: None, + tidal_client_getter=lambda: None, + qobuz_client_getter=lambda: None, + deezer_client_getter=lambda: None, + listenbrainz_manager_getter=lambda: _StubLBManager(), + discover_callable=fake_discover, + ) + + db = _StubDB(playlists=[ + { + 'id': 33, + 'name': 'LB Weekly', + 'source': 'listenbrainz', + 'source_playlist_id': 'lb-1', + 'profile_id': 1, + }, + ]) + deps = _build_deps(get_database=lambda: db) + # Replace the default registry with the one wired up above. + # AutomationDeps is a frozen dataclass-like — re-assign via + # object.__setattr__ since it's a plain dataclass. + object.__setattr__(deps, 'playlist_source_registry', registry) + + result = auto_refresh_mirrored({'playlist_id': '33'}, deps) + + assert result['status'] == 'completed' + assert result['refreshed'] == '1' + # discover_tracks ran once, with the single MB track. + assert len(discovery_calls) == 1 + assert discovery_calls[0][0]['track_name'] == 'MB Song' + assert discovery_calls[0][0]['artist_name'] == 'MB Artist' + + # Mirror DB row carries the matched_data extra_data. + call = db.mirror_calls[0] + assert call['source'] == 'listenbrainz' + assert len(call['tracks']) == 1 + track = call['tracks'][0] + assert track['source_track_id'] == 'sp-matched' + extra = json.loads(track['extra_data']) + assert extra['discovered'] is True + assert extra['provider'] == 'spotify' + assert extra['matched_data']['id'] == 'sp-matched' + + def test_spotify_public_uses_authed_spotify_when_signed_in(self): + """The handler-level fallback chain: when Spotify is authed + AND the public URL is a playlist URL, prefer the authed API so + the mirror gets album-art-bearing matched_data instead of the + bare scraper output.""" + track = _StubSpotifyTrack( + id='auth-trk', name='From Auth', artists=['Artist'], + album='Album', duration_ms=200_000, image_url='img', + ) + spotify = _StubSpotifyClient(_StubSpotifyPlaylist( + tracks=[track], id='auth-pid', name='Auth', + )) + + db = _StubDB(playlists=[ + { + 'id': 22, + 'name': 'Pub', + 'source': 'spotify_public', + 'source_playlist_id': 'hash', + 'description': 'https://open.spotify.com/playlist/abc123def456', + 'profile_id': 1, + }, + ]) + deps = _build_deps( + get_database=lambda: db, + spotify_client=spotify, + ) + + result = auto_refresh_mirrored({'playlist_id': '22'}, deps) + + assert result['refreshed'] == '1' + call = db.mirror_calls[0] + # Track came from the authed Spotify path → carries matched_data. + extra = json.loads(call['tracks'][0]['extra_data']) + assert extra['discovered'] is True + assert extra['provider'] == 'spotify' + assert extra['matched_data']['id'] == 'auth-trk' + # ─── sync_playlist ─────────────────────────────────────────────────── @@ -368,6 +677,19 @@ class TestSyncPlaylist: class TestPlaylistPipeline: + def test_pipeline_skips_when_shared_lock_is_already_running(self): + deps = _build_deps() + deps.state.set_pipeline_running(True) + + result = auto_playlist_pipeline({'all': True}, deps) + + assert result == { + 'status': 'skipped', + 'reason': 'playlist_pipeline is already running', + '_manages_own_progress': True, + } + assert deps.state.pipeline_running is True + def test_no_playlist_specified_returns_error(self): deps = _build_deps() result = auto_playlist_pipeline({}, deps) @@ -398,3 +720,26 @@ class TestPlaylistPipeline: assert result['status'] == 'error' assert result['_manages_own_progress'] is True assert deps.state.pipeline_running is False + + def test_shared_sync_tail_counts_background_sync_errors(self): + progress = [] + sync_states = { + 'auto_mirror_1': {'status': 'error', 'error': 'media server unavailable'}, + } + deps = _build_deps( + get_sync_states=lambda: sync_states, + update_progress=lambda *a, **k: progress.append(k), + ) + + result = run_sync_and_wishlist( + deps, + 'auto-1', + [{'id': 1, 'name': 'Broken'}], + sync_one_fn=lambda _pl: {'status': 'started'}, + sync_id_for_fn=lambda _pl: 'auto_mirror_1', + skip_wishlist=True, + ) + + assert result['errors'] == 1 + assert result['synced'] == 0 + assert any(p.get('log_type') == 'error' and 'media server unavailable' in p.get('log_line', '') for p in progress) diff --git a/tests/automation/test_handlers_simple.py b/tests/automation/test_handlers_simple.py index ac8b2ef4..5e535884 100644 --- a/tests/automation/test_handlers_simple.py +++ b/tests/automation/test_handlers_simple.py @@ -311,6 +311,14 @@ class TestAutomationState: s.set_pipeline_running(False) assert s.is_pipeline_running() is False + def test_try_start_pipeline_is_atomic(self): + s = AutomationState() + assert s.try_start_pipeline() is True + assert s.is_pipeline_running() is True + assert s.try_start_pipeline() is False + s.set_pipeline_running(False) + assert s.try_start_pipeline() is True + def test_concurrent_set_safe_via_lock(self): # Smoke test: two threads flipping the same field don't crash. # Lock ensures the final value is consistent. diff --git a/tests/database/test_album_thumb_preservation.py b/tests/database/test_album_thumb_preservation.py new file mode 100644 index 00000000..79304239 --- /dev/null +++ b/tests/database/test_album_thumb_preservation.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import sqlite3 + +from database.music_database import MusicDatabase + + +class _InMemoryDB(MusicDatabase): + def __init__(self): + self._conn = sqlite3.connect(":memory:") + self._conn.row_factory = sqlite3.Row + + def _get_connection(self): + return _NonClosingConn(self._conn) + + +class _NonClosingConn: + def __init__(self, real): + self._real = real + + def cursor(self): + return self._real.cursor() + + def commit(self): + return self._real.commit() + + def close(self): + pass + + +class _Album: + ratingKey = "album-1" + title = "Flower Boy" + year = 2017 + leafCount = 15 + duration = 2940 + genres = [] + thumb = None + + +def _seed(db): + cur = db._conn.cursor() + cur.execute(""" + CREATE TABLE albums ( + id TEXT PRIMARY KEY, + artist_id TEXT, + title TEXT, + year INTEGER, + thumb_url TEXT, + genres TEXT, + track_count INTEGER, + duration INTEGER, + server_source TEXT, + created_at TEXT, + updated_at TEXT + ) + """) + cur.execute(""" + INSERT INTO albums + (id, artist_id, title, year, thumb_url, server_source) + VALUES + ('album-1', 'artist-1', 'Flower Boy', 2017, '/rest/getCoverArt?id=correct-cover', 'navidrome') + """) + db._conn.commit() + + +def test_album_refresh_preserves_existing_thumb_when_incoming_thumb_missing(): + db = _InMemoryDB() + _seed(db) + + assert db.insert_or_update_media_album(_Album(), "artist-1", server_source="navidrome") is True + + row = db._conn.execute("SELECT thumb_url FROM albums WHERE id = 'album-1'").fetchone() + assert row["thumb_url"] == "/rest/getCoverArt?id=correct-cover" + + +def test_album_refresh_updates_existing_thumb_when_incoming_thumb_present(): + db = _InMemoryDB() + _seed(db) + + album = _Album() + album.thumb = "/rest/getCoverArt?id=new-cover" + + assert db.insert_or_update_media_album(album, "artist-1", server_source="navidrome") is True + + row = db._conn.execute("SELECT thumb_url FROM albums WHERE id = 'album-1'").fetchone() + assert row["thumb_url"] == "/rest/getCoverArt?id=new-cover" diff --git a/tests/database/test_get_artist_tracks_indexed.py b/tests/database/test_get_artist_tracks_indexed.py new file mode 100644 index 00000000..729d94de --- /dev/null +++ b/tests/database/test_get_artist_tracks_indexed.py @@ -0,0 +1,127 @@ +"""Tests for `MusicDatabase.get_artist_tracks_indexed` — the indexed +two-step lookup that backs the sync candidate pool fast path.""" + +from __future__ import annotations + +from database.music_database import MusicDatabase + + +def _seed(db: MusicDatabase, rows): + """Insert (artist_name, album_title, track_title, server_source) tuples. + IDs are TEXT PRIMARY KEY in this schema so we hand-mint string IDs to + keep foreign-key wiring happy.""" + conn = db._get_connection() + cursor = conn.cursor() + artist_ids: dict = {} + album_ids: dict = {} + track_counter = 0 + for artist_name, album_title, track_title, server_source in rows: + if artist_name not in artist_ids: + aid = f"a-{len(artist_ids) + 1}" + cursor.execute( + "INSERT INTO artists (id, name, server_source) VALUES (?, ?, ?)", + (aid, artist_name, server_source), + ) + artist_ids[artist_name] = aid + album_key = (artist_name, album_title, server_source) + if album_key not in album_ids: + alid = f"al-{len(album_ids) + 1}" + cursor.execute( + "INSERT INTO albums (id, artist_id, title, server_source) VALUES (?, ?, ?, ?)", + (alid, artist_ids[artist_name], album_title, server_source), + ) + album_ids[album_key] = alid + track_counter += 1 + cursor.execute( + "INSERT INTO tracks (id, album_id, artist_id, title, server_source) VALUES (?, ?, ?, ?, ?)", + (f"t-{track_counter}", album_ids[album_key], artist_ids[artist_name], track_title, server_source), + ) + conn.commit() + + +def test_exact_name_match_returns_tracks(tmp_path): + db = MusicDatabase(str(tmp_path / "music.db")) + _seed(db, [ + ('Drake', 'For All The Dogs', 'First Person Shooter', 'plex'), + ('Drake', 'For All The Dogs', 'Slime You Out', 'plex'), + ('SZA', 'SOS', 'Kill Bill', 'plex'), + ]) + tracks = db.get_artist_tracks_indexed('Drake') + titles = sorted(t.title for t in tracks) + assert titles == ['First Person Shooter', 'Slime You Out'] + + +def test_case_insensitive_fallback_finds_artist(tmp_path): + """Exact match misses 'DRAKE' (case-sensitive index lookup), but the + fallback LOWER() comparison still finds the canonical 'Drake' row.""" + db = MusicDatabase(str(tmp_path / "music.db")) + _seed(db, [ + ('Drake', 'FATD', 'IDGAF', 'plex'), + ]) + tracks = db.get_artist_tracks_indexed('DRAKE') + assert len(tracks) == 1 + assert tracks[0].title == 'IDGAF' + + +def test_artist_absent_returns_empty_list(tmp_path): + """Genuinely missing artists must fall straight through both steps + and return [] — that's what lets the caller skip the slow LIKE + fallback when the artist isn't in the library at all.""" + db = MusicDatabase(str(tmp_path / "music.db")) + _seed(db, [ + ('Drake', 'FATD', 'IDGAF', 'plex'), + ]) + assert db.get_artist_tracks_indexed('Nonexistent Artist') == [] + + +def test_empty_name_returns_empty(tmp_path): + db = MusicDatabase(str(tmp_path / "music.db")) + assert db.get_artist_tracks_indexed('') == [] + + +def test_server_source_filter_excludes_other_servers(tmp_path): + """The pool is per-server — Plex sync must not see Jellyfin tracks + even when the artist exists on both.""" + db = MusicDatabase(str(tmp_path / "music.db")) + _seed(db, [ + ('Drake', 'Plex Album', 'Plex Track', 'plex'), + ('Drake', 'Jellyfin Album', 'Jellyfin Track', 'jellyfin'), + ]) + plex_tracks = db.get_artist_tracks_indexed('Drake', server_source='plex') + jellyfin_tracks = db.get_artist_tracks_indexed('Drake', server_source='jellyfin') + assert [t.title for t in plex_tracks] == ['Plex Track'] + assert [t.title for t in jellyfin_tracks] == ['Jellyfin Track'] + + +def test_no_server_filter_returns_all_servers(tmp_path): + db = MusicDatabase(str(tmp_path / "music.db")) + _seed(db, [ + ('Drake', 'Plex Album', 'Plex Track', 'plex'), + ('Drake', 'Jellyfin Album', 'Jellyfin Track', 'jellyfin'), + ]) + tracks = db.get_artist_tracks_indexed('Drake') + titles = sorted(t.title for t in tracks) + assert titles == ['Jellyfin Track', 'Plex Track'] + + +def test_limit_caps_result_set(tmp_path): + db = MusicDatabase(str(tmp_path / "music.db")) + _seed(db, [('Drake', 'Album', f'Track {i}', 'plex') for i in range(10)]) + tracks = db.get_artist_tracks_indexed('Drake', limit=3) + assert len(tracks) == 3 + + +def test_returned_tracks_carry_artist_and_album_fields(tmp_path): + """check_track_exists' batched path reads `artist_name` and + `album_title` off each track for confidence scoring — verify the + indexed query attaches them like search_tracks does.""" + db = MusicDatabase(str(tmp_path / "music.db")) + _seed(db, [ + ('Drake', 'For All The Dogs', 'First Person Shooter', 'plex'), + ]) + tracks = db.get_artist_tracks_indexed('Drake') + assert len(tracks) == 1 + t = tracks[0] + assert t.artist_name == 'Drake' + assert t.album_title == 'For All The Dogs' + assert t.title == 'First Person Shooter' diff --git a/tests/database/test_mirrored_playlists.py b/tests/database/test_mirrored_playlists.py new file mode 100644 index 00000000..8da26480 --- /dev/null +++ b/tests/database/test_mirrored_playlists.py @@ -0,0 +1,62 @@ +from database.music_database import MusicDatabase + + +def test_update_mirrored_playlist_source_ref_preserves_tracks(tmp_path): + db = MusicDatabase(str(tmp_path / "music.db")) + playlist_id = db.mirror_playlist( + source="youtube", + source_playlist_id="oldhash", + name="Mirror", + tracks=[ + { + "track_name": "Song", + "artist_name": "Artist", + "source_track_id": "yt1", + "extra_data": {"discovered": True}, + } + ], + profile_id=1, + description="https://youtube.com/playlist?list=old", + ) + + assert playlist_id is not None + + updated = db.update_mirrored_playlist_source_ref( + playlist_id, + "newhash", + "https://youtube.com/playlist?list=new", + ) + + assert updated is True + playlist = db.get_mirrored_playlist(playlist_id) + assert playlist["source_playlist_id"] == "newhash" + assert playlist["description"] == "https://youtube.com/playlist?list=new" + + tracks = db.get_mirrored_playlist_tracks(playlist_id) + assert len(tracks) == 1 + assert tracks[0]["track_name"] == "Song" + assert tracks[0]["extra_data"] is not None + + +def test_mirror_playlist_refresh_preserves_existing_description(tmp_path): + db = MusicDatabase(str(tmp_path / "music.db")) + playlist_id = db.mirror_playlist( + source="spotify_public", + source_playlist_id="hash", + name="Release Radar", + tracks=[{"track_name": "Song", "artist_name": "Artist"}], + profile_id=1, + description="https://open.spotify.com/playlist/abc", + ) + + refreshed_id = db.mirror_playlist( + source="spotify_public", + source_playlist_id="hash", + name="Release Radar", + tracks=[{"track_name": "New Song", "artist_name": "Artist"}], + profile_id=1, + ) + + assert refreshed_id == playlist_id + playlist = db.get_mirrored_playlist(playlist_id) + assert playlist["description"] == "https://open.spotify.com/playlist/abc" diff --git a/tests/discovery/test_discovery_playlist.py b/tests/discovery/test_discovery_playlist.py index 8ab18063..8a8a8e9c 100644 --- a/tests/discovery/test_discovery_playlist.py +++ b/tests/discovery/test_discovery_playlist.py @@ -232,6 +232,36 @@ def test_unmatched_by_user_respected(): assert deps._db.extra_data_writes == [] +def test_manual_match_skipped_even_when_matched_data_incomplete(): + """manual_match=True must skip the incomplete-matched_data re-discovery + branch. The Fix-popup save shape is intentionally lean — search-result + rows don't carry track_number, and the MBID-lookup flat shape doesn't + carry album.id / release_date — so a manual fix always looks 'incomplete' + to the old check and used to be re-discovered every pipeline run, + overwriting the user's deliberate pick with whatever the auto-search + ranked first. Pin the fix: manual matches stay put.""" + extra = { + 'discovered': True, + 'manual_match': True, + 'provider': 'musicbrainz', + 'matched_data': { + 'id': 'mb-rec-id', + 'name': 'Coffee Break', + 'artists': ['Zeds Dead'], + 'album': {'name': 'Coffee Break'}, # no id, no release_date + 'source': 'musicbrainz', + # no track_number — Fix-popup shape never has it + }, + } + tracks = [_track(track_id=1, extra_data=extra)] + deps = _build_deps(tracks_by_playlist={'p1': tracks}) + + dp.run_playlist_discovery_worker([_playlist('p1')], deps=deps) + + # No extra_data writes — the manual match wasn't overwritten + assert deps._db.extra_data_writes == [] + + # --------------------------------------------------------------------------- # Cache hit short-circuit # --------------------------------------------------------------------------- diff --git a/tests/discovery/test_manual_match.py b/tests/discovery/test_manual_match.py new file mode 100644 index 00000000..8bc34b09 --- /dev/null +++ b/tests/discovery/test_manual_match.py @@ -0,0 +1,106 @@ +"""Tests for core.discovery.manual_match helpers. + +These pin the contract for two route-layer decisions lifted out of +web_server.py so the Fix-popup → mirrored-playlist back-sync flow is +testable in isolation (per kettui's standing rule that web_server.py +behavior is reproduced in core/ modules with real unit tests, not by +AST-parsing the route file). +""" + +from core.discovery.manual_match import ( + derive_manual_match_provider, + is_drifted_for_redo, +) + + +# --------------------------------------------------------------------------- +# derive_manual_match_provider +# --------------------------------------------------------------------------- + + +def test_derive_uses_payload_source_when_present(): + """Search-endpoint payloads always stamp `source` — that's the + authoritative provider for a manual match.""" + payload = {'id': 'rec-1', 'source': 'musicbrainz', 'name': 'Track'} + assert derive_manual_match_provider(payload, 'spotify') == 'musicbrainz' + + +def test_derive_falls_back_to_active_when_payload_missing_source(): + """MBID-paste path returns a lean flat shape without `source`. Fall + back to the user's active discovery source so the cached match + matches whatever provider next compares against it.""" + payload = {'id': 'mb-mbid', 'name': 'Track'} # no `source` + assert derive_manual_match_provider(payload, 'musicbrainz') == 'musicbrainz' + + +def test_derive_falls_back_to_spotify_when_both_missing(): + """Last-ditch default matches the historic hardcode so behaviour is + identical when both upstream signals are absent (e.g. broken + config, missing active source).""" + assert derive_manual_match_provider({}, None) == 'spotify' + assert derive_manual_match_provider({}, '') == 'spotify' + + +def test_derive_handles_non_dict_payload_gracefully(): + """Defensive — caller passes whatever request.get_json() returned.""" + assert derive_manual_match_provider(None, 'spotify') == 'spotify' + assert derive_manual_match_provider('not-a-dict', 'musicbrainz') == 'musicbrainz' + + +def test_derive_payload_source_wins_even_when_active_set(): + """`source` on payload is authoritative — even if the user's active + source changed mid-flow, the match came from whatever the popup + cascade actually queried.""" + payload = {'source': 'itunes'} + assert derive_manual_match_provider(payload, 'spotify') == 'itunes' + + +# --------------------------------------------------------------------------- +# is_drifted_for_redo +# --------------------------------------------------------------------------- + + +def test_drift_redo_when_provider_changed_and_not_manual(): + """Standard provider-drift case: cached provider differs from + active, no manual flag → re-discover so active source's IDs / + artwork take effect.""" + extra = {'discovered': True, 'provider': 'spotify'} + assert is_drifted_for_redo(extra, 'musicbrainz') is True + + +def test_drift_no_redo_when_provider_matches(): + """Same provider → cached entry is fresh, no redo needed.""" + extra = {'discovered': True, 'provider': 'spotify'} + assert is_drifted_for_redo(extra, 'spotify') is False + + +def test_drift_no_redo_when_manual_match_even_if_provider_drifted(): + """The crux of the bug fix: manual matches are exempt from + provider-drift redo. Re-running would overwrite the user's pick.""" + extra = {'discovered': True, 'provider': 'musicbrainz', 'manual_match': True} + assert is_drifted_for_redo(extra, 'spotify') is False + + +def test_drift_no_redo_when_manual_match_with_matching_provider(): + """Manual + provider match: trivially fresh.""" + extra = {'discovered': True, 'provider': 'spotify', 'manual_match': True} + assert is_drifted_for_redo(extra, 'spotify') is False + + +def test_drift_no_redo_when_extra_data_missing(): + """No cached entry → nothing to drift from.""" + assert is_drifted_for_redo(None, 'spotify') is False + assert is_drifted_for_redo({}, 'spotify') is False + + +def test_drift_handles_non_dict_extra_data(): + """Defensive — extra_data deserialisation can land non-dict shapes.""" + assert is_drifted_for_redo('not-a-dict', 'spotify') is False + + +def test_drift_default_provider_is_spotify_when_absent(): + """Historic cached entries may pre-date the provider column being + populated — treat absent provider as 'spotify' (the legacy default).""" + extra = {'discovered': True} # no provider field + assert is_drifted_for_redo(extra, 'spotify') is False + assert is_drifted_for_redo(extra, 'musicbrainz') is True diff --git a/tests/downloads/test_downloads_candidates.py b/tests/downloads/test_downloads_candidates.py index 1de44b51..95ed1328 100644 --- a/tests/downloads/test_downloads_candidates.py +++ b/tests/downloads/test_downloads_candidates.py @@ -419,6 +419,48 @@ def test_candidates_with_equal_confidence_both_tried(): assert deps.download_orchestrator.download_calls[0][1] == "a.flac" +def test_user_manual_pick_injects_acoustid_bypass_into_post_process_context(): + """Issue #701: when the user picks a specific candidate via the + candidates modal, the download_selected_candidate endpoint sets + `_user_manual_pick=True` on the task. The candidates helper must + propagate that into the stored post-process context as + `_skip_quarantine_check='acoustid'`; without it the manual pick + loops straight back into quarantine whenever AcoustID disagrees + with the user's selection.""" + deps = _build_deps() + _seed_task("t_manual_pick") + download_tasks["t_manual_pick"]["_user_manual_pick"] = True + + candidates = [_Candidate(filename="picked.flac", confidence=0.99)] + track = _Track() + + result = dc.attempt_download_with_candidates("t_manual_pick", candidates, track, batch_id="b1", deps=deps) + + assert result is True + ctx = matched_downloads_context["user1::picked.flac"] + assert ctx["_skip_quarantine_check"] == "acoustid" + assert ctx["_user_manual_pick"] is True + + +def test_auto_search_pick_does_not_inject_acoustid_bypass(): + """The bypass is ONLY for user-initiated manual picks. Auto-search + candidate picks (which run during the normal download flow) must + still get AcoustID verification — they're the canonical guard + against the wrong-file leak that quarantine exists to catch.""" + deps = _build_deps() + _seed_task("t_auto_pick") # No _user_manual_pick flag set + + candidates = [_Candidate(filename="auto.flac", confidence=0.99)] + track = _Track() + + result = dc.attempt_download_with_candidates("t_auto_pick", candidates, track, batch_id="b1", deps=deps) + + assert result is True + ctx = matched_downloads_context["user1::auto.flac"] + assert "_skip_quarantine_check" not in ctx + assert "_user_manual_pick" not in ctx + + def test_equal_confidence_candidates_prefer_better_peer_quality(): """Equal-confidence Soulseek candidates use peer quality as the tiebreaker.""" deps = _build_deps() diff --git a/tests/downloads/test_downloads_master.py b/tests/downloads/test_downloads_master.py index a1586028..c2c5b758 100644 --- a/tests/downloads/test_downloads_master.py +++ b/tests/downloads/test_downloads_master.py @@ -526,6 +526,36 @@ def test_no_missing_with_auto_wishlist_submits_completion(monkeypatch): assert args == ('B7',) +def test_no_missing_album_does_not_dispatch_torrent_bundle(monkeypatch): + """Release-level sources must wait until analysis confirms missing tracks.""" + album = _DBAlbum(id_=42, title='Test Album') + db = _FakeDB(album=album, album_tracks=[_DBTrack('T1')]) + monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db) + + plugin = _FakeAlbumBundleSoulseek() + deps = _build_deps( + config=_FakeConfig({'download_source.mode': 'torrent'}), + soulseek=_FakePluginWrapper({'torrent': plugin}), + ) + _seed_batch( + 'B7a', + is_album_download=True, + album_context={'name': 'Test Album', 'total_tracks': 1}, + artist_context={'name': 'Artist'}, + ) + + mw.run_full_missing_tracks_process( + 'B7a', + 'album:1', + [{'name': 'T1', 'artists': ['Artist'], 'track_number': 1}], + deps, + ) + + assert plugin.calls == [] + assert download_batches['B7a']['phase'] == 'complete' + assert 'album_bundle_source' not in download_batches['B7a'] + + # --------------------------------------------------------------------------- # Album fast path # --------------------------------------------------------------------------- @@ -862,6 +892,36 @@ def test_hybrid_first_torrent_uses_album_bundle_before_per_track(monkeypatch): assert download_batches['B27']['album_bundle_source'] == 'torrent' +def test_album_bundle_fallback_clears_private_staging(monkeypatch): + db = _FakeDB() + monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db) + + plugin = _FakeAlbumBundleSoulseek({ + 'success': False, + 'fallback': True, + 'error': 'No release passed validation', + }) + deps = _build_deps( + config=_FakeConfig({'download_source.mode': 'torrent'}), + soulseek=_FakePluginWrapper({'torrent': plugin}), + ) + _seed_batch( + 'B29', + 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('B29', 'album:1', tracks, deps) + + assert len(plugin.calls) == 1 + assert download_batches['B29']['album_bundle_state'] == 'fallback' + assert download_batches['B29']['album_bundle_private_staging'] is False + assert download_batches['B29']['album_bundle_staging_path'] is None + assert len(download_batches['B29']['queue']) == 1 + + # --------------------------------------------------------------------------- # Task creation # --------------------------------------------------------------------------- diff --git a/tests/downloads/test_downloads_staging.py b/tests/downloads/test_downloads_staging.py index 097c3323..a400f351 100644 --- a/tests/downloads/test_downloads_staging.py +++ b/tests/downloads/test_downloads_staging.py @@ -495,6 +495,74 @@ def test_staging_title_match_keeps_wrong_versions_separate(tmp_path): assert 'staging_t_wrong_version' not in matched_downloads_context +def test_staging_title_match_handles_untagged_release_filename(tmp_path): + """Album-bundle slskd downloads often arrive without ID3 tags. + + When that happens the staging cache falls back to the file stem + for the title (e.g. 'Kendrick Lamar - GNX - 03 - Reincarnated'). + The full stem is too noisy to fuzzy-match against the clean + Spotify title at the 0.80 threshold, so the variant generator + pulls out the trailing-title segment when a track-number block + is present between ' - ' delimiters. + """ + src_file = tmp_path / 'staging' / 'Kendrick Lamar - GNX - 03 - Reincarnated.flac' + src_file.parent.mkdir() + src_file.touch() + + deps = _build_deps( + transfer_path=str(tmp_path / 'transfer'), + staging_files=[ + { + 'full_path': str(src_file), + 'title': 'Kendrick Lamar - GNX - 03 - Reincarnated', + 'artist': 'Kendrick Lamar', + 'track_number': 3, + }, + ], + ) + _seed_task('t_untagged', track_info={ + '_is_explicit_album_download': True, + '_explicit_album_context': {'id': 'alb', 'name': 'GNX'}, + '_explicit_artist_context': {'id': 'art', 'name': 'Kendrick Lamar'}, + }) + + result = ds.try_staging_match( + 't_untagged', 'b1', + _Track(name='Reincarnated', artists=['Kendrick Lamar']), + deps, + ) + + assert result is True + assert matched_downloads_context['staging_t_untagged']['track_info']['track_number'] == 3 + + +def test_staging_title_match_keeps_dash_titles_intact(tmp_path): + """The trailing-title variant must not fire when there's no track-number + segment — otherwise a legit title like 'Hold Me - Live' would generate + a 'Live' variant and false-match unrelated 'Live' stems on disk. + """ + src_file = tmp_path / 'staging' / 'Live.flac' + src_file.parent.mkdir() + src_file.touch() + + deps = _build_deps( + transfer_path=str(tmp_path / 'transfer'), + staging_files=[ + {'full_path': str(src_file), 'title': 'Live', 'artist': 'Other Artist'}, + ], + ) + _seed_task('t_dash') + + result = ds.try_staging_match( + 't_dash', 'b1', + _Track(name='Hold Me - Live', artists=['Some Artist']), + deps, + ) + + assert result is False + assert 'staging_t_dash' not in matched_downloads_context + + def test_fallback_context_synthesizes_from_track(tmp_path): """Without explicit context, synthesizes spotify_artist/album from the track.""" src_file = tmp_path / 'staging' / 'Hello.flac' diff --git a/tests/downloads/test_wishlist_aggregator.py b/tests/downloads/test_wishlist_aggregator.py new file mode 100644 index 00000000..5d9714ed --- /dev/null +++ b/tests/downloads/test_wishlist_aggregator.py @@ -0,0 +1,192 @@ +"""Unit tests for ``core/downloads/wishlist_aggregator.merge_wishlist_run_status``. + +Pins the merge contract the wishlist-modal status path depends on +(Phase 1c.2.1 follow-up): when one logical wishlist run is split +across N sub-batches, the frontend modal polls the original +batch_id and expects a unified view that covers every sibling. +""" + +from __future__ import annotations + +from core.downloads.wishlist_aggregator import merge_wishlist_run_status + + +def _status(phase, **kwargs): + """Build a minimal per-batch status dict shaped like + ``build_batch_status_data``'s output.""" + base = { + 'phase': phase, + 'playlist_id': 'wishlist', + 'playlist_name': 'Wishlist', + 'active_count': 0, + 'max_concurrent': 3, + } + base.update(kwargs) + return base + + +def test_empty_siblings_returns_primary_unchanged(): + primary = _status('downloading', tasks=[{'task_id': 't1', 'track_index': 0}]) + out = merge_wishlist_run_status(primary, []) + assert out is primary + + +def test_two_siblings_merge_tasks_with_reindexed_track_index(): + """Both siblings locally start at track_index 0 — after merge, + indices are globally unique 0..N-1.""" + primary = _status( + 'downloading', + analysis_results=[ + {'track_index': 0, 'track': {'name': 'A1'}, 'found': False, 'confidence': 0.0}, + {'track_index': 1, 'track': {'name': 'A2'}, 'found': False, 'confidence': 0.0}, + ], + tasks=[ + {'task_id': 'task-a1', 'track_index': 0, 'status': 'downloading'}, + {'task_id': 'task-a2', 'track_index': 1, 'status': 'downloading'}, + ], + ) + sibling = _status( + 'downloading', + analysis_results=[ + {'track_index': 0, 'track': {'name': 'B1'}, 'found': False, 'confidence': 0.0}, + ], + tasks=[ + {'task_id': 'task-b1', 'track_index': 0, 'status': 'searching'}, + ], + ) + + merged = merge_wishlist_run_status(primary, [sibling]) + + # Three globally-unique track indices. + assert [r['track_index'] for r in merged['analysis_results']] == [0, 1, 2] + # Each task's track_index re-indexed to match its analysis_result. + indices_by_task = {t['task_id']: t['track_index'] for t in merged['tasks']} + assert indices_by_task == {'task-a1': 0, 'task-a2': 1, 'task-b1': 2} + # Tasks sorted by their new track_index. + assert [t['task_id'] for t in merged['tasks']] == ['task-a1', 'task-a2', 'task-b1'] + + +def test_phase_aggregation_most_advanced_live_phase_wins(): + """analysis + downloading + complete → downloading. Modal's task + table is phase-gated to downloading/complete/error so we must + surface that phase the moment any sibling has tasks, else the + modal stays in bundle/analysis UI and tasks stay invisible.""" + primary = _status('complete') + sibling1 = _status('downloading') + sibling2 = _status('analysis') + merged = merge_wishlist_run_status(primary, [sibling1, sibling2]) + assert merged['phase'] == 'downloading' + + +def test_phase_aggregation_downloading_wins_over_album_downloading(): + """Regression test for the parallel-bundle modal-blank bug: + one sibling past its bundle into the task stage means the + modal MUST be in 'downloading' phase, otherwise the task + rows for the advanced sibling don't render and the user + sees nothing while both albums actually download on slskd.""" + primary = _status('downloading') + sibling = _status('album_downloading') + merged = merge_wishlist_run_status(primary, [sibling]) + assert merged['phase'] == 'downloading' + + +def test_phase_aggregation_all_album_downloading_stays_album_downloading(): + """No sibling has reached the task stage yet — bundle progress + UI is the right thing to show.""" + primary = _status('album_downloading') + sibling = _status('album_downloading') + merged = merge_wishlist_run_status(primary, [sibling]) + assert merged['phase'] == 'album_downloading' + + +def test_phase_aggregation_album_downloading_wins_over_analysis(): + primary = _status('analysis') + sibling = _status('album_downloading') + merged = merge_wishlist_run_status(primary, [sibling]) + assert merged['phase'] == 'album_downloading' + + +def test_phase_aggregation_all_complete_returns_complete(): + primary = _status('complete') + sibling1 = _status('complete') + merged = merge_wishlist_run_status(primary, [sibling1]) + assert merged['phase'] == 'complete' + + +def test_phase_aggregation_mixed_complete_and_other_returns_downloading(): + """A finished sibling alongside a still-downloading sibling + surfaces 'downloading' (the run isn't done).""" + primary = _status('complete') + sibling = _status('downloading') + merged = merge_wishlist_run_status(primary, [sibling]) + assert merged['phase'] == 'downloading' + + +def test_phase_aggregation_error_is_sticky(): + """If any sibling errored, the merged phase is 'error' even + if other siblings are still running. Modal should show the + failure so the user notices.""" + primary = _status('downloading') + sibling = _status('error') + merged = merge_wishlist_run_status(primary, [sibling]) + assert merged['phase'] == 'error' + + +def test_analysis_progress_summed_across_siblings(): + primary = _status( + 'analysis', + analysis_progress={'total': 10, 'processed': 7}, + ) + sibling = _status( + 'analysis', + analysis_progress={'total': 5, 'processed': 2}, + ) + merged = merge_wishlist_run_status(primary, [sibling]) + assert merged['analysis_progress'] == {'total': 15, 'processed': 9} + + +def test_album_bundle_picks_active_sibling_over_idle(): + """Primary is past its bundle stage (state='staged'); + sibling is currently downloading_release. Merge surfaces the + active sibling's bundle so the progress bar stays useful.""" + primary = _status( + 'downloading', + album_bundle={'state': 'staged', 'progress': 100, 'release': 'PRISM (Deluxe)'}, + ) + sibling = _status( + 'album_downloading', + album_bundle={'state': 'downloading_release', 'progress': 42, 'release': '1432'}, + ) + merged = merge_wishlist_run_status(primary, [sibling]) + assert merged['album_bundle']['release'] == '1432' + assert merged['album_bundle']['progress'] == 42 + + +def test_album_bundle_falls_back_when_no_active_sibling(): + primary = _status( + 'complete', + album_bundle={'state': 'staged', 'progress': 100, 'release': 'PRISM (Deluxe)'}, + ) + sibling = _status( + 'complete', + album_bundle={'state': 'staged', 'progress': 100, 'release': '1432'}, + ) + merged = merge_wishlist_run_status(primary, [sibling]) + # Falls back to primary's bundle (first non-empty). + assert merged['album_bundle']['release'] == 'PRISM (Deluxe)' + + +def test_active_count_summed_across_siblings(): + primary = _status('downloading', active_count=2) + sibling = _status('downloading', active_count=1) + merged = merge_wishlist_run_status(primary, [sibling]) + assert merged['active_count'] == 3 + + +def test_primary_playlist_id_preserved(): + primary = _status('downloading', playlist_id='wishlist', playlist_name='Wishlist (Auto)') + sibling = _status('downloading', playlist_id='wishlist', playlist_name='Wishlist (Album: 1432)') + merged = merge_wishlist_run_status(primary, [sibling]) + # Primary's playlist_name + playlist_id propagate (it's the row the modal opened against). + assert merged['playlist_id'] == 'wishlist' + assert merged['playlist_name'] == 'Wishlist (Auto)' diff --git a/tests/imports/test_import_file_ops.py b/tests/imports/test_import_file_ops.py index 16e68ad7..ce9ecd4a 100644 --- a/tests/imports/test_import_file_ops.py +++ b/tests/imports/test_import_file_ops.py @@ -5,16 +5,40 @@ from core.imports.file_ops import ( cleanup_empty_directories, safe_move_file, ) -from core.imports.filename import extract_track_number_from_filename +from core.imports.filename import ( + extract_explicit_track_number, + extract_track_number_from_filename, +) from core.imports.staging import read_staging_file_metadata def test_extract_track_number_from_filename_handles_common_patterns(): assert extract_track_number_from_filename("01 - Song.mp3") == 1 assert extract_track_number_from_filename("1-03 - Song.mp3") == 3 + # Bare filename keeps the auto-import-friendly default of 1 — there's + # no upstream metadata to recover from in that flow. assert extract_track_number_from_filename("Artist - Song.mp3") == 1 +def test_extract_explicit_track_number_returns_zero_when_no_prefix(): + """Staging readers need to distinguish 'track 1' from 'unknown'. + + Pinned because: + - the legacy extractor defaults to 1 (auto-import semantics), + - staging file scanners that conflate the two end up writing every + file in an untagged album bundle to track_number=1. + """ + # Bare titles with no numeric prefix → 0 (unknown). + assert extract_explicit_track_number("Artist - Song.mp3") == 0 + assert extract_explicit_track_number("Cha-La Head-Cha-La.flac") == 0 + assert extract_explicit_track_number("") == 0 + # Real prefixes still parse correctly. + assert extract_explicit_track_number("01 - Song.mp3") == 1 + assert extract_explicit_track_number("(03) Song.mp3") == 3 + # Disc-track format requires a separator after the track number. + assert extract_explicit_track_number("1-07 - Song.mp3") == 7 + + def test_safe_move_file_replaces_existing_destination(tmp_path): src = tmp_path / "source.flac" dst_dir = tmp_path / "dest" @@ -92,6 +116,27 @@ def test_read_staging_file_metadata_falls_back_to_filename_track_number(monkeypa assert metadata["disc_number"] == 1 +def test_read_staging_file_metadata_returns_zero_track_when_unknown(monkeypatch, tmp_path): + """Bare filename + no tags → track_number=0, not 1. + + Pre-fix this returned 1 because the filename extractor's default + was 1. The bug caused every untagged file in an album-bundle + download to land in the staging cache with track_number=1, which + then short-circuited the downstream resolution chain that should + have picked up the real number from track_info. + """ + file_path = tmp_path / "Cha-La Head-Cha-La.flac" + file_path.write_text("fake") + + fake_mutagen = types.ModuleType("mutagen") + fake_mutagen.File = lambda path, easy=True: None + monkeypatch.setitem(sys.modules, "mutagen", fake_mutagen) + + metadata = read_staging_file_metadata(str(file_path), file_path.name) + + assert metadata["track_number"] == 0 + + def test_read_staging_file_metadata_uses_filename_fallbacks_when_tags_are_invalid(monkeypatch, tmp_path): file_path = tmp_path / "02 - Song Three.flac" file_path.write_text("fake") diff --git a/tests/media_server/test_navidrome_pinning.py b/tests/media_server/test_navidrome_pinning.py index 500d3953..7850743f 100644 --- a/tests/media_server/test_navidrome_pinning.py +++ b/tests/media_server/test_navidrome_pinning.py @@ -12,7 +12,7 @@ from unittest.mock import MagicMock, patch import pytest -from core.navidrome_client import NavidromeClient +from core.navidrome_client import NavidromeAlbum, NavidromeClient @pytest.fixture @@ -77,3 +77,22 @@ def test_get_all_album_ids_returns_set(nav_client): assert isinstance(result, set) assert result == {'nav-1', 'nav-2'} + + +def test_navidrome_album_exposes_cover_art_url(nav_client): + album = NavidromeAlbum({ + 'id': 'album-1', + 'name': 'Flower Boy', + 'coverArt': 'cover-123', + }, nav_client) + + assert album.thumb == '/rest/getCoverArt?id=cover-123' + + +def test_navidrome_album_cover_art_falls_back_to_album_id(nav_client): + album = NavidromeAlbum({ + 'id': 'album-1', + 'name': 'Flower Boy', + }, nav_client) + + assert album.thumb == '/rest/getCoverArt?id=album-1' diff --git a/tests/metadata/test_musicbrainz_search.py b/tests/metadata/test_musicbrainz_search.py index e5859707..96d2c220 100644 --- a/tests/metadata/test_musicbrainz_search.py +++ b/tests/metadata/test_musicbrainz_search.py @@ -1000,33 +1000,84 @@ def test_get_recording_flat_swallows_client_errors(): # search_tracks_with_artist — Fix-popup cascade adapter # --------------------------------------------------------------------------- -def test_search_tracks_with_artist_uses_bare_query_mode(): - """The Fix-popup cascade needs MB's bare-query mode so diacritics and - bracketed suffixes don't kill recall. The adapter must pass strict=False - through to the underlying search_recording call.""" +def test_search_tracks_with_artist_strict_first_when_both_fields(): + """Both fields present → strict field-scoped Lucene query first + (`recording:"" AND artist:""`). Fixes the "Coffee Break" + + "Zeds Dead" case where bare query lets MB's title-text-biased + scorer surface unrelated covers ahead of the canonical recording.""" client = MusicBrainzSearchClient() client._client = MagicMock() client._client.search_recording.return_value = [ - {'id': 'rec-1', 'title': 'Army of Me', 'score': 95, - 'releases': [{'id': 'rel-1', 'title': 'Post', 'date': '1995'}], - 'artist-credit': [{'name': 'Björk'}]}, + {'id': 'rec-1', 'title': 'Coffee Break', 'score': 95, + 'length': 184000, + 'releases': [{'id': 'rel-1', 'title': 'Coffee Break', 'date': '2015'}], + 'artist-credit': [{'name': 'Zeds Dead'}]}, ] - tracks = client.search_tracks_with_artist('Army of Me', 'Björk', limit=10) + tracks = client.search_tracks_with_artist('Coffee Break', 'Zeds Dead', limit=10) - # strict=False is the critical bit — fuzzy recall, not phrase precision + # strict=True is the critical bit — anchors artist via Lucene AND clause client._client.search_recording.assert_called_once_with( - 'Army of Me', artist_name='Björk', limit=10, strict=False + 'Coffee Break', artist_name='Zeds Dead', limit=10, strict=True ) assert len(tracks) == 1 - assert tracks[0].name == 'Army of Me' - assert 'Björk' in tracks[0].artists + assert tracks[0].name == 'Coffee Break' + assert 'Zeds Dead' in tracks[0].artists + + +def test_search_tracks_with_artist_falls_back_to_bare_when_strict_empty(): + """Strict phrase match misses diacritic / alias cases ("Bjork" query + vs canonical "Björk" artist). When strict returns nothing, fall + through to bare query so rerank can still surface the right answer.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_recording.side_effect = [ + [], # strict pass → no hits (Lucene phrase match fails on diacritic) + [ # bare pass → recall via alias index + {'id': 'rec-canonical', 'title': 'Army of Me', 'score': 28, + 'releases': [], 'artist-credit': [{'name': 'Björk'}]}, + ], + ] + + tracks = client.search_tracks_with_artist('Army of Me', 'Bjork', limit=10) + + assert client._client.search_recording.call_count == 2 + first_call = client._client.search_recording.call_args_list[0] + second_call = client._client.search_recording.call_args_list[1] + assert first_call.kwargs['strict'] is True + assert second_call.kwargs['strict'] is False + assert len(tracks) == 1 + assert tracks[0].id == 'rec-canonical' + + +def test_search_tracks_with_artist_does_not_resort_by_length(): + """Length-preference ordering lives downstream in + ``rerank_tracks(..., prefer_known_duration=True)`` — sorting here + would be re-sorted away by rerank anyway, so this method preserves + the order MB returned. Pin the contract: this method does not + re-shuffle by duration_ms.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_recording.return_value = [ + {'id': 'rec-no-length', 'title': 'Coffee Break', 'score': 100, + 'releases': [], 'artist-credit': [{'name': 'Zeds Dead'}]}, + {'id': 'rec-with-length', 'title': 'Coffee Break', 'score': 90, + 'length': 184000, + 'releases': [], 'artist-credit': [{'name': 'Zeds Dead'}]}, + ] + + tracks = client.search_tracks_with_artist('Coffee Break', 'Zeds Dead', limit=10) + + # MB's order is preserved here — rerank applies length-pref downstream. + assert tracks[0].id == 'rec-no-length' + assert tracks[1].id == 'rec-with-length' def test_search_tracks_with_artist_handles_missing_artist(): - """Track-only query (no artist) still works — empty string becomes - None, and the underlying client searches recordings without an - artist filter.""" + """Track-only query (no artist) still works — single-field path takes + bare-query mode directly (no strict-first round-trip since there's no + artist to anchor). Empty string becomes None so MB drops the AND + clause.""" client = MusicBrainzSearchClient() client._client = MagicMock() client._client.search_recording.return_value = [ @@ -1036,7 +1087,6 @@ def test_search_tracks_with_artist_handles_missing_artist(): client.search_tracks_with_artist('Some Song', '', limit=5) - # Empty artist → None passed to the client so MB drops the AND clause client._client.search_recording.assert_called_once_with( 'Some Song', artist_name=None, limit=5, strict=False ) @@ -1051,33 +1101,33 @@ def test_search_tracks_with_artist_empty_returns_empty_list(): client._client.search_recording.assert_not_called() -def test_search_tracks_with_artist_keeps_low_score_for_rerank(): - """Cascade path uses a low score floor (20) so MB recordings whose - title doesn't literally contain the artist name still enter the - candidate pool — the endpoint's rerank pass surfaces them by - artist-match relevance. Real example: "Army of Me" + "Bjork" — the - canonical Björk recording scores 28 in MB (title doesn't contain - "Bjork"), while title-collision covers like "Army of Me (Bjork)" - score 73-100. Strict 80 floor drops the right answer.""" +def test_search_tracks_with_artist_bare_fallback_keeps_low_score_for_rerank(): + """When strict returns nothing and we fall through to bare, the bare + pass uses a low score floor (20) so MB recordings whose title doesn't + literally contain the artist name still enter the candidate pool — + the endpoint's rerank pass surfaces them by artist-match relevance. + Real example: "Army of Me" + "Bjork" — strict fails on the diacritic + mismatch, bare picks up the canonical Björk recording at score 28 + while filtering true noise at score 5.""" client = MusicBrainzSearchClient() client._client = MagicMock() - client._client.search_recording.return_value = [ - {'id': 'rec-cover', 'title': 'Army of Me (Bjork)', 'score': 100, - 'releases': [], 'artist-credit': [{'name': 'HIRS Collective'}]}, - {'id': 'rec-canonical', 'title': 'Army of Me', 'score': 28, - 'releases': [], 'artist-credit': [{'name': 'Björk'}]}, - {'id': 'rec-noise', 'title': 'Bjork', 'score': 5, - 'releases': [], 'artist-credit': [{'name': 'Random'}]}, + client._client.search_recording.side_effect = [ + [], # strict pass → no hits + [ # bare pass + {'id': 'rec-cover', 'title': 'Army of Me (Bjork)', 'score': 100, + 'releases': [], 'artist-credit': [{'name': 'HIRS Collective'}]}, + {'id': 'rec-canonical', 'title': 'Army of Me', 'score': 28, + 'releases': [], 'artist-credit': [{'name': 'Björk'}]}, + {'id': 'rec-noise', 'title': 'Bjork', 'score': 5, + 'releases': [], 'artist-credit': [{'name': 'Random'}]}, + ], ] tracks = client.search_tracks_with_artist('Army of Me', 'Bjork', limit=50) ids = [t.id for t in tracks] - # Score=28 canonical Björk recording is kept — the endpoint's rerank - # will surface it by artist match. assert 'rec-canonical' in ids assert 'rec-cover' in ids - # Score=5 is below the 20 floor — true garbage still filtered out. assert 'rec-noise' not in ids @@ -1120,7 +1170,10 @@ def test_search_tracks_text_strict_param_default_true(): def test_search_tracks_with_artist_swallows_client_errors(): """MB client raising must not crash the endpoint — return [] so the - Fix-popup cascade falls through to the next source.""" + Fix-popup cascade falls through to the next source. Both strict and + bare passes swallow exceptions independently, so a strict-pass raise + still lets the bare-pass run; a bare-pass raise after empty strict + returns [].""" client = MusicBrainzSearchClient() client._client = MagicMock() client._client.search_recording.side_effect = RuntimeError('network down') diff --git a/tests/metadata/test_relevance.py b/tests/metadata/test_relevance.py index 38e71555..4c6f9394 100644 --- a/tests/metadata/test_relevance.py +++ b/tests/metadata/test_relevance.py @@ -50,6 +50,7 @@ def _track( album: str = 'Unknown', album_type: str = 'album', track_id: str = 't', + duration_ms: int = 200000, ) -> Track: """Tiny Track factory — keeps test bodies focused on the fields under test.""" @@ -58,7 +59,7 @@ def _track( name=name, artists=[artist], album=album, - duration_ms=200000, + duration_ms=duration_ms, album_type=album_type, ) @@ -366,6 +367,56 @@ class TestRerankTracks: ranked = rerank_tracks([a, b], expected_title='Track', expected_artist='Artist') assert [t.id for t in ranked] == ['first', 'second'] + def test_prefer_known_duration_promotes_length_known_on_ties(self): + """MB has multiple recordings per song where some lack length + data. With ``prefer_known_duration=True`` and equal relevance + scores, the recording with non-zero duration_ms must surface + ahead of the length-less sibling.""" + no_length = _track('Track', artist='Artist', track_id='no-len', duration_ms=0) + with_length = _track('Track', artist='Artist', track_id='with-len', duration_ms=184000) + ranked = rerank_tracks( + [no_length, with_length], + expected_title='Track', + expected_artist='Artist', + prefer_known_duration=True, + ) + assert [t.id for t in ranked] == ['with-len', 'no-len'] + + def test_prefer_known_duration_does_not_override_relevance(self): + """Length-preference is a TIEBREAKER, not a global resort. A + length-less track that scores higher on relevance must still + win — only equal-score pairs use length as the deciding bit.""" + # Wrong-artist length-known: scored low. Right-artist length-less: + # scored high. + length_known_cover = _track( + 'Track', artist='Karaoke Channel', album='Karaoke Hits', + album_type='compilation', track_id='cover', duration_ms=184000, + ) + length_less_real = _track( + 'Track', artist='Real Artist', track_id='real', duration_ms=0, + ) + ranked = rerank_tracks( + [length_known_cover, length_less_real], + expected_title='Track', + expected_artist='Real Artist', + prefer_known_duration=True, + ) + assert ranked[0].id == 'real', "relevance must beat length-pref" + + def test_prefer_known_duration_default_off(self): + """Default behaviour unchanged — length-preference is opt-in + for MB callers; Spotify / iTunes / Deezer don't need it + because their search results always include length.""" + no_length = _track('Track', artist='Artist', track_id='no-len', duration_ms=0) + with_length = _track('Track', artist='Artist', track_id='with-len', duration_ms=184000) + ranked = rerank_tracks( + [no_length, with_length], + expected_title='Track', + expected_artist='Artist', + ) + # Default: stable input order, no length-pref re-shuffle. + assert [t.id for t in ranked] == ['no-len', 'with-len'] + # --------------------------------------------------------------------------- # filter_and_rerank — score floor convenience diff --git a/tests/playlists/test_source_refs.py b/tests/playlists/test_source_refs.py new file mode 100644 index 00000000..fb5e32e4 --- /dev/null +++ b/tests/playlists/test_source_refs.py @@ -0,0 +1,82 @@ +import pytest + +from core.playlists.source_refs import ( + describe_mirrored_source_ref, + normalize_mirrored_source_ref, + require_refresh_url, +) + + +def test_spotify_public_url_stores_hash_and_canonical_url(): + out = normalize_mirrored_source_ref( + "spotify_public", + "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M?si=abc", + ) + + assert out.source_playlist_id == "5e7de827abd1" + assert out.description == "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M" + + +def test_spotify_public_raw_id_defaults_to_playlist_url(): + out = normalize_mirrored_source_ref("spotify_public", "37i9dQZF1DXcBWIGoYBM5M") + + assert out.source_playlist_id == "5e7de827abd1" + assert out.description == "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M" + + +def test_spotify_public_embed_url_canonicalizes_to_open_url(): + out = normalize_mirrored_source_ref( + "spotify_public", + "https://open.spotify.com/embed/playlist/37i9dQZF1DX0kbJZpiYdZl?pi=abc", + ) + + assert out.source_playlist_id == "d29b105efc8e" + assert out.description == "https://open.spotify.com/playlist/37i9dQZF1DX0kbJZpiYdZl" + + +def test_youtube_url_stores_hash_and_canonical_url(): + out = normalize_mirrored_source_ref( + "youtube", + "https://music.youtube.com/playlist?list=PL123&si=abc", + ) + + assert out.source_playlist_id == "e5f5ab31b8f0" + assert out.description == "https://youtube.com/playlist?list=PL123" + + +def test_direct_id_sources_preserve_existing_description(): + out = normalize_mirrored_source_ref("tidal", "abc123", "Original service description") + + assert out.source_playlist_id == "abc123" + assert out.description == "Original service description" + + +def test_hash_backed_refresh_requires_url(): + with pytest.raises(ValueError, match="missing its original source URL"): + require_refresh_url("spotify_public", "", "Release Radar") + + +def test_describe_hash_backed_ref_reports_missing_url(): + view = describe_mirrored_source_ref({ + "source": "spotify_public", + "source_playlist_id": "hash", + "description": "", + "name": "Release Radar", + }) + + assert view.source_ref == "hash" + assert view.source_ref_kind == "url" + assert view.source_ref_status == "missing" + assert "missing its original source URL" in view.source_ref_error + + +def test_describe_hash_backed_ref_uses_url_when_present(): + view = describe_mirrored_source_ref({ + "source": "spotify_public", + "source_playlist_id": "hash", + "description": "https://open.spotify.com/playlist/abc", + }) + + assert view.source_ref == "https://open.spotify.com/playlist/abc" + assert view.source_ref_kind == "url" + assert view.source_ref_status == "ok" diff --git a/tests/static/test_auto_sync.mjs b/tests/static/test_auto_sync.mjs new file mode 100644 index 00000000..311be6c2 --- /dev/null +++ b/tests/static/test_auto_sync.mjs @@ -0,0 +1,394 @@ +// Tests for the pure-function helpers in `webui/static/auto-sync.js`. +// Run via: +// +// node --test tests/static/test_auto_sync.mjs +// +// The pytest wrapper at `tests/test_auto_sync_js.py` shells out to +// `node --test` and surfaces the result inside the regular pytest run. +// +// The module is loaded into a sandboxed `vm` context with stubs for +// the few globals it relies on (`_autoParseUTC` for the timezone-aware +// next_run label). No DOM — just the calculation contract. + +import { test, describe, before } from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +// Values returned from the sandboxed VM context are cross-realm — their +// prototype chain differs from the test realm's, so deepStrictEqual on +// raw VM objects fails even when shape and values match. JSON-round-trip +// to compare structural equality only. +function deepShapeEqual(actual, expected, msg) { + assert.deepEqual( + JSON.parse(JSON.stringify(actual)), + JSON.parse(JSON.stringify(expected)), + msg, + ); +} + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const AUTOSYNC_PATH = resolve(__dirname, '..', '..', 'webui', 'static', 'auto-sync.js'); + +let AUTOSYNC_SOURCE; +before(() => { + AUTOSYNC_SOURCE = readFileSync(AUTOSYNC_PATH, 'utf8'); +}); + +// Match the actual implementation in stats-automations.js so the +// timezone-bug fix is exercised end-to-end through auto-sync.js. +function realAutoParseUTC(ts) { + if (!ts) return NaN; + if (/[Zz]$/.test(ts) || /[+-]\d{2}:\d{2}$/.test(ts)) return new Date(ts).getTime(); + return new Date(ts + 'Z').getTime(); +} + +function makeSandbox() { + const sandbox = { + window: {}, + document: { getElementById: () => null, body: {} }, + console: { debug: () => {}, error: () => {}, log: () => {} }, + fetch: async () => { throw new Error('fetch not stubbed for this test'); }, + // Globals that auto-sync.js expects to find in the window namespace + _autoParseUTC: realAutoParseUTC, + _autoFormatTrigger: () => 'trigger', + _esc: (s) => String(s), + _escAttr: (s) => String(s), + showToast: () => {}, + showConfirmDialog: async () => true, + loadMirroredPlaylists: () => {}, + updateMirroredCardPhase: () => {}, + openMirroredPlaylistModal: () => {}, + closeMirroredModal: () => {}, + youtubePlaylistStates: {}, + setInterval: () => 0, + clearInterval: () => {}, + }; + vm.createContext(sandbox); + vm.runInContext(AUTOSYNC_SOURCE, sandbox); + return sandbox; +} + +// ========================================================================= +// autoSyncTriggerForHours / autoSyncHoursFromTrigger — round-trip +// ========================================================================= + +describe('autoSyncTriggerForHours', () => { + test('sub-day intervals become hours', () => { + const sb = makeSandbox(); + deepShapeEqual(sb.autoSyncTriggerForHours(1), { interval: 1, unit: 'hours' }); + deepShapeEqual(sb.autoSyncTriggerForHours(12), { interval: 12, unit: 'hours' }); + }); + + test('whole-day multiples become days', () => { + const sb = makeSandbox(); + deepShapeEqual(sb.autoSyncTriggerForHours(24), { interval: 1, unit: 'days' }); + deepShapeEqual(sb.autoSyncTriggerForHours(48), { interval: 2, unit: 'days' }); + deepShapeEqual(sb.autoSyncTriggerForHours(168), { interval: 7, unit: 'days' }); + }); + + test('non-day-multiple > 24 stays as hours', () => { + const sb = makeSandbox(); + // 36h doesn't divide evenly into days, stay as hours + deepShapeEqual(sb.autoSyncTriggerForHours(36), { interval: 36, unit: 'hours' }); + }); + + test('invalid input defaults to 24 hours', () => { + const sb = makeSandbox(); + // Per autoSyncTriggerForHours: `parseInt(undefined, 10) || 24` → 24, becomes 1 day + deepShapeEqual(sb.autoSyncTriggerForHours(undefined), { interval: 1, unit: 'days' }); + }); +}); + +describe('autoSyncHoursFromTrigger', () => { + test('hours unit returned directly', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncHoursFromTrigger({ interval: 6, unit: 'hours' }), 6); + }); + + test('days unit multiplied by 24', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncHoursFromTrigger({ interval: 3, unit: 'days' }), 72); + }); + + test('weeks unit multiplied by 168', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncHoursFromTrigger({ interval: 1, unit: 'weeks' }), 168); + }); + + test('minutes unit rounds up to at least 1 hour', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncHoursFromTrigger({ interval: 30, unit: 'minutes' }), 1); + assert.equal(sb.autoSyncHoursFromTrigger({ interval: 90, unit: 'minutes' }), 2); + }); + + test('zero or missing interval returns null', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncHoursFromTrigger({}), null); + assert.equal(sb.autoSyncHoursFromTrigger({ interval: 0 }), null); + }); + + test('round-trip with autoSyncTriggerForHours preserves hour count', () => { + const sb = makeSandbox(); + for (const hours of [1, 4, 12, 24, 48, 168]) { + const config = sb.autoSyncTriggerForHours(hours); + assert.equal(sb.autoSyncHoursFromTrigger(config), hours, `round-trip ${hours}`); + } + }); +}); + +// ========================================================================= +// Label helpers +// ========================================================================= + +describe('autoSyncBucketLabel', () => { + test('weekly bucket', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncBucketLabel(168), 'Weekly'); + }); + + test('day-multiple buckets', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncBucketLabel(24), '1d'); + assert.equal(sb.autoSyncBucketLabel(48), '2d'); + }); + + test('sub-day buckets', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncBucketLabel(1), '1h'); + assert.equal(sb.autoSyncBucketLabel(12), '12h'); + }); +}); + +describe('autoSyncIntervalLabel', () => { + test('pluralization', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncIntervalLabel(1), 'Every 1 hour'); + assert.equal(sb.autoSyncIntervalLabel(2), 'Every 2 hours'); + assert.equal(sb.autoSyncIntervalLabel(24), 'Every 1 day'); + assert.equal(sb.autoSyncIntervalLabel(48), 'Every 2 days'); + assert.equal(sb.autoSyncIntervalLabel(168), 'Every week'); + }); +}); + +describe('autoSyncSourceLabel', () => { + test('known sources mapped', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncSourceLabel('spotify'), 'Spotify'); + assert.equal(sb.autoSyncSourceLabel('youtube'), 'YouTube'); + }); + + test('unknown source returns the raw key', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncSourceLabel('newthing'), 'newthing'); + }); + + test('falsy source returns "Other"', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncSourceLabel(''), 'Other'); + assert.equal(sb.autoSyncSourceLabel(null), 'Other'); + }); +}); + +// ========================================================================= +// Schedulability and ownership predicates +// ========================================================================= + +describe('autoSyncCanSchedulePlaylist', () => { + test('blocks file and beatport sources', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncCanSchedulePlaylist({ source: 'file' }), false); + assert.equal(sb.autoSyncCanSchedulePlaylist({ source: 'beatport' }), false); + }); + + test('allows refreshable sources', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncCanSchedulePlaylist({ source: 'spotify' }), true); + assert.equal(sb.autoSyncCanSchedulePlaylist({ source: 'youtube' }), true); + }); + + test('null/undefined playlist returns falsy', () => { + const sb = makeSandbox(); + assert.ok(!sb.autoSyncCanSchedulePlaylist(null)); + assert.ok(!sb.autoSyncCanSchedulePlaylist(undefined)); + }); +}); + +describe('autoSyncIsPipelineAutomation', () => { + test('matches playlist_pipeline action type', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncIsPipelineAutomation({ action_type: 'playlist_pipeline' }), true); + assert.equal(sb.autoSyncIsPipelineAutomation({ action_type: 'process_wishlist' }), false); + }); +}); + +describe('autoSyncPlaylistIdFromAutomation', () => { + test('extracts numeric playlist_id', () => { + const sb = makeSandbox(); + const auto = { action_type: 'playlist_pipeline', action_config: { playlist_id: '42' } }; + assert.equal(sb.autoSyncPlaylistIdFromAutomation(auto), 42); + }); + + test('returns null when all=true (catch-all pipeline)', () => { + const sb = makeSandbox(); + const auto = { action_type: 'playlist_pipeline', action_config: { all: true } }; + assert.equal(sb.autoSyncPlaylistIdFromAutomation(auto), null); + }); + + test('returns null for non-pipeline automations', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncPlaylistIdFromAutomation({ action_type: 'other' }), null); + }); + + test('returns null when playlist_id missing', () => { + const sb = makeSandbox(); + const auto = { action_type: 'playlist_pipeline', action_config: {} }; + assert.equal(sb.autoSyncPlaylistIdFromAutomation(auto), null); + }); +}); + +describe('autoSyncIsScheduleOwned', () => { + test('owned_by="auto_sync" wins over name/group', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncIsScheduleOwned({ + owned_by: 'auto_sync', name: 'Whatever', group_name: 'unrelated', + }), true); + }); + + test('legacy name-prefix still recognized', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncIsScheduleOwned({ name: 'Auto-Sync: Discover Weekly' }), true); + }); + + test('legacy group_name still recognized', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncIsScheduleOwned({ group_name: 'Playlist Auto-Sync' }), true); + }); + + test('automation with no owned_by and no legacy markers returns false', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncIsScheduleOwned({ name: 'My Custom Pipeline' }), false); + }); +}); + +// ========================================================================= +// State partitioning +// ========================================================================= + +describe('buildAutoSyncScheduleState', () => { + test('partitions board-owned schedules from custom pipelines', () => { + const sb = makeSandbox(); + const playlists = [{ id: 1, name: 'Discover Weekly' }, { id: 2, name: 'Top Hits' }]; + const automations = [ + { + id: 10, action_type: 'playlist_pipeline', trigger_type: 'schedule', + trigger_config: { interval: 1, unit: 'hours' }, + action_config: { playlist_id: '1' }, + owned_by: 'auto_sync', + enabled: 1, + }, + { + id: 11, action_type: 'playlist_pipeline', trigger_type: 'schedule', + trigger_config: { interval: 1, unit: 'days' }, + action_config: { playlist_id: '99' }, // not in playlists, but custom-owned + enabled: 1, + // no owned_by → custom pipeline + }, + ]; + const state = sb.buildAutoSyncScheduleState(playlists, automations); + assert.equal(Object.keys(state.playlistSchedules).length, 1); + assert.equal(state.playlistSchedules[1].automation_id, 10); + assert.equal(state.playlistSchedules[1].hours, 1); + assert.equal(state.automationPipelines.length, 1); + assert.equal(state.automationPipelines[0].id, 11); + }); + + test('non-pipeline automations are ignored entirely', () => { + const sb = makeSandbox(); + const automations = [ + { id: 20, action_type: 'process_wishlist', trigger_type: 'schedule' }, + ]; + const state = sb.buildAutoSyncScheduleState([], automations); + deepShapeEqual(state.playlistSchedules, {}); + deepShapeEqual(state.automationPipelines, []); + }); +}); + +// ========================================================================= +// Timezone-aware countdown — the headline bug this branch fixed +// ========================================================================= + +describe('autoSyncNextRunLabel', () => { + test('empty string for missing input', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncNextRunLabel(''), ''); + assert.equal(sb.autoSyncNextRunLabel(null), ''); + }); + + test('naive UTC string is parsed as UTC, not local', () => { + const sb = makeSandbox(); + // Pick a time exactly one hour from now in UTC. If the parser + // mistakenly treats the bare timestamp as LOCAL it would land + // wildly far from 1h on machines in non-UTC timezones — + // that's exactly the bug Cin's review flagged. + const future = new Date(Date.now() + 60 * 60 * 1000); + const iso = future.toISOString().slice(0, 19).replace('T', ' '); + const label = sb.autoSyncNextRunLabel(iso); + // Allow either "next in 60m" (right at the boundary) or "next in 1h" + assert.ok(/^next in (60m|1h)$/.test(label), `expected ~1h, got "${label}"`); + }); + + test('"due now" when timestamp is in the past', () => { + const sb = makeSandbox(); + const past = new Date(Date.now() - 60 * 1000).toISOString().slice(0, 19).replace('T', ' '); + assert.equal(sb.autoSyncNextRunLabel(past), 'due now'); + }); + + test('day-scale label when timestamp is days out', () => { + const sb = makeSandbox(); + const future = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000); + const iso = future.toISOString().slice(0, 19).replace('T', ' '); + const label = sb.autoSyncNextRunLabel(iso); + assert.match(label, /^next in \dd$/); + }); +}); + +// ========================================================================= +// getMirroredSourceRef — playlist source URL resolution +// ========================================================================= + +describe('getMirroredSourceRef', () => { + test('explicit source_ref wins', () => { + const sb = makeSandbox(); + assert.equal( + sb.getMirroredSourceRef({ source_ref: 'https://example.com/x' }), + 'https://example.com/x', + ); + }); + + test('falls back to description URL for spotify_public', () => { + const sb = makeSandbox(); + const p = { + source: 'spotify_public', + description: 'https://open.spotify.com/playlist/abc', + }; + assert.equal(sb.getMirroredSourceRef(p), 'https://open.spotify.com/playlist/abc'); + }); + + test('non-URL description ignored, falls through to source_playlist_id', () => { + const sb = makeSandbox(); + const p = { + source: 'spotify_public', + description: 'just a note about this playlist', + source_playlist_id: 'abc123', + }; + assert.equal(sb.getMirroredSourceRef(p), 'abc123'); + }); + + test('empty playlist returns empty string', () => { + const sb = makeSandbox(); + assert.equal(sb.getMirroredSourceRef({}), ''); + }); +}); diff --git a/tests/sync/test_artist_name_extraction.py b/tests/sync/test_artist_name_extraction.py new file mode 100644 index 00000000..6a2fcb4a --- /dev/null +++ b/tests/sync/test_artist_name_extraction.py @@ -0,0 +1,43 @@ +"""Tests for `_artist_name` in services/sync_service.py — the helper +that pulls a string name out of Spotify's bare-string / dict / fallback +artist representations.""" + +from services.sync_service import _artist_name + + +def test_bare_string_returned_as_is(): + assert _artist_name('Drake') == 'Drake' + + +def test_dict_with_name_field(): + assert _artist_name({'name': 'Drake', 'id': '3TVXt'}) == 'Drake' + + +def test_dict_without_name_field_falls_back_to_str_repr(): + """Missing name field shouldn't crash — caller should still get a + string back, even if it's the awkward dict repr.""" + out = _artist_name({'id': '3TVXt'}) + assert isinstance(out, str) + assert out != '' + + +def test_dict_with_non_string_name_falls_back(): + """Defensive — if some endpoint ever returns {name: None} or a list, + the helper must not propagate the bad type.""" + out = _artist_name({'name': None}) + assert isinstance(out, str) + + +def test_none_returns_empty_string(): + assert _artist_name(None) == '' + + +def test_unexpected_type_returns_string_repr(): + """A weird type (int, custom object) must coerce to a string instead + of raising — sync iterates a lot of inputs and one bad row shouldn't + crash the whole loop.""" + assert _artist_name(12345) == '12345' + + +def test_empty_string_stays_empty(): + assert _artist_name('') == '' diff --git a/tests/sync/test_sync_candidate_pool.py b/tests/sync/test_sync_candidate_pool.py new file mode 100644 index 00000000..d233b38d --- /dev/null +++ b/tests/sync/test_sync_candidate_pool.py @@ -0,0 +1,208 @@ +"""Tests for the lazy per-artist candidate pool in PlaylistSyncService. + +The pool replaces a per-track SQL storm: instead of running ~30 +title-variation × artist-variation queries for every playlist track, +sync now fetches each unique artist's library tracks once and feeds the +matcher via the in-memory `candidate_tracks` path. The fetch is *lazy* +— it only fires when a track actually misses the sync_match_cache, +so warm-cache playlists pay zero pool cost. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from services.sync_service import PlaylistSyncService + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +def _make_service() -> PlaylistSyncService: + """Bare PlaylistSyncService — pool helper doesn't touch service state.""" + return PlaylistSyncService( + spotify_client=MagicMock(), + download_orchestrator=MagicMock(), + media_server_engine=MagicMock(), + ) + + +def _make_db_stub(indexed_returns=None, search_returns=None, raise_on_search=None) -> MagicMock: + """MusicDatabase stub mirroring the contract the helper relies on: + - get_artist_tracks_indexed is the fast path (indexed artist_id lookup) + - search_tracks is the slow LIKE-based fallback for recall edge cases + + Pool-key normalization runs through `core.text.normalize` directly, + not through the db, so no `_normalize_for_comparison` stub is needed. + """ + db = MagicMock() + db.get_artist_tracks_indexed.return_value = indexed_returns if indexed_returns is not None else [] + if raise_on_search is not None: + db.search_tracks.side_effect = raise_on_search + db.get_artist_tracks_indexed.side_effect = raise_on_search + else: + db.search_tracks.return_value = search_returns if search_returns is not None else [] + return db + + +# --------------------------------------------------------------------------- +# Pooling disabled — legacy fallback +# --------------------------------------------------------------------------- + +def test_returns_none_when_pool_disabled(): + """candidate_pool=None signals callers to fall through to the legacy + per-track SQL loop. Helper must not touch the DB.""" + svc = _make_service() + db = _make_db_stub() + result = svc._get_or_fetch_artist_candidates(None, db, 'Drake', 'plex') + assert result is None + db.search_tracks.assert_not_called() + db.get_artist_tracks_indexed.assert_not_called() + + +# --------------------------------------------------------------------------- +# Lazy population +# --------------------------------------------------------------------------- + +def test_indexed_fast_path_hits_skip_the_like_fallback(): + """When the indexed lookup finds tracks, the LIKE-based fallback must + NOT run — that's the whole perf point of the fast path.""" + svc = _make_service() + pool: dict = {} + db = _make_db_stub(indexed_returns=['t1', 't2']) + result = svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex') + assert result == ['t1', 't2'] + assert pool == {'drake': ['t1', 't2']} + db.get_artist_tracks_indexed.assert_called_once_with( + 'Drake', server_source='plex', limit=10000, + ) + db.search_tracks.assert_not_called() + + +def test_like_fallback_runs_when_indexed_returns_empty(): + """Diacritics / featured-artist recall lives in the LIKE path. The + helper must fall through to search_tracks when the indexed lookup + finds nothing, otherwise sync regresses on those cases. Note that + the pool key is accent-folded (`Beyoncé` → `beyonce`) so library + spellings with/without diacritics share one entry.""" + svc = _make_service() + pool: dict = {} + db = _make_db_stub(indexed_returns=[], search_returns=['feature-track']) + result = svc._get_or_fetch_artist_candidates(pool, db, 'Beyoncé', 'plex') + assert result == ['feature-track'] + assert pool == {'beyonce': ['feature-track']} + db.get_artist_tracks_indexed.assert_called_once() + db.search_tracks.assert_called_once_with( + artist='Beyoncé', limit=10000, server_source='plex', + ) + + +def test_second_call_for_same_artist_reuses_cache(): + """Once an artist's pool is populated, subsequent lookups must not + re-fetch — that's the whole perf point of the pool.""" + svc = _make_service() + pool = {'drake': ['cached']} + db = _make_db_stub(indexed_returns=['fresh'], search_returns=['stale']) + result = svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex') + assert result == ['cached'] + db.get_artist_tracks_indexed.assert_not_called() + db.search_tracks.assert_not_called() + + +def test_artist_absent_from_library_cached_as_empty_list(): + """Both paths return [] → cache [] so the next call short-circuits + via check_track_exists' batched path without firing SQL again.""" + svc = _make_service() + pool: dict = {} + db = _make_db_stub(indexed_returns=[], search_returns=[]) + result = svc._get_or_fetch_artist_candidates(pool, db, 'Obscure', 'plex') + assert result == [] + assert pool == {'obscure': []} + + +def test_none_return_normalized_to_empty_list(): + """Defensive — if both paths ever return None, helper must coerce + to [] so the cached value is still a valid iterable for the matcher.""" + svc = _make_service() + pool: dict = {} + db = _make_db_stub() + db.get_artist_tracks_indexed.return_value = None + db.search_tracks.return_value = None + result = svc._get_or_fetch_artist_candidates(pool, db, 'Anyone', 'plex') + assert result == [] + assert pool == {'anyone': []} + + +# --------------------------------------------------------------------------- +# Failure modes +# --------------------------------------------------------------------------- + +def test_fetch_failure_returns_none_and_does_not_cache(): + """A pool fetch exception must not poison the dict — the per-track + legacy path still has a chance to run for this track, and a later + track for the same artist can retry the fetch.""" + svc = _make_service() + pool: dict = {} + db = _make_db_stub(raise_on_search=RuntimeError('DB exploded')) + result = svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex') + assert result is None + assert pool == {} + + +# --------------------------------------------------------------------------- +# Normalization +# --------------------------------------------------------------------------- + +def test_pool_key_is_normalized_so_casing_variants_share_one_fetch(): + """'Drake' and 'DRAKE' must hash to the same pool entry — otherwise + a playlist that mixes casing would re-fetch the same artist twice.""" + svc = _make_service() + pool: dict = {} + db = _make_db_stub(indexed_returns=['t']) + svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex') + db.get_artist_tracks_indexed.reset_mock() + db.search_tracks.reset_mock() + result = svc._get_or_fetch_artist_candidates(pool, db, 'DRAKE', 'plex') + assert result == ['t'] + db.get_artist_tracks_indexed.assert_not_called() + db.search_tracks.assert_not_called() + + +def test_different_artists_get_separate_pool_entries(): + svc = _make_service() + pool: dict = {} + db = _make_db_stub() + db.get_artist_tracks_indexed.side_effect = [['drake-track'], ['sza-track']] + svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex') + svc._get_or_fetch_artist_candidates(pool, db, 'SZA', 'plex') + assert pool == {'drake': ['drake-track'], 'sza': ['sza-track']} + assert db.get_artist_tracks_indexed.call_count == 2 + + +# --------------------------------------------------------------------------- +# Server source plumbing +# --------------------------------------------------------------------------- + +def test_active_server_is_passed_through_to_indexed_path(): + """Misrouting server_source would make the pool include tracks from + the wrong server (e.g. Plex tracks in a Jellyfin sync) — verify it + survives the trip on the fast path.""" + svc = _make_service() + pool: dict = {} + db = _make_db_stub(indexed_returns=['t']) + svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'jellyfin') + db.get_artist_tracks_indexed.assert_called_once_with( + 'Drake', server_source='jellyfin', limit=10000, + ) + + +def test_active_server_is_passed_through_to_like_fallback(): + """Same server_source check for the slow LIKE-based fallback path.""" + svc = _make_service() + pool: dict = {} + db = _make_db_stub(indexed_returns=[], search_returns=['t']) + svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'jellyfin') + db.search_tracks.assert_called_once_with( + artist='Drake', limit=10000, server_source='jellyfin', + ) diff --git a/tests/test_auto_sync_js.py b/tests/test_auto_sync_js.py new file mode 100644 index 00000000..cd610b73 --- /dev/null +++ b/tests/test_auto_sync_js.py @@ -0,0 +1,72 @@ +"""Run the JS tests for `webui/static/auto-sync.js` under the regular +pytest sweep. + +The actual contract tests live in `tests/static/test_auto_sync.mjs` +and run via Node.js's stable built-in test runner (`node --test`). +This shim shells out to that runner and asserts a clean exit so the +JS tests fail the suite if the auto-sync helpers regress. + +Skipped when: + - `node` isn't on PATH (e.g. Python-only dev container). + - Node version < 22 (the built-in `--test` runner went stable in 18 + but the assert-flavor we use is 22+). + +Run directly: + node --test tests/static/test_auto_sync.mjs +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + + +_REPO_ROOT = Path(__file__).resolve().parents[1] +_TEST_FILE = _REPO_ROOT / "tests" / "static" / "test_auto_sync.mjs" + + +def _node_available() -> bool: + if not shutil.which("node"): + return False + try: + result = subprocess.run( + ["node", "--version"], + capture_output=True, text=True, timeout=10, + ) + except (subprocess.SubprocessError, FileNotFoundError): + return False + if result.returncode != 0: + return False + raw = (result.stdout or "").strip().lstrip("v") + try: + major = int(raw.split(".")[0]) + except (ValueError, IndexError): + return False + return major >= 22 + + +def test_auto_sync_js(): + """Pin the auto-sync helper contract via `node --test`.""" + if not _node_available(): + pytest.skip("Node.js >= 22 required to run the JS auto-sync tests") + + if not _TEST_FILE.exists(): + pytest.skip(f"JS test file missing: {_TEST_FILE}") + + result = subprocess.run( + ["node", "--test", str(_TEST_FILE)], + capture_output=True, text=True, + cwd=str(_REPO_ROOT), + timeout=60, + ) + + if result.returncode != 0: + pytest.fail( + "JS auto-sync tests failed:\n\n" + f"--- stdout ---\n{result.stdout}\n" + f"--- stderr ---\n{result.stderr}", + pytrace=False, + ) diff --git a/tests/test_lb_series_detect.py b/tests/test_lb_series_detect.py new file mode 100644 index 00000000..60498260 --- /dev/null +++ b/tests/test_lb_series_detect.py @@ -0,0 +1,89 @@ +"""Tests for the LB rotating-series detector that powers the +rolling-mirror collapse on the Sync page. + +Pins the title patterns + canonical-name templates so accidental +regex tweaks don't silently break the auto-mirror grouping the +Auto-Sync manager + Mirrored tab rely on. +""" + +from __future__ import annotations + +import pytest + +from core.playlists.lb_series import ( + detect_series, + is_series_synthetic_id, + list_series_synthetic_ids, +) + + +class TestDetectSeries: + def test_weekly_jams_collapses_into_rolling_series(self): + m = detect_series("Weekly Jams for Nezreka, week of 2026-05-25 Mon") + assert m is not None + assert m.series_id == "lb_weekly_jams_Nezreka" + assert m.canonical_name == "ListenBrainz Weekly Jams" + assert m.source_for_mirror == "listenbrainz" + assert m.title_pattern == "Weekly Jams for Nezreka, week of %" + + def test_weekly_exploration_collapses_into_rolling_series(self): + m = detect_series("Weekly Exploration for Nezreka, week of 2026-04-13 Mon") + assert m is not None + assert m.series_id == "lb_weekly_exploration_Nezreka" + assert m.canonical_name == "ListenBrainz Weekly Exploration" + assert m.title_pattern == "Weekly Exploration for Nezreka, week of %" + + def test_top_discoveries_collapses_per_user(self): + m = detect_series("Top Discoveries of 2024 for Nezreka") + assert m is not None + assert m.series_id == "lb_top_discoveries_Nezreka" + assert m.canonical_name == "ListenBrainz Top Discoveries (latest year)" + assert m.title_pattern == "Top Discoveries of % for Nezreka" + + def test_top_missed_collapses_per_user(self): + m = detect_series("Top Missed Recordings of 2025 for Nezreka") + assert m is not None + assert m.series_id == "lb_top_missed_Nezreka" + assert m.canonical_name == "ListenBrainz Top Missed Recordings (latest year)" + + def test_user_with_spaces_in_name(self): + # ListenBrainz allows usernames with spaces; the regex should + # still match and the series id propagates the literal user + # token. Whether SQLite LIKE works on that is the caller's + # problem — we just preserve the captured value. + m = detect_series("Weekly Jams for Some User, week of 2026-01-05 Mon") + assert m is not None + assert m.series_id == "lb_weekly_jams_Some User" + + def test_lastfm_radio_is_not_a_series(self): + # Last.fm radios get their own per-seed MBID — they should NOT + # be collapsed into a rolling series. + assert detect_series("Last.fm Radio: Selfish by Madison Beer") is None + + def test_user_created_playlist_is_not_a_series(self): + assert detect_series("My Custom Playlist") is None + + def test_empty_title_returns_none(self): + assert detect_series("") is None + assert detect_series(None) is None # type: ignore[arg-type] + + +class TestSyntheticIdHelpers: + def test_known_prefixes_listed(self): + prefixes = list_series_synthetic_ids() + assert "lb_weekly_jams_" in prefixes + assert "lb_weekly_exploration_" in prefixes + assert "lb_top_discoveries_" in prefixes + assert "lb_top_missed_" in prefixes + + def test_is_series_synthetic_id_matches_known(self): + assert is_series_synthetic_id("lb_weekly_jams_Nezreka") is True + assert is_series_synthetic_id("lb_weekly_exploration_OtherUser") is True + assert is_series_synthetic_id("lb_top_discoveries_X") is True + + def test_is_series_synthetic_id_rejects_mbids(self): + # Real LB playlist MBIDs are UUID-shaped, never start with ``lb_``. + assert is_series_synthetic_id("4badb5c9-266e-42ef-9d06-879ee311c9e0") is False + assert is_series_synthetic_id("") is False + assert is_series_synthetic_id("lb_") is False # not a real series + assert is_series_synthetic_id("lb_random_thing") is False diff --git a/tests/test_library_tag_payload.py b/tests/test_library_tag_payload.py new file mode 100644 index 00000000..f7b6d198 --- /dev/null +++ b/tests/test_library_tag_payload.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from web_server import _build_library_tag_db_data + + +def test_library_tag_payload_preserves_track_artist_and_artists_list(): + payload = _build_library_tag_db_data( + { + 'title': 'Duet', + 'artist_name': 'Album Artist', + 'track_artist': 'Artist A; Artist B', + 'album_title': 'Compilation', + 'year': 2026, + 'track_number': 3, + 'disc_number': 1, + 'bpm': 124, + 'track_count': 12, + 'album_thumb_url': 'https://example.test/cover.jpg', + 'artist_thumb_url': 'https://example.test/artist.jpg', + }, + ['Electronic', 'Dance'], + ) + + assert payload['artist_name'] == 'Album Artist' + assert payload['track_artist'] == 'Artist A; Artist B' + assert payload['artists_list'] == ['Artist A', 'Artist B'] + assert payload['genres'] == ['Electronic', 'Dance'] + assert payload['thumb_url'] == 'https://example.test/cover.jpg' + + +def test_library_tag_payload_falls_back_without_track_artist(): + payload = _build_library_tag_db_data( + { + 'title': 'Solo', + 'artist_name': 'Solo Artist', + 'track_artist': None, + 'album_title': 'Solo Album', + 'artist_thumb_url': 'https://example.test/artist.jpg', + } + ) + + assert payload['track_artist'] is None + assert 'artists_list' not in payload + assert payload['artist_name'] == 'Solo Artist' + assert payload['thumb_url'] == 'https://example.test/artist.jpg' diff --git a/tests/test_playlist_sources_adapters.py b/tests/test_playlist_sources_adapters.py new file mode 100644 index 00000000..e6b443ff --- /dev/null +++ b/tests/test_playlist_sources_adapters.py @@ -0,0 +1,837 @@ +"""Adapter contract tests for ``core/playlists/sources/``. + +These pin the projection from each backing client's native shape into +the unified ``PlaylistMeta`` / ``NormalizedTrack`` shape. Adapters are +fed minimal fakes (not real clients) so the test is independent of the +live API surface — the goal is to lock in the field mapping so later +phases that consume the unified interface can rely on it. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, Callable, List, Optional + +import pytest + +from core.playlists.sources import ( + NormalizedTrack, + PlaylistDetail, + PlaylistMeta, + PlaylistSource, + PlaylistSourceRegistry, + get_registry, + to_mirror_track_dict, +) +from core.playlists.sources.deezer import DeezerPlaylistSource +from core.playlists.sources.itunes_link import ITunesLinkPlaylistSource +from core.playlists.sources.lastfm import LastFMPlaylistSource +from core.playlists.sources.listenbrainz import ListenBrainzPlaylistSource +from core.playlists.sources.qobuz import QobuzPlaylistSource +from core.playlists.sources.soulsync_discovery import SoulSyncDiscoveryPlaylistSource +from core.playlists.sources.spotify import SpotifyPlaylistSource +from core.playlists.sources.spotify_public import SpotifyPublicPlaylistSource +from core.playlists.sources.tidal import TidalPlaylistSource +from core.playlists.sources.youtube import YouTubePlaylistSource + + +# ─── Spotify ──────────────────────────────────────────────────────────── + + +class _FakeSpotifyClient: + """Stand-in for ``core.spotify_client.SpotifyClient``.""" + + def __init__(self, authed: bool = True): + self._authed = authed + + def is_authenticated(self) -> bool: + return self._authed + + def get_user_playlists_metadata_only(self): + return [ + SimpleNamespace( + id="pl1", + name="Drive", + description="long drives", + owner="me", + public=True, + collaborative=False, + tracks=[], + total_tracks=2, + ) + ] + + def get_playlist_by_id(self, playlist_id: str): + track = SimpleNamespace( + id="t1", + name="Run", + artists=["A", "B"], + album="Album", + duration_ms=180_000, + popularity=50, + preview_url=None, + external_urls={"spotify": "url"}, + image_url="img", + ) + return SimpleNamespace( + id=playlist_id, + name="Drive", + description="long drives", + owner="me", + public=True, + collaborative=False, + tracks=[track], + total_tracks=1, + ) + + +def test_spotify_adapter_lists_and_fetches(): + client = _FakeSpotifyClient() + src = SpotifyPlaylistSource(lambda: client) + + assert isinstance(src, PlaylistSource) + assert src.is_authenticated() is True + + metas = src.list_playlists() + assert len(metas) == 1 + m = metas[0] + assert m.source == "spotify" + assert m.source_playlist_id == "pl1" + assert m.name == "Drive" + assert m.track_count == 2 + + detail = src.get_playlist("pl1") + assert detail is not None + assert detail.meta.track_count == 1 + t = detail.tracks[0] + assert t.source_track_id == "t1" + assert t.track_name == "Run" + # First artist only — matches the mirrored_playlist shape that the + # legacy refresh_mirrored handler wrote (``t.artists[0]``). + assert t.artist_name == "A" + assert t.album_name == "Album" + assert t.duration_ms == 180_000 + assert t.needs_discovery is False + # Spotify authenticated API path emits matched_data so the discovery + # worker can skip its search step and go straight to enrichment. + assert t.extra["discovered"] is True + assert t.extra["provider"] == "spotify" + assert t.extra["matched_data"]["id"] == "t1" + assert t.extra["matched_data"]["artists"] == [{"name": "A"}, {"name": "B"}] + assert t.extra["matched_data"]["album"]["name"] == "Album" + assert t.extra["matched_data"]["album"]["images"][0]["url"] == "img" + + +def test_spotify_adapter_handles_unauthed(): + src = SpotifyPlaylistSource(lambda: _FakeSpotifyClient(authed=False)) + assert src.is_authenticated() is False + assert src.list_playlists() == [] + assert src.get_playlist("pl1") is None + + +def test_spotify_adapter_handles_missing_client(): + src = SpotifyPlaylistSource(lambda: None) + assert src.is_authenticated() is False + assert src.list_playlists() == [] + + +# ─── Tidal ────────────────────────────────────────────────────────────── + + +class _FakeTidalClient: + def __init__(self, authed: bool = True): + self._authed = authed + + def is_authenticated(self) -> bool: + return self._authed + + def get_user_playlists_metadata_only(self): + return [ + SimpleNamespace( + id="tpl", + name="Tidal Mix", + description="", + tracks=[], + external_urls={"tidal": "url"}, + owner={"name": "broque"}, + public=True, + ) + ] + + def get_playlist(self, playlist_id: str): + track = SimpleNamespace( + id="ttrk", + name="Wave", + artists=["X"], + album="Ocean", + duration_ms=200_000, + external_urls={}, + popularity=0, + explicit=False, + ) + return SimpleNamespace( + id=playlist_id, + name="Tidal Mix", + description="", + tracks=[track], + external_urls={}, + owner={"name": "broque"}, + public=True, + ) + + +def test_tidal_adapter_projection(): + src = TidalPlaylistSource(lambda: _FakeTidalClient()) + + metas = src.list_playlists() + assert metas[0].owner == "broque" + assert metas[0].source == "tidal" + + detail = src.get_playlist("tpl") + assert detail is not None + assert detail.tracks[0].source_track_id == "ttrk" + assert detail.tracks[0].album_name == "Ocean" + assert detail.tracks[0].needs_discovery is False + + +# ─── Qobuz ────────────────────────────────────────────────────────────── + + +class _FakeQobuzClient: + def is_authenticated(self) -> bool: + return True + + def get_user_playlists(self): + return [{ + "id": "q1", + "name": "Q Mix", + "description": "qobuz", + "public": False, + "track_count": 2, + "image_url": "img", + "external_urls": {"qobuz": "url"}, + }] + + def get_playlist(self, playlist_id: str): + return { + "id": playlist_id, + "name": "Q Mix", + "description": "qobuz", + "public": False, + "track_count": 1, + "image_url": "img", + "external_urls": {"qobuz": "url"}, + "tracks": [{ + "id": "qt1", + "name": "Track", + "artists": ["Q-Artist"], + "album": "Q-Album", + "duration_ms": 300_000, + "image_url": "ti", + }], + } + + +def test_qobuz_adapter_projection(): + src = QobuzPlaylistSource(lambda: _FakeQobuzClient()) + metas = src.list_playlists() + assert metas[0].source_playlist_id == "q1" + + detail = src.get_playlist("q1") + assert detail.tracks[0].source_track_id == "qt1" + assert detail.tracks[0].duration_ms == 300_000 + assert detail.tracks[0].image_url == "ti" + + +# ─── Spotify Public ───────────────────────────────────────────────────── + + +def test_spotify_public_adapter_invalid_url(): + src = SpotifyPublicPlaylistSource() + # invalid URL → parser returns None → adapter returns None + assert src.get_playlist("not-a-spotify-url") is None + assert src.supports_listing is False + assert src.list_playlists() == [] + + +def test_spotify_public_adapter_projects_scrape(monkeypatch): + src = SpotifyPublicPlaylistSource() + + def fake_parse(url: str): + return {"type": "playlist", "id": "xyz"} + + def fake_scrape(spotify_type: str, spotify_id: str): + return { + "id": spotify_id, + "type": "playlist", + "name": "Embed", + "subtitle": "owner", + "tracks": [ + { + "id": "sptrk", + "name": "Song", + "artists": [{"name": "Artist"}], + "duration_ms": 100_000, + "is_explicit": False, + "track_number": 1, + }, + ], + "url": "https://open.spotify.com/playlist/xyz", + "url_hash": "abc123", + } + + monkeypatch.setattr("core.spotify_public_scraper.parse_spotify_url", fake_parse) + monkeypatch.setattr("core.spotify_public_scraper.scrape_spotify_embed", fake_scrape) + + detail = src.get_playlist("https://open.spotify.com/playlist/xyz") + assert detail is not None + assert detail.meta.source_playlist_id == "abc123" + assert detail.meta.source_url == "https://open.spotify.com/playlist/xyz" + assert detail.tracks[0].artist_name == "Artist" + assert detail.tracks[0].source_track_id == "sptrk" + + +# ─── YouTube ──────────────────────────────────────────────────────────── + + +def test_youtube_adapter_projection(): + def parser(url: str): + return { + "id": "yt_pl", + "name": "YT Mix", + "track_count": 1, + "url": url, + "image_url": "thumb", + "tracks": [{ + "id": "vid1", + "name": "Track", + "artists": ["Channel"], + "duration_ms": 240_000, + "url": "https://youtu.be/vid1", + }], + } + + src = YouTubePlaylistSource(parser) + detail = src.get_playlist("https://youtube.com/playlist?list=yt_pl") + assert detail is not None + assert detail.meta.source == "youtube" + assert detail.meta.source_url == "https://youtube.com/playlist?list=yt_pl" + assert len(detail.meta.source_playlist_id) == 12 # md5[:12] + assert detail.tracks[0].source_track_id == "vid1" + + +def test_youtube_adapter_failed_parse(): + src = YouTubePlaylistSource(lambda url: None) + assert src.get_playlist("https://bad") is None + + +# ─── iTunes Link ──────────────────────────────────────────────────────── + + +def test_itunes_link_adapter_projection(): + def parser(url: str): + return { + "id": "1234", + "type": "album", + "name": "Album X", + "subtitle": "Artist", + "url": url, + "url_hash": "abcd1234", + "track_count": 1, + "image_url": "art", + "tracks": [{ + "id": "555", + "name": "Song", + "artists": ["Artist"], + "album": {"name": "Album X"}, + "duration_ms": 220_000, + "image_url": "art", + }], + } + + src = ITunesLinkPlaylistSource(parser) + detail = src.get_playlist("https://music.apple.com/us/album/1234") + assert detail is not None + assert detail.meta.source == "itunes_link" + assert detail.meta.source_playlist_id == "abcd1234" + assert detail.tracks[0].album_name == "Album X" + assert detail.tracks[0].source_track_id == "555" + + +# ─── ListenBrainz ─────────────────────────────────────────────────────── + + +class _FakeLBManager: + def __init__(self, authed: bool = True): + self.client = SimpleNamespace(is_authenticated=lambda: authed) + self._rows = { + "created_for_user": [{ + "playlist_mbid": "lb-1", + "title": "Weekly Discovery", + "creator": "ListenBrainz", + "track_count": 1, + "annotation": {"note": "weekly"}, + "last_updated": "2026-05-26", + }], + "user_created": [], + "collaborative": [], + } + self._tracks = { + "lb-1": [{ + "track_name": "Discovery Track", + "artist_name": "MB Artist", + "album_name": "MB Album", + "duration_ms": 250_000, + "recording_mbid": "rec-1", + "release_mbid": "rel-1", + "album_cover_url": "cover", + "additional_metadata": {}, + }], + } + self.refresh_called = False + + def get_cached_playlists(self, playlist_type: str): + return self._rows.get(playlist_type, []) + + def get_playlist_type(self, mbid: str) -> str: + for ptype, rows in self._rows.items(): + if any(r["playlist_mbid"] == mbid for r in rows): + return ptype + return "" + + def get_cached_tracks(self, mbid: str): + return self._tracks.get(mbid, []) + + def update_all_playlists(self): + self.refresh_called = True + + +def test_listenbrainz_adapter_marks_needs_discovery(): + manager = _FakeLBManager() + src = ListenBrainzPlaylistSource(lambda: manager) + + metas = src.list_playlists() + assert len(metas) == 1 + assert metas[0].source == "listenbrainz" + assert metas[0].extra["playlist_type"] == "created_for_user" + + detail = src.get_playlist("lb-1") + assert detail is not None + assert detail.meta.track_count == 1 + t = detail.tracks[0] + assert t.needs_discovery is True + assert t.source_track_id == "rec-1" + assert t.extra["recording_mbid"] == "rec-1" + + +def test_listenbrainz_adapter_refresh_calls_manager(): + manager = _FakeLBManager() + src = ListenBrainzPlaylistSource(lambda: manager) + src.refresh_playlist("lb-1") + assert manager.refresh_called is True + + +# ─── Last.fm ──────────────────────────────────────────────────────────── + + +class _FakeLastFMManager: + def __init__(self): + self._rows = [{ + "playlist_mbid": "lfm-1", + "title": "Last.fm Radio: Seed", + "creator": "Last.fm", + "track_count": 1, + "annotation": {"seed": "track"}, + "last_updated": "2026-05-26", + }] + self._tracks = [{ + "track_name": "Similar", + "artist_name": "Artist", + "album_name": None, + "duration_ms": 200_000, + "recording_mbid": "lfm-rec-1", + "release_mbid": None, + "album_cover_url": None, + "additional_metadata": {}, + }] + + def get_cached_playlists(self, playlist_type: str): + if playlist_type == "lastfm_radio": + return self._rows + return [] + + def get_cached_tracks(self, mbid: str): + if mbid == "lfm-1": + return self._tracks + return [] + + +def test_lastfm_adapter_projects_radio_rows(): + src = LastFMPlaylistSource(lambda: _FakeLastFMManager()) + + metas = src.list_playlists() + assert len(metas) == 1 + assert metas[0].source == "lastfm" + assert metas[0].owner == "Last.fm" + + detail = src.get_playlist("lfm-1") + assert detail is not None + assert detail.tracks[0].needs_discovery is True + assert detail.tracks[0].source_track_id == "lfm-rec-1" + + +# ─── SoulSync Discovery ───────────────────────────────────────────────── + + +class _FakeDiscoveryManager: + def __init__(self): + self.refresh_calls = [] + self._records = [SimpleNamespace( + id=42, + profile_id=1, + kind="hidden_gems", + variant="", + name="Hidden Gems", + config=None, + track_count=1, + last_generated_at="2026-05-26T00:00:00Z", + last_synced_at=None, + last_generation_source="discovery_pool", + last_generation_error=None, + is_stale=False, + )] + self._tracks = [SimpleNamespace( + track_name="Gem", + artist_name="Indie", + album_name="EP", + spotify_track_id="sp-gem", + itunes_track_id=None, + deezer_track_id=None, + album_cover_url="art", + duration_ms=180_000, + popularity=20, + track_data_json=None, + source="discovery", + primary_id=lambda: "sp-gem", + )] + + def list_playlists(self, profile_id: int = 1): + return self._records + + def get_playlist_tracks(self, playlist_id: int): + return self._tracks if playlist_id == 42 else [] + + def refresh_playlist(self, kind: str, variant: str = "", profile_id: int = 1, config_overrides=None): + self.refresh_calls.append((kind, variant, profile_id)) + return self._records[0] + + +def test_soulsync_discovery_adapter_tracks_dont_need_discovery(): + manager = _FakeDiscoveryManager() + src = SoulSyncDiscoveryPlaylistSource(lambda: manager, profile_id_getter=lambda: 1) + + metas = src.list_playlists() + assert metas[0].source == "soulsync_discovery" + assert metas[0].source_playlist_id == "42" + assert metas[0].extra["kind"] == "hidden_gems" + + detail = src.get_playlist("42") + assert detail is not None + t = detail.tracks[0] + assert t.needs_discovery is False + assert t.source_track_id == "sp-gem" + assert t.album_name == "EP" + assert t.extra["spotify_track_id"] == "sp-gem" + + +def test_soulsync_discovery_adapter_refresh_invokes_manager(): + manager = _FakeDiscoveryManager() + src = SoulSyncDiscoveryPlaylistSource(lambda: manager, profile_id_getter=lambda: 1) + src.refresh_playlist("42") + assert manager.refresh_calls == [("hidden_gems", "", 1)] + + +# ─── Registry ─────────────────────────────────────────────────────────── + + +def test_registry_lazy_construct_and_cache(): + reg = PlaylistSourceRegistry() + constructed = [] + + def factory(): + constructed.append(True) + return SpotifyPlaylistSource(lambda: None) + + reg.register("spotify", factory) + assert constructed == [] # not built yet + + first = reg.get_source("spotify") + second = reg.get_source("spotify") + assert first is second # cached + assert len(constructed) == 1 + + +def test_registry_re_register_invalidates_instance(): + reg = PlaylistSourceRegistry() + reg.register("spotify", lambda: SpotifyPlaylistSource(lambda: None)) + first = reg.get_source("spotify") + reg.register("spotify", lambda: SpotifyPlaylistSource(lambda: None)) + second = reg.get_source("spotify") + assert first is not second + + +def test_registry_unknown_source_returns_none(): + reg = PlaylistSourceRegistry() + assert reg.get_source("nope") is None + + +# ─── Deezer ───────────────────────────────────────────────────────────── + + +class _FakeDeezerClient: + def is_authenticated(self) -> bool: + return True # Deezer public API always available + + def get_user_playlists(self): + return [] # stub-interface variant returns [] + + def get_playlist(self, playlist_id: str): + return { + "id": playlist_id, + "name": "Deez Mix", + "description": "deezer", + "track_count": 1, + "image_url": "img", + "owner": "user", + "tracks": [{ + "id": "dt1", + "name": "Song", + "artists": ["Deez Artist"], + "album": "Deez Album", + "duration_ms": 240_000, + "track_number": 1, + }], + } + + +def test_deezer_adapter_projection(): + src = DeezerPlaylistSource(lambda: _FakeDeezerClient()) + assert src.is_authenticated() is True + assert src.list_playlists() == [] # user playlists need OAuth + + detail = src.get_playlist("d1") + assert detail is not None + assert detail.meta.source == "deezer" + assert detail.meta.image_url == "img" + t = detail.tracks[0] + assert t.source_track_id == "dt1" + assert t.artist_name == "Deez Artist" + assert t.album_name == "Deez Album" + assert t.needs_discovery is False + + +# ─── to_mirror_track_dict projection helper ───────────────────────────── + + +def test_mirror_dict_minimal_track_has_no_extra_data(): + track = NormalizedTrack( + position=0, + track_name="Song", + artist_name="Artist", + album_name="Album", + duration_ms=200_000, + source_track_id="abc", + ) + d = to_mirror_track_dict(track) + assert d == { + "track_name": "Song", + "artist_name": "Artist", + "album_name": "Album", + "duration_ms": 200_000, + "source_track_id": "abc", + } + assert "extra_data" not in d + + +def test_mirror_dict_spotify_authed_emits_matched_data(): + """The Spotify adapter's authenticated-API path planted + ``discovered`` + ``matched_data`` in ``extra``; projection must + serialize them into ``extra_data`` matching the legacy refresh + handler's shape (pre-extraction).""" + track = NormalizedTrack( + position=0, + track_name="Run", + artist_name="Adele", + album_name="25", + duration_ms=295_000, + source_track_id="track123", + extra={ + "discovered": True, + "provider": "spotify", + "confidence": 1.0, + "matched_data": { + "id": "track123", + "name": "Run", + "artists": [{"name": "Adele"}], + "album": {"name": "25"}, + "duration_ms": 295_000, + "image_url": None, + }, + }, + ) + d = to_mirror_track_dict(track) + assert "extra_data" in d + import json as _json + extra = _json.loads(d["extra_data"]) + assert extra["discovered"] is True + assert extra["provider"] == "spotify" + assert extra["confidence"] == 1.0 + assert extra["matched_data"]["id"] == "track123" + assert extra["matched_data"]["artists"] == [{"name": "Adele"}] + + +def test_default_discover_tracks_is_no_op(): + """Adapters whose tracks already carry provider IDs (Spotify, + Tidal, Qobuz, YouTube, Deezer, Spotify-public, iTunes-link, + SoulSync-Discovery) inherit the ABC default — return tracks + unchanged.""" + track = NormalizedTrack( + position=0, + track_name="Song", + artist_name="Artist", + source_track_id="abc", + needs_discovery=False, + ) + src = SpotifyPlaylistSource(lambda: None) + out = src.discover_tracks([track]) + assert out == [track] + + +def test_listenbrainz_discover_tracks_uses_callable(): + """When the LB adapter is wired with a discover_callable, MB + tracks get matched_data populated; ``needs_discovery`` flips to + False on matches; non-matches stay as-is.""" + + def fake_discover(track_dicts): + # Match the first, leave second unmatched. + return [ + { + "id": "matched-1", + "name": "Matched", + "artists": ["Artist 1"], + "album": {"name": "Album"}, + "duration_ms": 200_000, + "image_url": "art", + "source": "spotify", + "_provider": "spotify", + "_confidence": 0.95, + }, + None, + ] + + src = ListenBrainzPlaylistSource( + lambda: None, + discover_callable=fake_discover, + ) + tracks = [ + NormalizedTrack( + position=0, + track_name="Song A", + artist_name="Artist 1", + source_track_id="mbid-1", + needs_discovery=True, + ), + NormalizedTrack( + position=1, + track_name="Song B", + artist_name="Artist 2", + source_track_id="mbid-2", + needs_discovery=True, + ), + ] + out = src.discover_tracks(tracks) + assert len(out) == 2 + + assert out[0].needs_discovery is False + assert out[0].source_track_id == "matched-1" + assert out[0].extra["discovered"] is True + assert out[0].extra["provider"] == "spotify" + assert out[0].extra["confidence"] == 0.95 + assert out[0].extra["matched_data"]["id"] == "matched-1" + + # Unmatched stays as-is. + assert out[1].needs_discovery is True + assert out[1].source_track_id == "mbid-2" + assert "matched_data" not in (out[1].extra or {}) + + +def test_listenbrainz_discover_tracks_no_callable_is_no_op(): + """If no ``discover_callable`` is wired, the adapter returns the + list unchanged — refresh paths that haven't enabled discovery + still work.""" + src = ListenBrainzPlaylistSource(lambda: None, discover_callable=None) + tracks = [ + NormalizedTrack( + position=0, + track_name="T", + artist_name="A", + needs_discovery=True, + ) + ] + assert src.discover_tracks(tracks) == tracks + + +def test_lastfm_discover_tracks_shares_listenbrainz_implementation(): + """Last.fm radio tracks have the same MB-metadata shape as LB + tracks, so the adapter reuses LB's ``discover_tracks``.""" + + def fake_discover(track_dicts): + return [{ + "id": "lfm-matched", + "name": "Match", + "artists": ["Artist"], + "album": {"name": ""}, + "duration_ms": 200_000, + "image_url": "", + "source": "spotify", + "_provider": "spotify", + }] + + src = LastFMPlaylistSource(lambda: None, discover_callable=fake_discover) + tracks = [ + NormalizedTrack( + position=0, + track_name="T", + artist_name="A", + needs_discovery=True, + ) + ] + out = src.discover_tracks(tracks) + assert out[0].needs_discovery is False + assert out[0].source_track_id == "lfm-matched" + assert out[0].extra["matched_data"]["id"] == "lfm-matched" + + +def test_mirror_dict_spotify_public_emits_spotify_hint(): + """Public-embed path: track ID known but album art / canonical + metadata missing, so we emit a ``spotify_hint`` for the discovery + worker instead of marking discovered.""" + track = NormalizedTrack( + position=0, + track_name="Song", + artist_name="Artist", + duration_ms=200_000, + source_track_id="sptrk", + extra={ + "spotify_hint": { + "id": "sptrk", + "name": "Song", + "artists": [{"name": "Artist"}], + }, + }, + ) + d = to_mirror_track_dict(track) + import json as _json + extra = _json.loads(d["extra_data"]) + assert extra["discovered"] is False + assert extra["spotify_hint"]["id"] == "sptrk" diff --git a/tests/test_script_split_integrity.py b/tests/test_script_split_integrity.py index a801f7d1..1f11f05a 100644 --- a/tests/test_script_split_integrity.py +++ b/tests/test_script_split_integrity.py @@ -45,6 +45,7 @@ SPLIT_MODULES = [ "discover.js", "enrichment.js", "stats-automations.js", + "auto-sync.js", "pages-extra.js", "init.js", ] diff --git a/tests/text/__init__.py b/tests/text/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/text/test_normalize.py b/tests/text/test_normalize.py new file mode 100644 index 00000000..e2024f1c --- /dev/null +++ b/tests/text/test_normalize.py @@ -0,0 +1,38 @@ +"""Tests for `core.text.normalize.normalize_for_comparison`.""" + +from core.text.normalize import normalize_for_comparison + + +def test_empty_input_returns_empty_string(): + assert normalize_for_comparison("") == "" + assert normalize_for_comparison(None) == "" # type: ignore[arg-type] + + +def test_lowercases_ascii(): + assert normalize_for_comparison("Drake") == "drake" + assert normalize_for_comparison("DRAKE") == "drake" + + +def test_strips_surrounding_whitespace(): + assert normalize_for_comparison(" Drake ") == "drake" + assert normalize_for_comparison("\tDrake\n") == "drake" + + +def test_folds_accents_to_ascii(): + """Diacritic-different spellings of the same artist must collapse to + one normalized key — otherwise the pool would re-fetch the same + artist when the playlist and library disagree on casing/accents.""" + assert normalize_for_comparison("Beyoncé") == "beyonce" + assert normalize_for_comparison("Björk") == "bjork" + assert normalize_for_comparison("Subcarpaţi") == "subcarpati" + + +def test_combines_lowercase_and_accent_folding(): + assert normalize_for_comparison("BEYONCÉ") == "beyonce" + + +def test_preserves_internal_whitespace(): + """Multi-word artist names must keep their internal spacing — only + leading/trailing whitespace is stripped.""" + assert normalize_for_comparison("Bon Iver") == "bon iver" + assert normalize_for_comparison("Tame Impala") == "tame impala" diff --git a/tests/wishlist/test_album_grouping.py b/tests/wishlist/test_album_grouping.py new file mode 100644 index 00000000..9fc3264a --- /dev/null +++ b/tests/wishlist/test_album_grouping.py @@ -0,0 +1,159 @@ +"""Tests for the wishlist-cycle album grouping helper that drives +the per-album bundle dispatch. + +Pins the bucketing contract so future changes to the dispatch flow +don't silently regress the user-visible behavior: wishlist 'albums' +cycle should emit one album-bundle search per missing album, not +one per missing track. +""" + +from __future__ import annotations + +from core.wishlist.album_grouping import ( + WishlistAlbumGroup, + WishlistGroupingResult, + group_wishlist_tracks_by_album, +) + + +def _wt(track_name, artist, album_id, album_name, **extra): + """Build a wishlist row in the shape the wishlist service returns.""" + return { + 'track_name': track_name, + 'artist_name': artist, + 'spotify_data': { + 'name': track_name, + 'artists': [{'name': artist}], + 'album': { + 'id': album_id, + 'name': album_name, + **extra, + }, + }, + } + + +def test_empty_input_returns_empty_result(): + res = group_wishlist_tracks_by_album([]) + assert res.album_groups == [] + assert res.residual_tracks == [] + + +def test_single_album_groups_all_tracks_together(): + tracks = [ + _wt('Dragon Soul', 'Ryoto', 'alb1', 'Cha-La Head-Cha-La'), + _wt('Cha-La Head-Cha-La', 'Ryoto', 'alb1', 'Cha-La Head-Cha-La'), + _wt('Zenkai Power', 'Ryoto', 'alb1', 'Cha-La Head-Cha-La'), + ] + res = group_wishlist_tracks_by_album(tracks) + assert len(res.album_groups) == 1 + g = res.album_groups[0] + assert g.album_key == 'alb1' + assert g.album_context['name'] == 'Cha-La Head-Cha-La' + assert g.artist_context['name'] == 'Ryoto' + assert len(g.tracks) == 3 + + +def test_multiple_albums_emit_separate_groups(): + tracks = [ + _wt('Song A', 'Artist 1', 'alb1', 'Album 1'), + _wt('Song B', 'Artist 1', 'alb1', 'Album 1'), + _wt('Song C', 'Artist 2', 'alb2', 'Album 2'), + ] + res = group_wishlist_tracks_by_album(tracks) + assert len(res.album_groups) == 2 + keys = {g.album_key for g in res.album_groups} + assert keys == {'alb1', 'alb2'} + for g in res.album_groups: + if g.album_key == 'alb1': + assert len(g.tracks) == 2 + else: + assert len(g.tracks) == 1 + + +def test_missing_album_metadata_falls_through_to_residual(): + tracks = [ + # No spotify_data.album at all + {'track_name': 'Orphan', 'artist_name': 'X', 'spotify_data': {'artists': [{'name': 'X'}]}}, + # Empty album dict + {'track_name': 'Empty Album', 'artist_name': 'X', 'spotify_data': {'album': {}, 'artists': [{'name': 'X'}]}}, + ] + res = group_wishlist_tracks_by_album(tracks) + assert res.album_groups == [] + assert len(res.residual_tracks) == 2 + + +def test_missing_artist_demotes_to_residual(): + """Album-bundle search needs an artist; if we can't recover one, + skip the bundle path and let the track go through per-track.""" + tracks = [{ + 'track_name': 'Song', + 'spotify_data': { + 'artists': [], + 'album': {'id': 'a', 'name': 'Album'}, + }, + }] + res = group_wishlist_tracks_by_album(tracks) + assert res.album_groups == [] + assert res.residual_tracks == tracks + + +def test_min_tracks_threshold_demotes_solos(): + """When ``min_tracks_per_album=2``, single-track albums fall to + residual so the user doesn't fire a bundle search for a 1-track + rip when per-track would do.""" + tracks = [ + _wt('Solo Track', 'Artist 1', 'alb1', 'Album 1'), + _wt('Song A', 'Artist 2', 'alb2', 'Album 2'), + _wt('Song B', 'Artist 2', 'alb2', 'Album 2'), + ] + res = group_wishlist_tracks_by_album(tracks, min_tracks_per_album=2) + assert len(res.album_groups) == 1 + assert res.album_groups[0].album_key == 'alb2' + assert len(res.residual_tracks) == 1 + assert res.residual_tracks[0]['track_name'] == 'Solo Track' + + +def test_default_threshold_promotes_solo_albums(): + """Default ``min_tracks_per_album=1`` — even one missing track + triggers the album-bundle path. Matches the user's stated + preference (don't gate on track count).""" + tracks = [_wt('Solo', 'Artist 1', 'alb1', 'Album 1')] + res = group_wishlist_tracks_by_album(tracks) + assert len(res.album_groups) == 1 + assert res.residual_tracks == [] + + +def test_album_without_id_uses_name_normalized_key(): + """Some older wishlist rows are missing the album id. Group by + a name-normalized key so they still bucket together.""" + tracks = [ + _wt('S1', 'Artist', None, 'Same Album'), + _wt('S2', 'Artist', None, 'Same Album'), + ] + # First track has explicit id=None which is filtered; the fallback + # is ``_name_``. Build manually so the + # helper sees no id at all. + for t in tracks: + del t['spotify_data']['album']['id'] + res = group_wishlist_tracks_by_album(tracks) + assert len(res.album_groups) == 1 + assert res.album_groups[0].album_key == '_name_same album' + assert len(res.album_groups[0].tracks) == 2 + + +def test_nested_track_data_payloads_normalized(): + """The wishlist service sometimes nests spotify_data under + track_data (JSON-string in DB → re-parsed). Ensure the grouper + digs through the same shapes ``classify_wishlist_track`` does.""" + tracks = [{ + 'track_data': { + 'spotify_data': { + 'artists': [{'name': 'Artist'}], + 'album': {'id': 'a', 'name': 'Album'}, + }, + }, + }] + res = group_wishlist_tracks_by_album(tracks) + assert len(res.album_groups) == 1 + assert res.album_groups[0].album_key == 'a' diff --git a/tests/wishlist/test_automation.py b/tests/wishlist/test_automation.py index fa5fd11d..2d01a7ab 100644 --- a/tests/wishlist/test_automation.py +++ b/tests/wishlist/test_automation.py @@ -233,7 +233,111 @@ def test_process_wishlist_automatically_creates_batch_for_matching_tracks(): assert batch["analysis_total"] == 1 assert any(kwargs.get("progress") == 50 for _args, kwargs in progress_calls) assert guard_events == ["enter", "exit"] - assert any("Starting automatic wishlist batch" in msg for msg in logger.info_messages) + # Track has no album id/name → falls to residual batch path + assert any("Starting wishlist residual batch" in msg for msg in logger.info_messages) + + +def test_wishlist_albums_cycle_splits_into_per_album_batches(): + """Multi-album wishlist run: each album emits its own sub-batch + with ``is_album_download=True`` + populated album/artist context. + Pinned so the album-bundle dispatch gate (which keys on those + fields) engages per album instead of falling through to per-track + on a single mixed batch.""" + batch_map = {} + runtime, _service, _profiles_db, music_db, executor, _logger, _progress, _guards = _build_runtime( + tracks=[ + { + "name": "Song A1", + "artists": [{"name": "Artist 1"}], + "spotify_data": { + "album": {"id": "alb1", "name": "Album One", "album_type": "album"}, + "artists": [{"name": "Artist 1"}], + }, + }, + { + "name": "Song A2", + "artists": [{"name": "Artist 1"}], + "spotify_data": { + "album": {"id": "alb1", "name": "Album One", "album_type": "album"}, + "artists": [{"name": "Artist 1"}], + }, + }, + { + "name": "Song B1", + "artists": [{"name": "Artist 2"}], + "spotify_data": { + "album": {"id": "alb2", "name": "Album Two", "album_type": "album"}, + "artists": [{"name": "Artist 2"}], + }, + }, + ], + cycle_value="albums", + count=3, + batch_map=batch_map, + ) + + process_wishlist_automatically(runtime, automation_id="auto-multi-album") + + # Two album groups → two sub-batches submitted (no residual). + assert len(executor.submissions) == 2 + assert len(batch_map) == 2 + + # Each sub-batch must carry album-bundle dispatch context. + for batch in batch_map.values(): + assert batch.get("is_album_download") is True + assert batch.get("album_context", {}).get("name") in {"Album One", "Album Two"} + assert batch.get("artist_context", {}).get("name") in {"Artist 1", "Artist 2"} + + submitted_track_lists = [submitted_args[2] for _fn, submitted_args, _kw in executor.submissions] + track_counts = sorted(len(tracks) for tracks in submitted_track_lists) + assert track_counts == [1, 2] + + # All sub-batches of one wishlist invocation share a single + # ``wishlist_run_id`` so the completion handler can gate the + # cycle toggle on "all siblings done". + run_ids = {batch.get("wishlist_run_id") for batch in batch_map.values()} + assert len(run_ids) == 1 + assert next(iter(run_ids)) # non-empty string + + +def test_wishlist_albums_cycle_residual_for_orphan_tracks(): + """Tracks without resolvable album metadata fall to the classic + per-track residual batch (no ``is_album_download`` flag), while + sibling tracks with valid album info still get their own + album-bundle sub-batch.""" + batch_map = {} + runtime, _service, _profiles_db, music_db, executor, _logger, _progress, _guards = _build_runtime( + tracks=[ + { + "name": "Real Album Track", + "artists": [{"name": "Artist 1"}], + "spotify_data": { + "album": {"id": "alb1", "name": "Album One", "album_type": "album"}, + "artists": [{"name": "Artist 1"}], + }, + }, + { + # No album id, no album name — orphan + "name": "Orphan", + "artists": [{"name": "X"}], + "spotify_data": {"album": {"album_type": "album"}, "artists": [{"name": "X"}]}, + }, + ], + cycle_value="albums", + count=2, + batch_map=batch_map, + ) + + process_wishlist_automatically(runtime, automation_id="auto-mixed") + + assert len(executor.submissions) == 2 # 1 album batch + 1 residual + + album_batches = [b for b in batch_map.values() if b.get("is_album_download")] + residual_batches = [b for b in batch_map.values() if not b.get("is_album_download")] + assert len(album_batches) == 1 + assert len(residual_batches) == 1 + assert album_batches[0]["album_context"]["name"] == "Album One" + assert residual_batches[0]["analysis_total"] == 1 def test_process_wishlist_automatically_returns_early_when_already_processing(): diff --git a/tests/wishlist/test_manual_download.py b/tests/wishlist/test_manual_download.py index b7a6b35d..36437727 100644 --- a/tests/wishlist/test_manual_download.py +++ b/tests/wishlist/test_manual_download.py @@ -232,6 +232,80 @@ def test_start_manual_wishlist_download_batch_does_not_run_library_cleanup(): assert activity_calls == [("", "Wishlist Download Started", "2 tracks", "Now")] +def test_manual_wishlist_splits_into_per_album_sub_batches(): + """Manual wishlist run with multi-album content splits into one + sub-batch per album. Each sub-batch flips + ``is_album_download=True`` + populates album/artist context so + the slskd / Prowlarr album-bundle dispatch engages. + + Pinned to verify the manual path matches the auto path's + behavior — the user's first real-world test hit the manual + flow, not the auto flow.""" + runtime, _service, _db, executor, _logger, _activity, batch_map, master_calls = _build_runtime( + tracks=[ + { + "id": "trk-a1", + "spotify_track_id": "trk-a1", + "name": "Song A1", + "artists": [{"name": "Artist 1"}], + "spotify_data": { + "album": {"id": "alb1", "name": "Album One", "album_type": "album"}, + "artists": [{"name": "Artist 1"}], + }, + }, + { + "id": "trk-a2", + "spotify_track_id": "trk-a2", + "name": "Song A2", + "artists": [{"name": "Artist 1"}], + "spotify_data": { + "album": {"id": "alb1", "name": "Album One", "album_type": "album"}, + "artists": [{"name": "Artist 1"}], + }, + }, + { + "id": "trk-b1", + "spotify_track_id": "trk-b1", + "name": "Song B1", + "artists": [{"name": "Artist 2"}], + "spotify_data": { + "album": {"id": "alb2", "name": "Album Two", "album_type": "album"}, + "artists": [{"name": "Artist 2"}], + }, + }, + ] + ) + + payload, status = processing.start_manual_wishlist_download_batch(runtime) + assert status == 200 + _run_submitted_bg_job(executor) + + # Two album groups → two master-worker calls. + assert len(master_calls) == 2 + + # First sub-batch uses the caller-allocated batch_id. + first_args, _ = master_calls[0] + assert first_args[0] == payload["batch_id"] + assert batch_map[payload["batch_id"]].get("is_album_download") is True + + # Second sub-batch gets a fresh uuid; its row exists in batch_map. + second_args, _ = master_calls[1] + assert second_args[0] != payload["batch_id"] + assert second_args[0] in batch_map + assert batch_map[second_args[0]].get("is_album_download") is True + + # Track counts across the two sub-batches sum to the wishlist total. + counts = sorted(len(args[2]) for args, _ in master_calls) + assert counts == [1, 2] + + # Both sub-batches carry album context populated from spotify_data. + album_names = { + batch_map[args[0]]["album_context"]["name"] + for args, _ in master_calls + } + assert album_names == {"Album One", "Album Two"} + + def test_bg_job_marks_batch_complete_when_wishlist_genuinely_empty(): """If the wishlist is empty before the manual click, the bg job marks the batch complete.""" runtime, _service, _db, executor, _logger, _activity, batch_map, master_calls = _build_runtime( diff --git a/tests/wishlist/test_payloads.py b/tests/wishlist/test_payloads.py index a5c77376..b8cbd499 100644 --- a/tests/wishlist/test_payloads.py +++ b/tests/wishlist/test_payloads.py @@ -106,6 +106,44 @@ def test_extract_spotify_track_from_modal_info_reconstructs_from_slskd_result(): assert out["album"]["name"] == "Album Three" +def test_ensure_wishlist_track_format_defaults_non_dict_album_to_album_type(): + """When ``album`` arrives as a non-dict (legacy/reconstruction path) we + must not stamp ``album_type='single'`` — that lies about the origin + and routes the wishlist requeue through the single_path template + instead of album_path, dumping album tracks into the Singles tree. + Default to 'album' / total_tracks=0 (unknown) so downstream code can + fall through to the real release-type detection logic.""" + track = { + "name": "Song", + "artist": "Artist One", + "album": "Album From Legacy String", + } + + out = payloads.ensure_wishlist_track_format(track) + + assert out["album"]["name"] == "Album From Legacy String" + assert out["album"]["album_type"] == "album" + assert out["album"]["total_tracks"] == 0 + + +def test_extract_spotify_track_from_modal_info_slskd_reconstruction_defaults_to_album(): + """Slskd-result reconstruction is a last-resort path; defaulting to + ``album_type='single'`` corrupted the requeue routing for album + batches. Same fix as ensure_wishlist_track_format: default 'album'.""" + track_info = { + "slskd_result": SimpleNamespace( + title="Song Three", + artist="Artist Three", + album="Album Three", + ) + } + + out = payloads.extract_spotify_track_from_modal_info(track_info) + + assert out["album"]["album_type"] == "album" + assert out["album"]["total_tracks"] == 0 + + def test_extract_wishlist_track_from_modal_info_uses_track_data_key(): track_info = { "track_data": { diff --git a/tests/wishlist/test_processing.py b/tests/wishlist/test_processing.py index 709b3cc6..3fcc2104 100644 --- a/tests/wishlist/test_processing.py +++ b/tests/wishlist/test_processing.py @@ -108,6 +108,53 @@ def test_add_cancelled_tracks_to_failed_tracks_builds_entries(): assert failed[0]["failure_reason"] == "Download cancelled" +def test_resolve_wishlist_source_type_for_batch_returns_album_for_album_batch(): + assert processing.resolve_wishlist_source_type_for_batch({"is_album_download": True}) == "album" + + +def test_resolve_wishlist_source_type_for_batch_returns_playlist_otherwise(): + assert processing.resolve_wishlist_source_type_for_batch({}) == "playlist" + assert processing.resolve_wishlist_source_type_for_batch({"is_album_download": False}) == "playlist" + + +def test_build_wishlist_source_context_minimal_batch_skips_album_provenance(): + """Non-album batches must not carry album_context (would mislead the + requeue logic into routing single-track sync failures through + album_path).""" + batch = { + "playlist_name": "My Mix", + "playlist_id": "pl-99", + } + + context = processing.build_wishlist_source_context(batch) + + assert context["playlist_name"] == "My Mix" + assert context["playlist_id"] == "pl-99" + assert context["added_from"] == "webui_modal" + assert "is_album_download" not in context + assert "album_context" not in context + assert "artist_context" not in context + + +def test_build_wishlist_source_context_preserves_album_context_for_album_batches(): + """Album batches must carry album_context/artist_context through to the + wishlist row so a later requeue has authoritative routing data instead + of having to reconstruct it from per-track album dicts.""" + batch = { + "playlist_name": "Album: GNX", + "playlist_id": "library_redownload_abc", + "is_album_download": True, + "album_context": {"id": "alb-1", "name": "GNX", "total_tracks": 12}, + "artist_context": {"id": "art-1", "name": "Kendrick Lamar"}, + } + + context = processing.build_wishlist_source_context(batch) + + assert context["is_album_download"] is True + assert context["album_context"] == {"id": "alb-1", "name": "GNX", "total_tracks": 12} + assert context["artist_context"] == {"id": "art-1", "name": "Kendrick Lamar"} + + def test_recover_uncaptured_failed_tracks_builds_entries(): batch = {"queue": ["a"]} download_tasks = { @@ -135,6 +182,111 @@ def test_recover_uncaptured_failed_tracks_builds_entries(): assert failed[0]["failure_reason"] == "boom" +def test_finalize_auto_wishlist_completion_defers_toggle_when_siblings_active(): + """When the completing batch shares a ``wishlist_run_id`` with + siblings still in pre-terminal phases, finalize must NOT toggle + the cycle yet — that only happens when the LAST sibling done. + Pinned to prevent the regression where every sub-batch's + completion fired its own cycle toggle (Phase 1c.2.1 split path).""" + db = _FakeDB() + automation_engine = _FakeAutomationEngine() + resets = [] + activities = [] + summary = {"tracks_added": 1, "total_failed": 1, "errors": 0} + + # Two sub-batches share the same run id. The first finishes, + # the second is still 'analysis'. + download_batches = { + "batch-A": {"current_cycle": "albums", "wishlist_run_id": "run-1", "phase": "complete"}, + "batch-B": {"current_cycle": "albums", "wishlist_run_id": "run-1", "phase": "analysis"}, + } + + processing.finalize_auto_wishlist_completion( + "batch-A", + summary, + download_batches=download_batches, + tasks_lock=_FakeLock(), + reset_processing_state=lambda: resets.append(True), + add_activity_item=lambda *args: activities.append(args), + automation_engine=automation_engine, + db_factory=lambda: db, + logger=_FakeLogger(), + ) + + # Activity log still fires (it's a per-batch record), but cycle + # toggle + state reset + automation emit are deferred. + assert activities == [("", "Wishlist Updated", "1 failed tracks added to wishlist", "Now")] + assert resets == [] # NOT reset yet — siblings still active + assert automation_engine.events == [] # NOT emitted yet + assert db.connection.cursor_obj.calls == [] # DB cycle-toggle NOT written + + +def test_finalize_auto_wishlist_completion_toggles_when_last_sibling_done(): + """When all siblings of the same run are in terminal phases (or + don't exist), the completing batch IS the last → cycle toggles + + state resets + automation event fires.""" + db = _FakeDB() + automation_engine = _FakeAutomationEngine() + resets = [] + summary = {"tracks_added": 1, "total_failed": 1, "errors": 0} + + download_batches = { + # Both siblings already terminal — current batch is the last. + "batch-A": {"current_cycle": "albums", "wishlist_run_id": "run-1", "phase": "complete"}, + "batch-B": {"current_cycle": "albums", "wishlist_run_id": "run-1", "phase": "complete"}, + "batch-C": {"current_cycle": "albums", "wishlist_run_id": "run-1", "phase": "analysis"}, # the completing one + } + + processing.finalize_auto_wishlist_completion( + "batch-C", + summary, + download_batches=download_batches, + tasks_lock=_FakeLock(), + reset_processing_state=lambda: resets.append(True), + add_activity_item=lambda *_a: None, + automation_engine=automation_engine, + db_factory=lambda: db, + logger=_FakeLogger(), + ) + + assert resets == [True] + assert db.connection.committed is True + assert db.connection.cursor_obj.calls[0][1] == ("singles",) + assert automation_engine.events # event emitted + + +def test_finalize_auto_wishlist_completion_legacy_no_run_id_toggles_immediately(): + """Back-compat: a batch with NO ``wishlist_run_id`` (legacy + single-batch run from before Phase 1c.2.1) should keep firing + the toggle on its own completion regardless of any unrelated + batches in the dict.""" + db = _FakeDB() + automation_engine = _FakeAutomationEngine() + resets = [] + summary = {"tracks_added": 0, "total_failed": 0, "errors": 0} + + download_batches = { + "batch-legacy": {"current_cycle": "albums"}, # no wishlist_run_id + # Even with another unrelated batch active, legacy should toggle. + "unrelated": {"current_cycle": "singles", "phase": "analysis"}, + } + + processing.finalize_auto_wishlist_completion( + "batch-legacy", + summary, + download_batches=download_batches, + tasks_lock=_FakeLock(), + reset_processing_state=lambda: resets.append(True), + add_activity_item=lambda *_a: None, + automation_engine=automation_engine, + db_factory=lambda: db, + logger=_FakeLogger(), + ) + + assert resets == [True] + assert db.connection.cursor_obj.calls[0][1] == ("singles",) + + def test_finalize_auto_wishlist_completion_toggles_cycle_and_resets_state(): db = _FakeDB() automation_engine = _FakeAutomationEngine() @@ -364,3 +516,4 @@ def test_finalize_auto_wishlist_completion_with_no_tracks_added_still_resets_sta ) ] assert db.connection.committed is True + diff --git a/web_server.py b/web_server.py index fc8c8a2e..268ec877 100644 --- a/web_server.py +++ b/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.6.1" +_SOULSYNC_BASE_VERSION = "2.6.2" def _build_version_string(): """Append short commit hash to version when available (e.g. 2.35+abc1234).""" @@ -305,7 +305,20 @@ _STATIC_CACHE_BUST = str(int(_cache_bust_time.time())) @app.context_processor def _inject_static_cache_bust(): - return {'static_v': _STATIC_CACHE_BUST} + static_v = _STATIC_CACHE_BUST + if DEV_STATIC_NO_CACHE: + try: + static_dir = Path(app.static_folder) + mtimes = [ + p.stat().st_mtime_ns + for p in static_dir.rglob('*') + if p.is_file() and p.suffix.lower() in {'.css', '.js'} + ] + if mtimes: + static_v = str(max(mtimes)) + except Exception: + static_v = _STATIC_CACHE_BUST + return {'static_v': static_v} @app.context_processor @@ -919,6 +932,12 @@ except Exception as e: # --- Automation Progress Tracking --- _scan_library_automation_id = None +_automation_deps = None + +# Playlist-native manual pipeline runs share the automation dependency +# bundle, but keep their own small progress state for the playlist UI. +playlist_pipeline_progress_states = {} +playlist_pipeline_progress_lock = threading.Lock() def _register_automation_handlers(): @@ -935,11 +954,14 @@ def _register_automation_handlers(): closures still live below until subsequent commits in the same branch finish the lift. """ + global _automation_deps + if not automation_engine: return from core.automation.deps import AutomationDeps, AutomationState from core.automation.handlers import register_all as _register_extracted_handlers + from core.playlists.sources.bootstrap import build_playlist_source_registry # Mutable shared state previously lived as module-level globals # (`_scan_library_automation_id`, `_pipeline_running`, etc). @@ -950,6 +972,56 @@ def _register_automation_handlers(): from core.watchlist_scanner import get_watchlist_scanner as _get_watchlist_scanner_fn + # ListenBrainz / Last.fm are profile-scoped, so the manager getter + # resolves the current profile's manager on each call. iTunes-link + # parsing lives as a module helper rather than a class — wrap it in + # a callable that matches the adapter contract. + def _lb_manager_for_registry(): + try: + manager, _username, _source = _get_profile_lb_manager() + return manager + except Exception: + return None + + def _itunes_link_parser_for_registry(url): + # ``parse_itunes_link_endpoint`` is a Flask route handler; the + # actual parsing work needs the underlying helpers. For now + # we punt — Phase 2 will lift the parsing into a pure helper + # the adapter can call directly. Refresh of itunes_link + # mirrors is not yet wired (current handler skips them). + return None + + # Discovery callable for the LB / Last.fm adapters' ``discover_tracks``. + # Wraps the pure ``match_mb_tracks`` helper with the live matching + # engine + Spotify / iTunes clients. Adapter calls it per refresh + # when any track has ``needs_discovery=True``. + from core.discovery.matching import MBMatchDeps, match_mb_tracks + + _mb_match_deps = MBMatchDeps( + matching_engine=matching_engine, + score_candidates=_discovery_score_candidates, + spotify_client_getter=lambda: spotify_client, + itunes_client_getter=_get_itunes_client, + prefer_spotify_getter=lambda: (_get_active_discovery_source() == 'spotify'), + ) + + def _discover_callable_for_registry(tracks): + return match_mb_tracks(tracks, _mb_match_deps) + + _playlist_source_registry = build_playlist_source_registry( + spotify_client_getter=lambda: spotify_client, + tidal_client_getter=lambda: tidal_client, + qobuz_client_getter=_get_qobuz_client_for_sync, + deezer_client_getter=_get_deezer_client, + youtube_parser=parse_youtube_playlist, + itunes_link_parser=_itunes_link_parser_for_registry, + listenbrainz_manager_getter=_lb_manager_for_registry, + lastfm_manager_getter=_lb_manager_for_registry, + personalized_manager_getter=_build_personalized_manager, + profile_id_getter=get_current_profile_id, + discover_callable=_discover_callable_for_registry, + ) + _automation_deps = AutomationDeps( engine=automation_engine, state=_automation_state, @@ -971,6 +1043,7 @@ def _register_automation_handlers(): get_deezer_client=_get_deezer_client, parse_youtube_playlist=parse_youtube_playlist, get_sync_states=lambda: sync_states, + playlist_source_registry=_playlist_source_registry, set_db_update_automation_id=_set_db_update_automation_id, get_db_update_state=lambda: db_update_state, db_update_lock=db_update_lock, @@ -9004,6 +9077,32 @@ def batch_update_library_tracks(): # ── Write Tags to File endpoints ── +def _build_library_tag_db_data(track_data, album_genres=None): + """Build the metadata payload consumed by core.tag_writer.""" + album_genres = album_genres or [] + db_data = { + 'title': track_data.get('title'), + 'artist_name': track_data.get('artist_name'), + 'track_artist': track_data.get('track_artist'), + 'album_title': track_data.get('album_title'), + 'year': track_data.get('year'), + 'genres': album_genres, + 'track_number': track_data.get('track_number'), + 'disc_number': track_data.get('disc_number'), + 'bpm': track_data.get('bpm'), + 'track_count': track_data.get('track_count'), + 'thumb_url': track_data.get('album_thumb_url') or track_data.get('artist_thumb_url'), + } + + track_artist = track_data.get('track_artist') + if isinstance(track_artist, str) and ';' in track_artist: + artists_list = [name.strip() for name in track_artist.split(';') if name.strip()] + if artists_list: + db_data['artists_list'] = artists_list + + return db_data + + @app.route('/api/library/track//tag-preview', methods=['GET']) def get_track_tag_preview(track_id): """Read current file tags and compare against DB metadata for a single track.""" @@ -9051,19 +9150,7 @@ def get_track_tag_preview(track_id): album_genres = [g.strip() for g in track_data['album_genres'].split(',') if g.strip()] # Build DB metadata dict for comparison - db_data = { - 'title': track_data.get('title'), - 'artist_name': track_data.get('artist_name'), - 'track_artist': track_data.get('track_artist'), - 'album_title': track_data.get('album_title'), - 'year': track_data.get('year'), - 'genres': album_genres, - 'track_number': track_data.get('track_number'), - 'disc_number': track_data.get('disc_number'), - 'bpm': track_data.get('bpm'), - 'track_count': track_data.get('track_count'), - 'thumb_url': track_data.get('album_thumb_url') or track_data.get('artist_thumb_url'), - } + db_data = _build_library_tag_db_data(track_data, album_genres) diff = build_tag_diff(file_tags, db_data) has_changes = any(d['changed'] for d in diff) @@ -9145,18 +9232,7 @@ def get_batch_tag_preview(): except (ValueError, TypeError): album_genres = [g.strip() for g in track_data['album_genres'].split(',') if g.strip()] - db_data = { - 'title': track_data.get('title'), - 'artist_name': track_data.get('artist_name'), - 'album_title': track_data.get('album_title'), - 'year': track_data.get('year'), - 'genres': album_genres, - 'track_number': track_data.get('track_number'), - 'disc_number': track_data.get('disc_number'), - 'bpm': track_data.get('bpm'), - 'track_count': track_data.get('track_count'), - 'thumb_url': track_data.get('album_thumb_url') or track_data.get('artist_thumb_url'), - } + db_data = _build_library_tag_db_data(track_data, album_genres) diff = build_tag_diff(file_tags, db_data) has_changes = any(d['changed'] for d in diff) @@ -9228,17 +9304,7 @@ def write_track_tags(track_id): album_genres = [g.strip() for g in track_data['album_genres'].split(',') if g.strip()] # Build data for writer - db_data = { - 'title': track_data.get('title'), - 'artist_name': track_data.get('artist_name'), - 'album_title': track_data.get('album_title'), - 'year': track_data.get('year'), - 'genres': album_genres, - 'track_number': track_data.get('track_number'), - 'disc_number': track_data.get('disc_number'), - 'bpm': track_data.get('bpm'), - 'track_count': track_data.get('track_count'), - } + db_data = _build_library_tag_db_data(track_data, album_genres) # Resolve cover URL cover_url = None @@ -9387,17 +9453,7 @@ def write_tracks_tags_batch(): except (ValueError, TypeError): album_genres = [g.strip() for g in track_data['album_genres'].split(',') if g.strip()] - db_data = { - 'title': track_data.get('title'), - 'artist_name': track_data.get('artist_name'), - 'album_title': track_data.get('album_title'), - 'year': track_data.get('year'), - 'genres': album_genres, - 'track_number': track_data.get('track_number'), - 'disc_number': track_data.get('disc_number'), - 'bpm': track_data.get('bpm'), - 'track_count': track_data.get('track_count'), - } + db_data = _build_library_tag_db_data(track_data, album_genres) # Get pre-downloaded cover art for this track's album art_data = None @@ -18554,6 +18610,15 @@ def start_missing_tracks_process(playlist_id): spotify_public_discovery_states[sp_url_hash]['converted_spotify_playlist_id'] = playlist_id logger.info(f"Linked Spotify Public playlist {sp_url_hash} to download process {batch_id} (converted ID: {playlist_id})") + # Link iTunes Link playlist to download process if this is an iTunes Link playlist + if playlist_id.startswith('itunes_link_'): + itunes_url_hash = playlist_id.replace('itunes_link_', '') + if itunes_url_hash in itunes_link_discovery_states: + itunes_link_discovery_states[itunes_url_hash]['download_process_id'] = batch_id + itunes_link_discovery_states[itunes_url_hash]['phase'] = 'downloading' + itunes_link_discovery_states[itunes_url_hash]['converted_spotify_playlist_id'] = playlist_id + logger.info(f"Linked iTunes Link playlist {itunes_url_hash} to download process {batch_id} (converted ID: {playlist_id})") + # Link Deezer playlist to download process if this is a Deezer playlist if playlist_id.startswith('deezer_'): deezer_playlist_id = playlist_id.replace('deezer_', '') @@ -19351,12 +19416,19 @@ def search_musicbrainz_tracks(): # Local rerank — same helper Deezer / iTunes use. Penalises # cover / karaoke / tribute patterns + boosts exact-artist match. + # `prefer_known_duration=True` is MB-specific: MB has multiple + # recordings per song (single / album / compilation / remaster + # editions) and not every recording carries length data. The + # flag promotes length-known recordings ahead of length-less + # duplicates when relevance scores tie, so the user sees the + # actionable 3:04 row before the 0:00 sibling. if track_q or artist_q: from core.metadata.relevance import rerank_tracks tracks = rerank_tracks( tracks, expected_title=track_q, expected_artist=artist_q, + prefer_known_duration=True, ) tracks_dict = [{ @@ -22526,6 +22598,361 @@ def cancel_qobuz_sync(playlist_id): spotify_public_discovery_states = {} # Key: url_hash, Value: discovery state spotify_public_discovery_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="spotify_public_discovery") +# Global state for iTunes/Apple Music link imports +itunes_link_discovery_states = {} # Key: url_hash, Value: discovery state +itunes_link_discovery_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="itunes_link_discovery") +_apple_music_token_cache = {'token': None, 'fetched_at': 0} +_apple_music_token_lock = threading.Lock() +_APPLE_MUSIC_TOKEN_TTL = 6 * 60 * 60 # seconds +_APPLE_MUSIC_JWT_RE = re.compile(r'eyJ[A-Za-z0-9_\-]+\.eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+') +_APPLE_MUSIC_BUNDLE_SCRAPE_CAP = 8 + +def _parse_itunes_link_url(url): + """Return {'type': 'album'|'track'|'playlist', 'id': str} for supported Apple links.""" + import re + from urllib.parse import parse_qs + + raw = (url or '').strip() + if not raw: + return None + + uri_match = re.match(r'^(?:itunes|applemusic):(album|track|playlist):([A-Za-z0-9._-]+)$', raw, re.IGNORECASE) + if uri_match: + return {'type': uri_match.group(1).lower(), 'id': uri_match.group(2), 'country': 'us'} + + parsed = urlparse(raw) + host = (parsed.netloc or '').lower() + path = parsed.path or '' + query = parse_qs(parsed.query or '') + + if 'itunes.apple.com' not in host and 'music.apple.com' not in host: + return None + + # Apple Music track links are album URLs with ?i=. + track_id = (query.get('i') or [None])[0] + if track_id and str(track_id).isdigit(): + return {'type': 'track', 'id': str(track_id), 'country': _apple_music_country_from_path(path)} + + song_match = re.search(r'/song(?:/[^/]+)?/(\d+)', path) + if song_match: + return {'type': 'track', 'id': song_match.group(1), 'country': _apple_music_country_from_path(path)} + + album_match = re.search(r'/album(?:/[^/]+)?/(\d+)', path) + if album_match: + return {'type': 'album', 'id': album_match.group(1), 'country': _apple_music_country_from_path(path)} + + playlist_match = re.search(r'/playlist(?:/[^/]+)?/(pl\.[A-Za-z0-9._-]+)', path) + if playlist_match: + return {'type': 'playlist', 'id': playlist_match.group(1), 'country': _apple_music_country_from_path(path)} + + return None + + +def _apple_music_country_from_path(path): + import re + match = re.match(r'^/([a-z]{2})(?:/|$)', path or '', re.IGNORECASE) + return match.group(1).lower() if match else 'us' + + +def _itunes_album_image_url(album_data): + images = album_data.get('images') or [] + if images and isinstance(images[0], dict): + return images[0].get('url', '') + return '' + + +def _itunes_track_to_link_track(track_data, fallback_album=None): + artists = track_data.get('artists') or [] + if artists and isinstance(artists[0], dict): + artists = [a.get('name', '') for a in artists if a.get('name')] + elif isinstance(artists, str): + artists = [artists] + + album = track_data.get('album') or fallback_album or {} + if isinstance(album, str): + album = {'name': album, 'images': []} + + return { + 'id': str(track_data.get('id') or track_data.get('trackId') or ''), + 'name': track_data.get('name') or track_data.get('trackName') or '', + 'artists': artists, + 'album': album, + 'duration_ms': track_data.get('duration_ms') or track_data.get('trackTimeMillis') or 0, + 'explicit': track_data.get('explicit') or track_data.get('trackExplicitness') == 'explicit', + 'track_number': track_data.get('track_number') or track_data.get('trackNumber') or 0, + 'disc_number': track_data.get('disc_number') or track_data.get('discNumber') or 1, + 'external_urls': track_data.get('external_urls') or {'itunes': track_data.get('trackViewUrl', '')}, + '_source': 'itunes' + } + + +def _apple_music_artwork_images(artwork): + if not isinstance(artwork, dict): + return [] + template = artwork.get('url') or '' + if not template: + return [] + sizes = [600, 300, 100] + images = [] + for size in sizes: + url = template.replace('{w}', str(size)).replace('{h}', str(size)) + url = url.replace('{f}', 'jpg').replace('{c}', '') + images.append({'url': url, 'height': size, 'width': size}) + return images + + +def _apple_music_song_to_link_track(song): + attrs = (song or {}).get('attributes') or {} + album_images = _apple_music_artwork_images(attrs.get('artwork')) + album = { + 'id': attrs.get('albumId') or '', + 'name': attrs.get('albumName') or '', + 'images': album_images, + 'release_date': attrs.get('releaseDate') or '', + 'album_type': 'album', + } + return { + 'id': str((song or {}).get('id') or ''), + 'name': attrs.get('name') or '', + 'artists': [attrs.get('artistName') or 'Unknown Artist'], + 'album': album, + 'duration_ms': attrs.get('durationInMillis') or 0, + 'explicit': attrs.get('contentRating') == 'explicit', + 'track_number': attrs.get('trackNumber') or 0, + 'disc_number': attrs.get('discNumber') or 1, + 'external_urls': {'itunes': attrs.get('url') or ''}, + '_source': 'itunes' + } + + +def _looks_like_apple_music_token(token): + """Decode JWT payload and confirm Apple media-api claims before trusting.""" + import base64 + import json + + if not token or token.count('.') != 2: + return False + try: + payload_b64 = token.split('.')[1] + padding = '=' * (-len(payload_b64) % 4) + payload = json.loads(base64.urlsafe_b64decode(payload_b64 + padding)) + except Exception: + return False + # Apple media-api tokens carry root_https_origin (distinctive) or are + # Apple-issued JWTs with iss + iat + exp claims. + if payload.get('root_https_origin'): + return True + if payload.get('iss') and payload.get('iat') and payload.get('exp'): + return True + return False + + +def _extract_apple_music_web_token(html_text, session=None): + import html + import json + from urllib.parse import unquote, urljoin + + if not html_text: + return None + + meta_match = re.search( + r']+name=["\']desktop-music-app/config/environment["\'][^>]+content=["\']([^"\']+)["\']', + html_text, + re.IGNORECASE, + ) + if meta_match: + try: + raw = html.unescape(meta_match.group(1)) + data = json.loads(unquote(raw)) + token = ((data.get('MEDIA_API') or {}).get('token') + or (data.get('media-api') or {}).get('token')) + if token and _looks_like_apple_music_token(token): + return token + except Exception as e: + logger.debug(f"Apple Music token meta parse failed: {e}") + + inline_match = re.search(r'"token"\s*:\s*"(' + _APPLE_MUSIC_JWT_RE.pattern + r')"', html_text) + if inline_match and _looks_like_apple_music_token(inline_match.group(1)): + return inline_match.group(1) + + if session is None: + return None + + script_srcs = re.findall( + r']+src=["\']([^"\']+\.js)["\']', + html_text, + re.IGNORECASE, + ) + prioritized = sorted( + script_srcs, + key=lambda s: 0 if re.search(r'(index|chunk|main|app)[^/]*\.js$', s, re.IGNORECASE) else 1, + ) + attempted = 0 + for src in prioritized: + if attempted >= _APPLE_MUSIC_BUNDLE_SCRAPE_CAP: + break + attempted += 1 + try: + js_url = urljoin('https://music.apple.com/', src) + js_resp = session.get(js_url, timeout=15) + js_resp.raise_for_status() + for match in _APPLE_MUSIC_JWT_RE.finditer(js_resp.text): + candidate = match.group(0) + if _looks_like_apple_music_token(candidate): + return candidate + except Exception as e: + logger.debug(f"Apple Music bundle scrape failed for {src}: {e}") + continue + return None + + +def _get_apple_music_web_token(session, page_text, force_refresh=False): + with _apple_music_token_lock: + if not force_refresh: + cached = _apple_music_token_cache.get('token') + fetched_at = _apple_music_token_cache.get('fetched_at', 0) + if cached and (time.time() - fetched_at) < _APPLE_MUSIC_TOKEN_TTL: + return cached + token = _extract_apple_music_web_token(page_text, session=session) + if token: + _apple_music_token_cache['token'] = token + _apple_music_token_cache['fetched_at'] = time.time() + elif force_refresh: + _apple_music_token_cache['token'] = None + _apple_music_token_cache['fetched_at'] = 0 + return token + + +def _invalidate_apple_music_token(): + with _apple_music_token_lock: + _apple_music_token_cache['token'] = None + _apple_music_token_cache['fetched_at'] = 0 + + +def _apple_music_amp_get(session, page_text_provider, web_headers, page_url, api_url, params): + """GET amp-api with current cached token; on 401 invalidate + refetch token + retry once.""" + def build_headers(tok): + return { + 'Accept': 'application/json', + 'Origin': 'https://music.apple.com', + 'Referer': page_url, + 'User-Agent': web_headers['User-Agent'], + 'Authorization': f"Bearer {tok}", + } + + token = _get_apple_music_web_token(session, page_text_provider()) + if not token: + raise ValueError("Could not read Apple Music web token") + resp = session.get(api_url, headers=build_headers(token), params=params, timeout=20) + if resp.status_code == 401: + _invalidate_apple_music_token() + page = session.get(page_url, headers=web_headers, timeout=20) + page.raise_for_status() + token = _get_apple_music_web_token(session, page.text, force_refresh=True) + if not token: + raise ValueError("Could not read Apple Music web token") + resp = session.get(api_url, headers=build_headers(token), params=params, timeout=20) + resp.raise_for_status() + return resp + + +def _fetch_apple_music_playlist(url, playlist_id, country): + import requests + from urllib.parse import urljoin + + session = requests.Session() + web_headers = { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15', + 'Accept-Language': 'en-US,en;q=0.9', + } + cached_page_text = {'value': None} + + def page_text_provider(): + if cached_page_text['value'] is None: + page = session.get(url, headers=web_headers, timeout=20) + page.raise_for_status() + cached_page_text['value'] = page.text + return cached_page_text['value'] + + country = (country or 'us').lower() + api_url = f"https://amp-api.music.apple.com/v1/catalog/{country}/playlists/{playlist_id}" + playlist_resp = _apple_music_amp_get(session, page_text_provider, web_headers, url, api_url, {'l': 'en-US'}) + playlist_payload = playlist_resp.json() + playlist_item = (playlist_payload.get('data') or [{}])[0] + playlist_attrs = playlist_item.get('attributes') or {} + + tracks = [] + tracks_url = f"{api_url}/tracks" + params = {'l': 'en-US'} + while tracks_url: + tracks_resp = _apple_music_amp_get(session, page_text_provider, web_headers, url, tracks_url, params) + tracks_payload = tracks_resp.json() + tracks.extend(_apple_music_song_to_link_track(song) for song in tracks_payload.get('data') or []) + next_path = tracks_payload.get('next') + tracks_url = urljoin('https://amp-api.music.apple.com', next_path) if next_path else None + params = None + + images = _apple_music_artwork_images(playlist_attrs.get('artwork')) + return { + 'id': playlist_id, + 'type': 'playlist', + 'name': playlist_attrs.get('name') or 'Apple Music Playlist', + 'subtitle': playlist_attrs.get('curatorName') or playlist_attrs.get('editorialNotes', {}).get('standard') or 'Apple Music', + 'url': url, + 'track_count': len(tracks), + 'image_url': images[0]['url'] if images else '', + 'tracks': tracks, + } + + +def _build_itunes_link_state(response_data): + return { + 'playlist': response_data, + 'phase': 'fresh', + 'status': 'fresh', + 'discovery_progress': 0, + 'spotify_matches': 0, + 'spotify_total': len(response_data.get('tracks') or []), + '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': {} + } + + +def _convert_link_results_to_spotify_tracks(discovery_results, label): + 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': + spotify_tracks.append({ + '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 + }) + logger.info(f"Converted {len(spotify_tracks)} {label} matches to Spotify tracks for sync") + return spotify_tracks + @app.route('/api/spotify/parse-public', methods=['POST']) def parse_spotify_public_endpoint(): """Parse a public Spotify playlist or album URL without API auth""" @@ -23140,6 +23567,453 @@ def cancel_spotify_public_sync(url_hash): return jsonify({"error": str(e)}), 500 +# =================================================================== +# ITUNES LINK DISCOVERY API ENDPOINTS +# =================================================================== + +@app.route('/api/itunes-link/parse', methods=['POST']) +def parse_itunes_link_endpoint(): + """Parse an iTunes/Apple Music album or track URL into a virtual playlist.""" + try: + data = request.get_json() + url = data.get('url', '').strip() + + if not url: + return jsonify({"error": "iTunes URL is required"}), 400 + + parsed = _parse_itunes_link_url(url) + if not parsed: + return jsonify({"error": "Invalid iTunes or Apple Music link. Album and track links are supported."}), 400 + + client = _get_itunes_client() + tracks = [] + image_url = '' + subtitle = 'iTunes' + name = '' + + if parsed['type'] == 'playlist': + response_data = _fetch_apple_music_playlist(url, parsed['id'], parsed.get('country') or 'us') + tracks = response_data.get('tracks') or [] + image_url = response_data.get('image_url', '') + subtitle = response_data.get('subtitle', 'Apple Music') + name = response_data.get('name', '') + elif parsed['type'] == 'album': + album = client.get_album(parsed['id'], include_tracks=True) + if not album: + return jsonify({"error": "iTunes album not found"}), 404 + album_tracks = ((album.get('tracks') or {}).get('items') or []) + tracks = [_itunes_track_to_link_track(t, fallback_album={ + 'id': album.get('id'), + 'name': album.get('name', ''), + 'images': album.get('images') or [], + 'release_date': album.get('release_date', ''), + 'album_type': album.get('album_type', 'album') + }) for t in album_tracks] + name = album.get('name', '') + artists = album.get('artists') or [] + subtitle = ', '.join(a.get('name', '') for a in artists if isinstance(a, dict)) or 'iTunes' + image_url = _itunes_album_image_url(album) + else: + track = client.get_track_details(parsed['id']) + if not track: + return jsonify({"error": "iTunes track not found"}), 404 + tracks = [_itunes_track_to_link_track(track)] + name = track.get('name', '') + subtitle = ', '.join(track.get('artists') or []) or 'iTunes' + album = track.get('album') or {} + if isinstance(album, dict): + name = f"{track.get('name', '')} - {subtitle}".strip(' -') + + if not tracks: + return jsonify({"error": "No tracks found for this iTunes link"}), 400 + + import hashlib + canonical = f"itunes:{parsed['type']}:{parsed['id']}" + url_hash = hashlib.md5(canonical.encode()).hexdigest()[:12] + + if parsed['type'] == 'playlist': + response_data['url_hash'] = url_hash + response_data['track_count'] = len(tracks) + else: + response_data = { + 'id': parsed['id'], + 'type': parsed['type'], + 'name': name or f"iTunes {parsed['type'].title()}", + 'subtitle': subtitle, + 'url': url, + 'url_hash': url_hash, + 'track_count': len(tracks), + 'image_url': image_url, + 'tracks': tracks + } + + if url_hash not in itunes_link_discovery_states: + itunes_link_discovery_states[url_hash] = _build_itunes_link_state(response_data) + else: + itunes_link_discovery_states[url_hash]['playlist'] = response_data + itunes_link_discovery_states[url_hash]['last_accessed'] = time.time() + + logger.info(f"iTunes {parsed['type']} parsed: {response_data['name']} ({len(tracks)} tracks)") + return jsonify(response_data) + + except Exception as e: + logger.error(f"Error parsing iTunes link: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/itunes-link/discovery/start/', methods=['POST']) +def start_itunes_link_discovery(url_hash): + try: + if url_hash not in itunes_link_discovery_states: + return jsonify({"error": "iTunes link not found. Please load the URL first."}), 404 + + state = itunes_link_discovery_states[url_hash] + if state['phase'] == 'discovering': + return jsonify({"error": "Discovery already in progress"}), 400 + + if not state.get('playlist'): + return jsonify({"error": "iTunes link data missing. Please load the URL again."}), 404 + + state['phase'] = 'discovering' + state['status'] = 'discovering' + state['last_accessed'] = time.time() + state['discovery_results'] = [] + state['discovery_progress'] = 0 + state['spotify_matches'] = 0 + + playlist_name = state['playlist']['name'] + track_count = len(state['playlist']['tracks']) + add_activity_item("", "iTunes Link Discovery Started", f"'{playlist_name}' - {track_count} tracks", "Now") + + future = itunes_link_discovery_executor.submit(_run_itunes_link_discovery_worker, url_hash) + state['discovery_future'] = future + + logger.info(f"Started discovery for iTunes Link: {playlist_name}") + return jsonify({"success": True, "message": "Discovery started"}) + + except Exception as e: + logger.error(f"Error starting iTunes Link discovery: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/itunes-link/discovery/status/', methods=['GET']) +def get_itunes_link_discovery_status(url_hash): + try: + if url_hash not in itunes_link_discovery_states: + return jsonify({"error": "iTunes Link discovery not found"}), 404 + state = itunes_link_discovery_states[url_hash] + state['last_accessed'] = time.time() + return jsonify({ + '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' + }) + except Exception as e: + logger.error(f"Error getting iTunes Link discovery status: {e}") + return jsonify({"error": str(e)}), 500 + + +def _update_itunes_link_discovery_result(identifier, track_index, spotify_track): + state = itunes_link_discovery_states.get(identifier) + if not state: + return None, ('Discovery state not found', 404) + if track_index >= len(state['discovery_results']): + return None, ('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'] + result['duration'] = '0:00' + duration_ms = spotify_track.get('duration_ms', 0) + if duration_ms: + result['duration'] = f"{duration_ms // 60000}:{(duration_ms % 60000) // 1000:02d}" + result['spotify_data'] = _build_fix_modal_spotify_data(spotify_track) + result['wing_it_fallback'] = False + result['manual_match'] = True + if old_status not in ('found', 'Found'): + state['spotify_matches'] = state.get('spotify_matches', 0) + 1 + return result, None + + +@app.route('/api/itunes-link/discovery/update_match', methods=['POST']) +def update_itunes_link_discovery_match(): + try: + data = request.get_json() + identifier = data.get('identifier') + 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 + + result, error = _update_itunes_link_discovery_result(identifier, track_index, spotify_track) + if error: + message, status = error + return jsonify({'error': message}), status + + try: + original_track = result.get('itunes_link_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) + cache_db = get_database() + cache_db.save_discovery_cache_match( + cache_key[0], cache_key[1], _get_active_discovery_source(), 1.0, + result['spotify_data'], original_name, original_artist + ) + except Exception as cache_err: + logger.error(f"Error saving iTunes Link manual fix to discovery cache: {cache_err}") + + return jsonify({'success': True, 'result': result}) + + except Exception as e: + logger.error(f"Error updating iTunes Link discovery match: {e}") + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/itunes-link/playlists/states', methods=['GET']) +def get_itunes_link_playlist_states(): + try: + current_time = time.time() + states = [] + for url_hash, state in itunes_link_discovery_states.items(): + state['last_accessed'] = current_time + states.append({ + 'playlist_id': url_hash, + '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'] + }) + return jsonify({"states": states}) + except Exception as e: + logger.error(f"Error getting iTunes Link playlist states: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/itunes-link/state/', methods=['GET']) +def get_itunes_link_playlist_state(url_hash): + try: + if url_hash not in itunes_link_discovery_states: + return jsonify({"error": "iTunes Link not found"}), 404 + state = itunes_link_discovery_states[url_hash] + state['last_accessed'] = time.time() + return jsonify({ + 'playlist_id': url_hash, + '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'] + }) + except Exception as e: + logger.error(f"Error getting iTunes Link state: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/itunes-link/reset/', methods=['POST']) +def reset_itunes_link_playlist(url_hash): + try: + if url_hash not in itunes_link_discovery_states: + return jsonify({"error": "iTunes Link not found"}), 404 + state = itunes_link_discovery_states[url_hash] + if state.get('discovery_future'): + state['discovery_future'].cancel() + state.update({ + 'phase': 'fresh', + 'status': 'fresh', + 'discovery_results': [], + 'discovery_progress': 0, + 'spotify_matches': 0, + 'sync_playlist_id': None, + 'converted_spotify_playlist_id': None, + 'download_process_id': None, + 'sync_progress': {}, + 'discovery_future': None, + 'last_accessed': time.time() + }) + return jsonify({"success": True, "message": "iTunes Link reset to fresh phase"}) + except Exception as e: + logger.error(f"Error resetting iTunes Link: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/itunes-link/delete/', methods=['POST']) +def delete_itunes_link_playlist(url_hash): + try: + if url_hash not in itunes_link_discovery_states: + return jsonify({"error": "iTunes Link not found"}), 404 + state = itunes_link_discovery_states[url_hash] + if state.get('discovery_future'): + state['discovery_future'].cancel() + del itunes_link_discovery_states[url_hash] + return jsonify({"success": True, "message": "iTunes Link deleted"}) + except Exception as e: + logger.error(f"Error deleting iTunes Link: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/itunes-link/update_phase/', methods=['POST']) +def update_itunes_link_playlist_phase(url_hash): + try: + if url_hash not in itunes_link_discovery_states: + return jsonify({"error": "iTunes Link not found"}), 404 + data = request.get_json() + new_phase = data.get('phase') if data else None + 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 = itunes_link_discovery_states[url_hash] + 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'] + return jsonify({"success": True, "old_phase": old_phase, "new_phase": new_phase}) + except Exception as e: + logger.error(f"Error updating iTunes Link phase: {e}") + return jsonify({"error": str(e)}), 500 + + +def _build_itunes_link_discovery_deps(): + return _discovery_spotify_public.SpotifyPublicDiscoveryDeps( + spotify_public_discovery_states=itunes_link_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, + source_label="iTunes Link", + activity_label="iTunes Link", + original_track_key="itunes_link_track", + ) + + +def _run_itunes_link_discovery_worker(url_hash): + return _discovery_spotify_public.run_spotify_public_discovery_worker( + url_hash, _build_itunes_link_discovery_deps() + ) + + +@app.route('/api/itunes-link/sync/start/', methods=['POST']) +def start_itunes_link_sync(url_hash): + try: + if url_hash not in itunes_link_discovery_states: + return jsonify({"error": "iTunes Link not found"}), 404 + state = itunes_link_discovery_states[url_hash] + state['last_accessed'] = time.time() + if state['phase'] not in ['discovered', 'sync_complete', 'download_complete']: + return jsonify({"error": "iTunes Link not ready for sync"}), 400 + + spotify_tracks = _convert_link_results_to_spotify_tracks(state['discovery_results'], 'iTunes Link') + if not spotify_tracks: + return jsonify({"error": "No Spotify matches found for sync"}), 400 + + sync_playlist_id = f"itunes_link_{url_hash}" + playlist_name = state['playlist']['name'] + add_activity_item("", "iTunes Link Sync Started", f"'{playlist_name}' - {len(spotify_tracks)} tracks", "Now") + state['phase'] = 'syncing' + state['sync_playlist_id'] = sync_playlist_id + state['sync_progress'] = {} + + 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, playlist_name, spotify_tracks, None, get_current_profile_id(), playlist_image_url) + active_sync_workers[sync_playlist_id] = future + return jsonify({"success": True, "sync_playlist_id": sync_playlist_id}) + + except Exception as e: + logger.error(f"Error starting iTunes Link sync: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/itunes-link/sync/status/', methods=['GET']) +def get_itunes_link_sync_status(url_hash): + try: + if url_hash not in itunes_link_discovery_states: + return jsonify({"error": "iTunes Link not found"}), 404 + state = itunes_link_discovery_states[url_hash] + 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', {}) + add_activity_item("", "Sync Complete", f"iTunes Link '{state['playlist']['name']}' synced successfully", "Now") + elif sync_state.get('status') == 'error': + state['phase'] = 'discovered' + add_activity_item("", "Sync Failed", f"iTunes Link '{state['playlist']['name']}' sync failed", "Now") + return jsonify(response) + except Exception as e: + logger.error(f"Error getting iTunes Link sync status: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/itunes-link/sync/cancel/', methods=['POST']) +def cancel_itunes_link_sync(url_hash): + try: + if url_hash not in itunes_link_discovery_states: + return jsonify({"error": "iTunes Link not found"}), 404 + state = itunes_link_discovery_states[url_hash] + 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": "iTunes Link sync cancelled"}) + except Exception as e: + logger.error(f"Error cancelling iTunes Link sync: {e}") + return jsonify({"error": str(e)}), 500 + + # =================================================================== # YOUTUBE PLAYLIST API ENDPOINTS # =================================================================== @@ -23313,6 +24187,7 @@ def get_youtube_discovery_status(url_hash): @app.route('/api/tidal/discovery/unmatch', methods=['POST']) @app.route('/api/deezer/discovery/unmatch', methods=['POST']) @app.route('/api/spotify-public/discovery/unmatch', methods=['POST']) +@app.route('/api/itunes-link/discovery/unmatch', methods=['POST']) @app.route('/api/beatport/discovery/unmatch', methods=['POST']) @app.route('/api/listenbrainz/discovery/unmatch', methods=['POST']) def unmatch_discovery_track(): @@ -23330,6 +24205,7 @@ def unmatch_discovery_track(): or tidal_discovery_states.get(identifier) or deezer_discovery_states.get(identifier) or spotify_public_discovery_states.get(identifier) + or itunes_link_discovery_states.get(identifier) or beatport_chart_states.get(identifier) or listenbrainz_playlist_states.get(identifier)) @@ -23446,6 +24322,20 @@ def update_youtube_discovery_match(): logger.info(f"Manual match updated: youtube - {identifier} - track {track_index}") logger.info(f" → {result['spotify_artist']} - {result['spotify_track']}") + # See core.discovery.manual_match — Fix-popup matches can come from + # any metadata source (primary first, then Spotify / Deezer / iTunes + # / MusicBrainz as fallbacks). Hardcoding 'spotify' here used to + # make every non-Spotify manual match look provider-drifted on the + # next prepare-discovery, which triggered automatic re-discovery + # that overwrote the user's pick. Computed once before the try + # block so both the cache-save path AND the mirrored-DB save below + # (in the except fallback case) see the same value. + from core.discovery.manual_match import derive_manual_match_provider + match_source = derive_manual_match_provider( + spotify_track, _get_active_discovery_source() + ) + matched_data = None + # Save manual fix to discovery cache so it appears in discovery pool try: # Get original track name from the YouTube/source track data @@ -23487,7 +24377,7 @@ def update_youtube_discovery_match(): 'album': album_obj, 'duration_ms': spotify_track.get('duration_ms', 0), 'image_url': image_url, - 'source': 'spotify', + 'source': match_source, } cache_db = get_database() cache_db.save_discovery_cache_match( @@ -23498,8 +24388,11 @@ def update_youtube_discovery_match(): except Exception as cache_err: logger.error(f"Error saving manual fix to discovery cache: {cache_err}") - # Persist manual fix to DB for mirrored playlists - if identifier.startswith('mirrored_'): + # Persist manual fix to DB for mirrored playlists. Skips when the + # cache-save block raised before matched_data was constructed — + # without the payload there's nothing to persist, and re-deriving + # it here would duplicate the construction logic above. + if matched_data is not None and identifier.startswith('mirrored_'): try: tracks = state['playlist']['tracks'] if track_index < len(tracks): @@ -23508,7 +24401,7 @@ def update_youtube_discovery_match(): db = get_database() extra_data = { 'discovered': True, - 'provider': 'spotify', + 'provider': match_source, 'confidence': 1.0, 'matched_data': matched_data, 'manual_match': True, @@ -29117,6 +30010,7 @@ def _get_lb_discover_playlists(playlist_type): "identifier": f"https://listenbrainz.org/playlist/{playlist['playlist_mbid']}", "title": playlist['title'], "creator": playlist['creator'], + "track_count": playlist.get('track_count', 0), "annotation": playlist.get('annotation', {}), "track": [] } @@ -29383,6 +30277,7 @@ def get_listenbrainz_lastfm_radio(): "identifier": f"https://listenbrainz.org/playlist/{p['playlist_mbid']}", "title": p['title'], "creator": p['creator'], + "track_count": p.get('track_count', 0), "annotation": p.get('annotation', {}), "track": [], } @@ -29401,6 +30296,40 @@ def get_listenbrainz_lastfm_radio(): # LISTENBRAINZ PLAYLIST MANAGEMENT (Discovery System) # ======================================== +@app.route('/api/listenbrainz/series-detect', methods=['GET']) +def get_listenbrainz_series_detect(): + """Detect whether a LB playlist title belongs to a rotating series. + + Auto-mirror uses this to decide whether the resulting mirror + row should point at a per-playlist MBID (one-off LB playlist) + or a synthetic series id (e.g. ``lb_weekly_jams_``) that + rolls forward as ListenBrainz publishes new periods. + + Query: ``?title=`` + Response on a match: + ``{matched: true, series_id, canonical_name, + source: 'listenbrainz'|'lastfm'}`` + Response on no match: + ``{matched: false}`` + """ + try: + from core.playlists.lb_series import detect_series + + title = (request.args.get('title') or '').strip() + match = detect_series(title) + if match is None: + return jsonify({"matched": False}) + return jsonify({ + "matched": True, + "series_id": match.series_id, + "canonical_name": match.canonical_name, + "source": match.source_for_mirror, + }) + except Exception as e: + logger.error(f"Error detecting LB series: {e}") + return jsonify({"matched": False, "error": str(e)}), 500 + + def _lb_state_key(playlist_mbid, profile_id=None): """Build profile-scoped key for listenbrainz_playlist_states""" if profile_id is None: @@ -32183,15 +33112,26 @@ def mirror_playlist_endpoint(): def get_mirrored_playlists_endpoint(): """List all mirrored playlists for the active profile.""" try: + from core.playlists.source_refs import describe_mirrored_source_ref database = get_database() profile_id = get_current_profile_id() playlists = database.get_mirrored_playlists(profile_id=profile_id) + # Single batched query instead of N per-playlist round-trips. Used to + # take ~50ms per playlist (new connection + 4 sub-queries) — at 30 + # playlists that's 1.5s of modal load time just for status counts. + batch_counts = database.get_all_mirrored_playlist_status_counts(profile_id=profile_id) for pl in playlists: - counts = database.get_mirrored_playlist_status_counts(pl['id']) + counts = batch_counts.get(pl['id'], {'total': 0, 'discovered': 0, 'wishlisted': 0, 'in_library': 0}) pl['discovered_count'] = counts['discovered'] pl['total_count'] = counts['total'] pl['wishlisted_count'] = counts['wishlisted'] pl['in_library_count'] = counts['in_library'] + source_ref = describe_mirrored_source_ref(pl) + pl['source_ref'] = source_ref.source_ref + pl['source_ref_kind'] = source_ref.source_ref_kind + pl['source_ref_status'] = source_ref.source_ref_status + pl['source_ref_error'] = source_ref.source_ref_error + pl['pipeline_state'] = _snapshot_playlist_pipeline_state(pl['id']) return jsonify(playlists) except Exception as e: logger.error(f"Error getting mirrored playlists: {e}") @@ -32201,16 +33141,300 @@ def get_mirrored_playlists_endpoint(): def get_mirrored_playlist_endpoint(playlist_id): """Get a mirrored playlist with its tracks.""" try: + from core.playlists.source_refs import describe_mirrored_source_ref database = get_database() playlist = database.get_mirrored_playlist(playlist_id) if not playlist: return jsonify({"error": "Playlist not found"}), 404 + source_ref = describe_mirrored_source_ref(playlist) + playlist['source_ref'] = source_ref.source_ref + playlist['source_ref_kind'] = source_ref.source_ref_kind + playlist['source_ref_status'] = source_ref.source_ref_status + playlist['source_ref_error'] = source_ref.source_ref_error + playlist['pipeline_state'] = _snapshot_playlist_pipeline_state(playlist_id) playlist['tracks'] = database.get_mirrored_playlist_tracks(playlist_id) return jsonify(playlist) except Exception as e: logger.error(f"Error getting mirrored playlist: {e}") return jsonify({"error": str(e)}), 500 +@app.route('/api/mirrored-playlists//source-ref', methods=['PATCH']) +def update_mirrored_playlist_source_ref_endpoint(playlist_id): + """Update the upstream source link/id for a mirrored playlist.""" + try: + data = request.get_json() or {} + source_ref = data.get('source_ref') or data.get('source_playlist_id') or data.get('url') + + database = get_database() + playlist = database.get_mirrored_playlist(playlist_id) + if not playlist: + return jsonify({"error": "Playlist not found"}), 404 + + try: + from core.playlists.source_refs import normalize_mirrored_source_ref + normalized = normalize_mirrored_source_ref( + playlist.get('source'), + source_ref, + playlist.get('description') or '', + ) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + + existing = [ + pl for pl in database.get_mirrored_playlists(profile_id=playlist.get('profile_id', 1)) + if ( + pl.get('source') == playlist.get('source') + and str(pl.get('source_playlist_id')) == str(normalized.source_playlist_id) + and int(pl.get('id')) != int(playlist_id) + ) + ] + if existing: + return jsonify({ + "error": f"That source is already mirrored as '{existing[0].get('name', 'another playlist')}'" + }), 409 + + ok = database.update_mirrored_playlist_source_ref( + playlist_id, + normalized.source_playlist_id, + normalized.description, + ) + if not ok: + return jsonify({"error": "Failed to update source reference"}), 500 + + updated = database.get_mirrored_playlist(playlist_id) or {} + return jsonify({"success": True, "playlist": updated}) + except Exception as e: + logger.error(f"Error updating mirrored playlist source reference: {e}") + return jsonify({"error": str(e)}), 500 + + +def _playlist_pipeline_state_key(playlist_id): + return f"mirrored_{int(playlist_id)}" + + +def _snapshot_playlist_pipeline_state(playlist_id): + key = _playlist_pipeline_state_key(playlist_id) + with playlist_pipeline_progress_lock: + state = playlist_pipeline_progress_states.get(key) + return dict(state) if state else None + + +def _replace_playlist_pipeline_state(playlist_id, state): + key = _playlist_pipeline_state_key(playlist_id) + with playlist_pipeline_progress_lock: + playlist_pipeline_progress_states[key] = dict(state) + return dict(playlist_pipeline_progress_states[key]) + + +def _update_playlist_pipeline_progress(playlist_id, **kwargs): + key = _playlist_pipeline_state_key(playlist_id) + with playlist_pipeline_progress_lock: + state = playlist_pipeline_progress_states.setdefault(key, { + 'run_id': key, + 'playlist_id': int(playlist_id), + 'status': 'running', + 'progress': 0, + 'phase': 'Starting pipeline...', + 'log': [], + 'started_at': time.time(), + 'finished_at': None, + 'result': None, + 'error': None, + }) + for field in ('status', 'progress', 'phase', 'result', 'error'): + if field in kwargs: + state[field] = kwargs[field] + if 'log_line' in kwargs and kwargs.get('log_line'): + state.setdefault('log', []).append({ + 'message': kwargs.get('log_line'), + 'type': kwargs.get('log_type') or 'info', + 'timestamp': time.time(), + }) + state['log'] = state['log'][-80:] + if state.get('status') in ('finished', 'error', 'skipped') and not state.get('finished_at'): + state['finished_at'] = time.time() + return dict(state) + + +class _PlaylistPipelineDepsProxy: + """Forward automation deps while routing progress into playlist UI state.""" + + def __init__(self, base_deps, playlist_id): + self._base_deps = base_deps + self._playlist_id = playlist_id + + def __getattr__(self, name): + return getattr(self._base_deps, name) + + def update_progress(self, _automation_id, **kwargs): + _update_playlist_pipeline_progress(self._playlist_id, **kwargs) + + +def _run_mirrored_playlist_pipeline_for_ui(playlist_id, skip_wishlist=False): + try: + if _automation_deps is None: + raise RuntimeError("Automation dependencies are not available") + + from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored + from core.automation.handlers.sync_playlist import auto_sync_playlist + from core.automation.handlers._pipeline_shared import run_sync_and_wishlist + from core.playlists.pipeline import run_mirrored_playlist_pipeline + + deps = _PlaylistPipelineDepsProxy(_automation_deps, playlist_id) + result = run_mirrored_playlist_pipeline( + { + 'playlist_id': str(playlist_id), + 'all': False, + 'skip_wishlist': bool(skip_wishlist), + '_automation_id': _playlist_pipeline_state_key(playlist_id), + }, + deps, + refresh_fn=auto_refresh_mirrored, + sync_one_fn=auto_sync_playlist, + sync_and_wishlist_fn=run_sync_and_wishlist, + ) + + status = result.get('status') + if status == 'completed': + _update_playlist_pipeline_progress( + playlist_id, + status='finished', + progress=100, + phase='Pipeline complete', + result=result, + ) + elif status == 'skipped': + _update_playlist_pipeline_progress( + playlist_id, + status='skipped', + progress=100, + phase='Pipeline already running', + error=result.get('reason') or 'Pipeline already running', + result=result, + log_line=result.get('reason') or 'Pipeline already running', + log_type='warning', + ) + else: + _update_playlist_pipeline_progress( + playlist_id, + status='error', + progress=100, + phase='Pipeline error', + error=result.get('error') or 'Pipeline failed', + result=result, + ) + except Exception as e: + logger.error(f"Manual mirrored playlist pipeline failed for {playlist_id}: {e}") + _update_playlist_pipeline_progress( + playlist_id, + status='error', + progress=100, + phase='Pipeline error', + error=str(e), + log_line=f'Pipeline failed: {e}', + log_type='error', + ) + + +@app.route('/api/mirrored-playlists//pipeline/run', methods=['POST']) +def run_mirrored_playlist_pipeline_endpoint(playlist_id): + """Run the all-in-one mirrored playlist pipeline from the playlist UI.""" + try: + database = get_database() + playlist = database.get_mirrored_playlist(playlist_id) + if not playlist: + return jsonify({"error": "Playlist not found"}), 404 + if playlist.get('source') in ('file', 'beatport'): + return jsonify({"error": "This playlist source cannot be refreshed by the pipeline"}), 400 + if _automation_deps is None: + return jsonify({"error": "Playlist pipeline is not available"}), 503 + if _automation_deps.state.is_pipeline_running(): + return jsonify({"error": "A playlist pipeline is already running"}), 409 + + data = request.get_json(silent=True) or {} + state = _replace_playlist_pipeline_state(playlist_id, { + 'run_id': _playlist_pipeline_state_key(playlist_id), + 'playlist_id': int(playlist_id), + 'playlist_name': playlist.get('name') or '', + 'status': 'running', + 'progress': 0, + 'phase': 'Starting pipeline...', + 'log': [{ + 'message': f"Starting pipeline for {playlist.get('name') or playlist_id}", + 'type': 'info', + 'timestamp': time.time(), + }], + 'started_at': time.time(), + 'finished_at': None, + 'result': None, + 'error': None, + }) + + threading.Thread( + target=_run_mirrored_playlist_pipeline_for_ui, + args=(playlist_id, bool(data.get('skip_wishlist', False))), + daemon=True, + name=f"playlist-pipeline-{playlist_id}", + ).start() + + return jsonify({"success": True, "state": state}) + except Exception as e: + logger.error(f"Error starting mirrored playlist pipeline: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/mirrored-playlists//pipeline/status', methods=['GET']) +def get_mirrored_playlist_pipeline_status_endpoint(playlist_id): + """Return the latest manual pipeline progress for a mirrored playlist.""" + try: + state = _snapshot_playlist_pipeline_state(playlist_id) + if not state: + return jsonify({ + "run_id": _playlist_pipeline_state_key(playlist_id), + "playlist_id": int(playlist_id), + "status": "idle", + "progress": 0, + "phase": "Idle", + "log": [], + "result": None, + "error": None, + }) + return jsonify(state) + except Exception as e: + logger.error(f"Error getting mirrored playlist pipeline status: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/playlist-pipeline/history', methods=['GET']) +def get_playlist_pipeline_history_endpoint(): + """Return persisted run history for mirrored playlist pipeline executions.""" + try: + database = get_database() + profile_id = get_current_profile_id() + limit = min(max(int(request.args.get('limit', 50)), 1), 100) + offset = max(int(request.args.get('offset', 0)), 0) + playlist_id_raw = request.args.get('playlist_id') + playlist_id = int(playlist_id_raw) if playlist_id_raw else None + data = database.get_playlist_pipeline_run_history( + profile_id=profile_id, + playlist_id=playlist_id, + limit=limit, + offset=offset, + ) + for entry in data.get('history', []): + for key in ('before_json', 'after_json', 'result_json', 'log_lines'): + if entry.get(key): + try: + entry[key] = json.loads(entry[key]) + except (json.JSONDecodeError, TypeError): + entry[key] = [] if key == 'log_lines' else {} + else: + entry[key] = [] if key == 'log_lines' else {} + return jsonify(data) + except Exception as e: + logger.error(f"Error getting playlist pipeline history: {e}") + return jsonify({"error": str(e)}), 500 + @app.route('/api/mirrored-playlists/', methods=['DELETE']) def delete_mirrored_playlist_endpoint(playlist_id): """Delete a mirrored playlist.""" @@ -32467,15 +33691,20 @@ def prepare_mirrored_discovery(playlist_id): pre_discovered_count = 0 has_pending = False + from core.discovery.manual_match import is_drifted_for_redo + for idx, track in enumerate(tracks): extra = track.get('extra_data') if extra and extra.get('discovered'): cached_provider = extra.get('provider', 'spotify') - # If the cached result was discovered by a different provider than the - # currently active one, treat it as pending so re-discovery uses the - # correct source (IDs, album data, images differ between providers). - if cached_provider != _current_provider: + # See core.discovery.manual_match.is_drifted_for_redo — + # provider-drift triggers re-discovery so the active source's + # IDs / artwork take effect, but manual matches are exempt: + # re-running would overwrite the user's deliberate pick with + # whatever auto-search ranks first. Pre-fix, every Playlist + # Pipeline run clobbered manual fixes for exactly this reason. + if is_drifted_for_redo(extra, _current_provider): has_pending = True dur = track.get('duration_ms', 0) pre_discovered_results.append({ @@ -32557,11 +33786,14 @@ def prepare_mirrored_discovery(playlist_id): 'confidence': 0, }) - # Only treat as cached if at least one track was discovered by the current provider + # Treat as cached when at least one track has a non-drifted cached + # discovery — same predicate the per-track loop above uses (inverse + # polarity), so a future field change only has to land in + # core.discovery.manual_match.is_drifted_for_redo. has_cached = any( t.get('extra_data') and (t['extra_data'].get('discovered') or t['extra_data'].get('discovery_attempted')) and - t['extra_data'].get('provider', 'spotify') == _current_provider + not is_drifted_for_redo(t['extra_data'], _current_provider) for t in tracks ) @@ -35299,6 +36531,7 @@ def _emit_discovery_progress_loop(): 'beatport': lambda: beatport_chart_states, 'listenbrainz': lambda: listenbrainz_playlist_states, 'spotify_public': lambda: spotify_public_discovery_states, + 'itunes_link': lambda: itunes_link_discovery_states, } while not globals().get('IS_SHUTTING_DOWN', False): socketio.sleep(1) diff --git a/webui/index.html b/webui/index.html index 709c9b9e..8d70b9e7 100644 --- a/webui/index.html +++ b/webui/index.html @@ -837,18 +837,86 @@ - -
+ +
-

Tools

-

Database, scanning, backups, cache, maintenance & more.

+

Quick Actions

+

Three control rooms inside SoulSync.

-
-
- -
-
Open Tools
- +
+ + +
@@ -916,6 +984,7 @@ server

+
@@ -937,15 +1006,18 @@ + - + @@ -955,6 +1027,15 @@ + + + @@ -1046,6 +1127,19 @@ + + +
@@ -1827,6 +1921,44 @@
+ +
+
+

SoulSync Discovery Playlists

+ +
+
+
Click 'Refresh' to load your personalized SoulSync Discovery playlists.
+
+
+ + +
+
+

Your Last.fm Radio Playlists

+ +
+
+
Click 'Refresh' to load your Last.fm Radio playlists. Generate new ones from the Discover page.
+
+
+ + +
+
+

Your ListenBrainz Playlists

+
+ + + +
+ +
+
+
Click 'Refresh' to load your ListenBrainz playlists.
+
+
+
@@ -7877,6 +8009,9 @@ + + + @@ -7884,6 +8019,7 @@ + diff --git a/webui/static/auto-sync.js b/webui/static/auto-sync.js new file mode 100644 index 00000000..2dd3b21c --- /dev/null +++ b/webui/static/auto-sync.js @@ -0,0 +1,1521 @@ +// Auto-Sync: schedule board + mirrored-playlist pipeline runs +// ───────────────────────────────────────────────────────────────────── +// Extracted from stats-automations.js (Cin review feedback). All +// references rely on globals available at runtime — `_esc`, `_escAttr`, +// `_autoParseUTC`, `_autoFormatTrigger`, `showToast`, `showConfirmDialog`, +// `loadMirroredPlaylists`, `updateMirroredCardPhase`, +// `openMirroredPlaylistModal`, `closeMirroredModal`, `youtubePlaylistStates` +// all live in stats-automations.js (or earlier helpers). This file +// declares the auto-sync-specific state + render/event functions on top. + +const mirroredPipelinePollers = {}; +const AUTO_SYNC_BUCKETS = [1, 2, 4, 8, 12, 16, 24, 48, 72, 168]; +let _autoSyncStatusPoller = null; +let _autoSyncIsDragging = false; +let _autoSyncScheduleState = { + playlists: [], + automations: [], + playlistSchedules: {}, + automationPipelines: [], + runHistory: [], + runHistoryTotal: 0, +}; +let _autoSyncActiveTab = 'schedule'; +let _autoSyncSidebarFilter = ''; +let _autoSyncHistoryFilter = 'all'; // 'all' | 'error' | 'completed' | 'skipped' +let _autoSyncHistoryLimit = 50; + +function getMirroredSourceRef(p) { + if (p && p.source_ref) return String(p.source_ref); + const desc = (p && p.description) ? String(p.description).trim() : ''; + if ((p.source === 'spotify_public' || p.source === 'youtube') && /^https?:\/\//i.test(desc)) { + return desc; + } + return (p && p.source_playlist_id) ? String(p.source_playlist_id) : ''; +} + +function autoSyncTriggerForHours(hours) { + const h = parseInt(hours, 10) || 24; + if (h >= 24 && h % 24 === 0) { + return { interval: h / 24, unit: 'days' }; + } + return { interval: h, unit: 'hours' }; +} + +function autoSyncHoursFromTrigger(config) { + const interval = parseInt(config?.interval, 10) || 0; + const unit = config?.unit || 'hours'; + if (!interval) return null; + if (unit === 'minutes') return Math.max(1, Math.round(interval / 60)); + if (unit === 'days') return interval * 24; + if (unit === 'weeks') return interval * 168; + return interval; +} + +function autoSyncBucketLabel(hours) { + if (hours === 168) return 'Weekly'; + if (hours >= 24) return `${hours / 24}d`; + return `${hours}h`; +} + +function autoSyncIntervalLabel(hours) { + if (hours === 168) return 'Every week'; + if (hours >= 24) { + const days = hours / 24; + return `Every ${days} day${days === 1 ? '' : 's'}`; + } + return `Every ${hours} hour${hours === 1 ? '' : 's'}`; +} + +function autoSyncSourceLabel(source) { + const labels = { + spotify: 'Spotify', + spotify_public: 'Spotify Link', + tidal: 'Tidal', + youtube: 'YouTube', + deezer: 'Deezer', + qobuz: 'Qobuz', + beatport: 'Beatport', + file: 'File Imports', + itunes_link: 'iTunes Link', + listenbrainz: 'ListenBrainz', + lastfm: 'Last.fm Radio', + soulsync_discovery: 'SoulSync Discovery', + }; + return labels[source] || source || 'Other'; +} + +function autoSyncCanSchedulePlaylist(playlist) { + if (!playlist) return false; + const src = playlist.source || ''; + // ``file`` + ``beatport`` have no external refresh hook. + // ``lastfm`` is excluded because each Last.fm Radio playlist is a + // seed-track-specific similar-tracks snapshot that doesn't update + // on the Last.fm side — auto-syncing it would just re-discover the + // same 25 tracks every interval. Users mirror Last.fm radios once + // to grab the downloads, then move on; they belong in the + // Mirrored / Sync tab but not the Auto-Sync schedule board. + return !['file', 'beatport', 'lastfm'].includes(src); +} + +function autoSyncIsPipelineAutomation(auto) { + return auto && auto.action_type === 'playlist_pipeline'; +} + +function autoSyncPlaylistIdFromAutomation(auto) { + if (!autoSyncIsPipelineAutomation(auto)) return null; + const cfg = auto.action_config || {}; + if (cfg.all === true || cfg.all === 'true') return null; + const raw = cfg.playlist_id; + if (raw === undefined || raw === null || raw === '') return null; + const id = parseInt(raw, 10); + return Number.isFinite(id) ? id : null; +} + +function autoSyncIsScheduleOwned(auto) { + // Primary signal: the explicit owned_by flag the board writes on every + // schedule it creates. Falls back to the legacy name/group convention + // so rows created before the column existed (or hand-edited from the + // Automations page) still get recognized after backfill. + if (auto?.owned_by === 'auto_sync') return true; + const group = auto?.group_name || ''; + const name = auto?.name || ''; + return group === 'Playlist Auto-Sync' || name.startsWith('Auto-Sync:'); +} + +function buildAutoSyncScheduleState(playlists, automations, historyData = {}) { + const playlistSchedules = {}; + const automationPipelines = []; + const pipelineAutomations = automations.filter(autoSyncIsPipelineAutomation); + pipelineAutomations.forEach(auto => { + const playlistId = autoSyncPlaylistIdFromAutomation(auto); + const hours = auto.trigger_type === 'schedule' ? autoSyncHoursFromTrigger(auto.trigger_config || {}) : null; + if (playlistId && hours && autoSyncIsScheduleOwned(auto)) { + playlistSchedules[playlistId] = { + automation_id: auto.id, + automation_name: auto.name, + hours, + enabled: auto.enabled !== false && auto.enabled !== 0, + owned: true, + next_run: auto.next_run, + trigger_config: auto.trigger_config || {}, + }; + } else { + automationPipelines.push(auto); + } + }); + return { + playlists, + automations, + playlistSchedules, + automationPipelines, + runHistory: historyData.history || [], + runHistoryTotal: historyData.total || 0, + }; +} + +async function openAutoSyncScheduleModal() { + let overlay = document.getElementById('auto-sync-schedule-modal'); + if (!overlay) { + overlay = document.createElement('div'); + overlay.id = 'auto-sync-schedule-modal'; + overlay.className = 'auto-sync-overlay'; + document.body.appendChild(overlay); + } + overlay.innerHTML = ` +
+
+
+

Auto-Sync Schedule

+

Drop mirrored playlists onto an interval to schedule refresh, discovery, sync, and wishlist processing.

+
+ +
+
Loading schedule...
+
+ `; + overlay.style.display = 'flex'; + overlay.onclick = e => { if (e.target === overlay) closeAutoSyncScheduleModal(); }; + await refreshAutoSyncScheduleModal(); +} + +function closeAutoSyncScheduleModal() { + const overlay = document.getElementById('auto-sync-schedule-modal'); + stopAutoSyncStatusPolling(); + if (overlay) overlay.remove(); +} + +async function refreshAutoSyncScheduleModal() { + const overlay = document.getElementById('auto-sync-schedule-modal'); + if (!overlay) return; + try { + const [playlistRes, automationRes, historyRes] = await Promise.all([ + fetch('/api/mirrored-playlists'), + fetch('/api/automations'), + fetch(`/api/playlist-pipeline/history?limit=${_autoSyncHistoryLimit}`), + ]); + const playlists = await playlistRes.json(); + const automations = await automationRes.json(); + const historyData = await historyRes.json(); + if (!playlistRes.ok || playlists.error) throw new Error(playlists.error || 'Failed to load mirrored playlists'); + if (!automationRes.ok || automations.error) throw new Error(automations.error || 'Failed to load automations'); + if (!historyRes.ok || historyData.error) throw new Error(historyData.error || 'Failed to load pipeline run history'); + _autoSyncScheduleState = buildAutoSyncScheduleState(playlists, automations, historyData); + renderAutoSyncScheduleModal(); + manageAutoSyncStatusPolling(); + } catch (err) { + overlay.innerHTML = ` +
+
+

Auto-Sync Schedule

Could not load schedule data.

+ +
+
${_esc((err && err.message) || String(err))}
+
+ `; + } +} + +function renderAutoSyncScheduleModal() { + const overlay = document.getElementById('auto-sync-schedule-modal'); + if (!overlay) return; + + const { playlists, playlistSchedules, automationPipelines, runHistory, runHistoryTotal } = _autoSyncScheduleState; + const scheduledCount = Object.keys(playlistSchedules).length; + const enabledCount = Object.values(playlistSchedules).filter(s => s.enabled).length; + const pipelineCount = automationPipelines.length; + const totalTracks = playlists.reduce((sum, p) => sum + (parseInt(p.track_count, 10) || 0), 0); + const scheduleActive = _autoSyncActiveTab === 'schedule'; + const automationsActive = _autoSyncActiveTab === 'automations'; + const historyActive = _autoSyncActiveTab === 'history'; + + const schedulePanel = renderAutoSyncSchedulePanel(playlists, playlistSchedules); + const automationPanel = renderAutoSyncAutomationPanel(automationPipelines, playlists); + const historyPanel = renderAutoSyncHistoryPanel(runHistory, runHistoryTotal); + const monitor = renderAutoSyncPipelineMonitor(playlists); + + overlay.innerHTML = ` +
+
+
+
Playlist automation
+

Auto-Sync Manager

+

Schedule mirrored playlists through the same playlist-pipeline engine used by Automations.

+
+ +
+
+
${scheduledCount}scheduled playlists
+
${enabledCount}active schedules
+
${pipelineCount}automation pipelines
+
${totalTracks}mirrored tracks
+
+ ${monitor} +
+ + + +
+
${schedulePanel}
+
${automationPanel}
+
${historyPanel}
+
+ `; + populateAutoSyncHistoryList(overlay); + bindAutoSyncHistoryCardInteractions(overlay); +} + +function setAutoSyncTab(tab) { + _autoSyncActiveTab = ['automations', 'history'].includes(tab) ? tab : 'schedule'; + renderAutoSyncScheduleModal(); +} + +function renderAutoSyncSchedulePanel(playlists, playlistSchedules) { + const filter = (_autoSyncSidebarFilter || '').trim().toLowerCase(); + const matchesFilter = (p) => !filter || (p.name || '').toLowerCase().includes(filter) + || autoSyncSourceLabel(p.source || '').toLowerCase().includes(filter); + const schedulablePlaylists = playlists.filter(p => autoSyncCanSchedulePlaylist(p) && matchesFilter(p)); + const unavailablePlaylists = playlists.filter(p => !autoSyncCanSchedulePlaylist(p) && matchesFilter(p)); + const grouped = schedulablePlaylists.reduce((acc, p) => { + const key = p.source || 'other'; + if (!acc[key]) acc[key] = []; + acc[key].push(p); + return acc; + }, {}); + const sourceKeys = Object.keys(grouped).sort((a, b) => autoSyncSourceLabel(a).localeCompare(autoSyncSourceLabel(b))); + + const sidebarHtml = sourceKeys.length ? sourceKeys.map(source => ` +
+
+ ${_esc(autoSyncSourceLabel(source))} + +
+ ${grouped[source].map(p => { + const schedule = playlistSchedules[p.id]; + const assigned = schedule ? autoSyncIntervalLabel(schedule.hours) : 'Unscheduled'; + return ` +
+
${_esc(p.name)}
+
${p.track_count || 0} tracks · ${_esc(assigned)}
+
+ `; + }).join('')} +
+ `).join('') : '
No refreshable mirrored playlists yet.
'; + + const unavailableHtml = unavailablePlaylists.length ? ` +
+
Not schedulable
+ ${unavailablePlaylists.map(p => ` +
+
${_esc(p.name)}
+
${_esc(autoSyncSourceLabel(p.source))} · refresh not supported
+
+ `).join('')} +
+ ` : ''; + + // Merge standard buckets with any custom intervals that are already in + // use, so a 6h or 36h schedule (created via Automations page or the + // custom-interval prompt) still renders as its own column instead of + // disappearing from the board. + const customHours = Object.values(playlistSchedules) + .map(s => parseInt(s?.hours, 10)) + .filter(h => Number.isFinite(h) && h > 0 && !AUTO_SYNC_BUCKETS.includes(h)); + const allBuckets = [...new Set([...AUTO_SYNC_BUCKETS, ...customHours])].sort((a, b) => a - b); + const bucketHtml = allBuckets.map(hours => { + const assigned = schedulablePlaylists.filter(p => playlistSchedules[p.id]?.hours === hours); + const isCustom = !AUTO_SYNC_BUCKETS.includes(hours); + return ` +
+
+ ${autoSyncBucketLabel(hours)}${isCustom ? ' custom' : ''} + ${assigned.length} playlist${assigned.length === 1 ? '' : 's'} +
+
+ ${assigned.length ? assigned.map(p => autoSyncScheduledCardHtml(p, playlistSchedules[p.id])).join('') : '
Drop hereSchedule playlists at this interval
'} +
+
+ `; + }).join(''); + + const filterValue = _esc(_autoSyncSidebarFilter || ''); + return ` +
+
+ Drag playlists into an interval + Each placement creates or updates an Auto-Sync-owned playlist-pipeline automation. +
+ +
+
+ +
${bucketHtml}
+
+ `; +} + +function setAutoSyncSidebarFilter(value) { + _autoSyncSidebarFilter = String(value || ''); + // Only re-render the sidebar/board portion to keep input focus. + const panel = document.getElementById('auto-sync-schedule-panel'); + if (!panel) return; + const { playlists, playlistSchedules } = _autoSyncScheduleState; + panel.innerHTML = renderAutoSyncSchedulePanel(playlists, playlistSchedules); + // Restore focus to the search input + caret position at end. + const input = panel.querySelector('.auto-sync-sidebar-search'); + if (input) { + input.focus(); + try { input.setSelectionRange(value.length, value.length); } catch (_) {} + } +} + +function getAutoSyncPipelinePlaylists(playlists) { + return playlists + .map(p => ({ playlist: p, state: p.pipeline_state || null })) + .filter(item => item.state && item.state.status && item.state.status !== 'idle') + .sort((a, b) => { + const aRunning = a.state.status === 'running' ? 1 : 0; + const bRunning = b.state.status === 'running' ? 1 : 0; + if (aRunning !== bRunning) return bRunning - aRunning; + return (b.state.finished_at || b.state.started_at || 0) - (a.state.finished_at || a.state.started_at || 0); + }); +} + +function autoSyncPipelineStatusLabel(status) { + if (status === 'running') return 'Running'; + if (status === 'finished') return 'Completed'; + if (status === 'skipped') return 'Skipped'; + if (status === 'error') return 'Needs attention'; + return 'Idle'; +} + +function autoSyncPipelineStatusClass(status) { + if (status === 'running') return 'running'; + if (status === 'finished') return 'finished'; + if (status === 'error' || status === 'skipped') return 'error'; + return 'idle'; +} + +function renderAutoSyncPipelineMonitor(playlists) { + const pipelineItems = getAutoSyncPipelinePlaylists(playlists); + const running = pipelineItems.filter(item => item.state.status === 'running'); + const recent = pipelineItems.filter(item => item.state.status !== 'running').slice(0, 2); + const visible = [...running, ...recent].slice(0, 4); + const title = running.length + ? `${running.length} pipeline${running.length === 1 ? '' : 's'} running` + : 'No pipelines running'; + const detail = running.length + ? 'Live status refreshes while this modal is open.' + : 'Use Run now on a scheduled playlist when you want the pipeline immediately.'; + + return ` +
+
+
+ Live pipeline monitor + ${_esc(title)} + ${_esc(detail)} +
+ +
+ ${visible.length ? ` +
+ ${visible.map(({ playlist, state }) => autoSyncPipelineMonitorCardHtml(playlist, state)).join('')} +
+ ` : '
ReadyScheduled playlists appear here while the all-in-one pipeline runs.
'} +
+ `; +} + +function autoSyncPipelineMonitorCardHtml(playlist, state) { + const status = state.status || 'idle'; + const progress = Math.max(0, Math.min(100, parseInt(state.progress, 10) || 0)); + const latest = Array.isArray(state.log) && state.log.length ? state.log[state.log.length - 1].message : ''; + const phase = state.phase || autoSyncPipelineStatusLabel(status); + return ` +
+
+
+ ${_esc(playlist.name || `Playlist #${playlist.id}`)} + ${_esc(autoSyncPipelineStatusLabel(status))} +
+
${_esc(phase)}
+
+
+
+ ${latest ? `${_esc(latest)}` : ''} +
+ +
+ `; +} + +function renderAutoSyncAutomationPanel(automationPipelines, playlists) { + if (!automationPipelines.length) { + return '
No Automations-page playlist pipelines found.
'; + } + return ` +
+ Read-only Automations-page pipelines + These use the playlist pipeline but are managed from the Automations page, so this modal only displays them. +
+
+ ${automationPipelines.map(auto => autoSyncAutomationCardHtml(auto, playlists)).join('')} +
+ `; +} + +function renderAutoSyncHistoryPanel(history, total) { + if (!history.length) { + return ` +
+ No playlist pipeline runs yet + Future Auto-Sync and playlist pipeline runs will record before/after playlist snapshots here. +
+ `; + } + const filter = _autoSyncHistoryFilter || 'all'; + const tabs = [ + ['all', 'All', history.length], + ['error', 'Errors', history.filter(h => h.status === 'error' || h.status === 'skipped').length], + ['completed', 'Completed', history.filter(h => h.status === 'completed' || h.status === 'finished').length], + ]; + const filterTabsHtml = tabs.map(([key, label, count]) => ` + + `).join(''); + const canLoadMore = total > history.length; + return ` +
+
+ Playlist pipeline run history + Each run records what changed on the mirrored playlist before and after refresh, discovery, sync, and wishlist processing. +
+
+
${filterTabsHtml}
+ +
+
+
+
Preparing run history...
+
+ ${canLoadMore ? ` +
+ +
+ ` : ''} + `; +} + +function setAutoSyncHistoryFilter(key) { + _autoSyncHistoryFilter = ['error', 'completed'].includes(key) ? key : 'all'; + renderAutoSyncScheduleModal(); +} + +function loadMoreAutoSyncHistory() { + _autoSyncHistoryLimit = Math.min(500, _autoSyncHistoryLimit + 50); + refreshAutoSyncScheduleModal(); +} + +function openAutoSyncBulkMenu(event, source) { + // Build a transient popover with all the standard buckets + a "Custom…" + // entry. Position relative to the button that triggered it. + closeAutoSyncBulkMenu(); + const anchor = event.currentTarget; + if (!anchor) return; + const menu = document.createElement('div'); + menu.className = 'auto-sync-bulk-menu'; + menu.id = 'auto-sync-bulk-menu'; + const buckets = [...AUTO_SYNC_BUCKETS]; + const buttons = buckets.map(h => ` + + `).join(''); + menu.innerHTML = ` +
Schedule all ${_esc(autoSyncSourceLabel(source))}
+
${buttons}
+ + + `; + document.body.appendChild(menu); + const rect = anchor.getBoundingClientRect(); + menu.style.top = `${rect.bottom + 4}px`; + menu.style.left = `${Math.max(8, rect.right - menu.offsetWidth)}px`; + // Close on outside click + setTimeout(() => { + document.addEventListener('click', _autoSyncBulkMenuOutsideClick, { once: true }); + }, 0); +} + +function _autoSyncBulkMenuOutsideClick(event) { + const menu = document.getElementById('auto-sync-bulk-menu'); + if (menu && !menu.contains(event.target)) closeAutoSyncBulkMenu(); +} + +function closeAutoSyncBulkMenu() { + const existing = document.getElementById('auto-sync-bulk-menu'); + if (existing) existing.remove(); +} + +function promptAutoSyncBulkCustom(source) { + closeAutoSyncBulkMenu(); + const raw = window.prompt('Custom interval in hours (e.g. 6, 36, 96):', '6'); + if (raw === null) return; + const hours = parseInt(raw, 10); + if (!Number.isFinite(hours) || hours < 1) { + showToast('Interval must be a whole number of hours, 1 or greater', 'error'); + return; + } + bulkScheduleAutoSyncSource(source, hours); +} + +async function bulkScheduleAutoSyncSource(source, hours) { + closeAutoSyncBulkMenu(); + const { playlists } = _autoSyncScheduleState; + const targets = (playlists || []).filter(p => p.source === source && autoSyncCanSchedulePlaylist(p)); + if (!targets.length) { + showToast(`No schedulable ${autoSyncSourceLabel(source)} playlists`, 'info'); + return; + } + if (!await showConfirmDialog({ + title: `Schedule ${targets.length} ${autoSyncSourceLabel(source)} playlist${targets.length === 1 ? '' : 's'}`, + message: `Every ${autoSyncIntervalLabel(hours).toLowerCase().replace(/^every /, '')}. Existing schedules in this source will be updated.`, + })) return; + let ok = 0, fail = 0; + for (const playlist of targets) { + try { + await saveAutoSyncPlaylistScheduleSilent(playlist.id, hours); + ok++; + } catch (_err) { + fail++; + } + } + showToast(`Scheduled ${ok} ${autoSyncSourceLabel(source)} playlist${ok === 1 ? '' : 's'} at ${autoSyncBucketLabel(hours)}${fail ? ` (${fail} failed)` : ''}`, fail ? 'warning' : 'success'); + await refreshAutoSyncScheduleModal(); +} + +async function bulkUnscheduleAutoSyncSource(source) { + closeAutoSyncBulkMenu(); + const { playlists, playlistSchedules } = _autoSyncScheduleState; + const targets = (playlists || []).filter(p => p.source === source && playlistSchedules[p.id]); + if (!targets.length) { + showToast(`No scheduled ${autoSyncSourceLabel(source)} playlists to unschedule`, 'info'); + return; + } + if (!await showConfirmDialog({ + title: `Unschedule ${targets.length} ${autoSyncSourceLabel(source)} playlist${targets.length === 1 ? '' : 's'}`, + message: 'Removes the Auto-Sync schedules. Mirrored playlists themselves stay.', + })) return; + let ok = 0, fail = 0; + for (const playlist of targets) { + const schedule = playlistSchedules[playlist.id]; + if (!schedule) continue; + try { + const res = await fetch(`/api/automations/${schedule.automation_id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + ok++; + } catch (_err) { + fail++; + } + } + showToast(`Removed ${ok} schedule${ok === 1 ? '' : 's'}${fail ? ` (${fail} failed)` : ''}`, fail ? 'warning' : 'success'); + await refreshAutoSyncScheduleModal(); +} + +async function saveAutoSyncPlaylistScheduleSilent(playlistId, hours) { + // Like saveAutoSyncPlaylistSchedule but without toasts/refresh — caller + // batches feedback. Re-uses the existing automation row when one already + // exists for the playlist. + const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); + if (!playlist) throw new Error('playlist not found'); + const existing = _autoSyncScheduleState.playlistSchedules[playlistId]; + const payload = { + name: `Auto-Sync: ${playlist.name}`, + trigger_type: 'schedule', + trigger_config: autoSyncTriggerForHours(hours), + action_type: 'playlist_pipeline', + action_config: { playlist_id: String(playlistId), all: false }, + then_actions: [], + group_name: 'Playlist Auto-Sync', + owned_by: 'auto_sync', + }; + const res = await fetch(existing ? `/api/automations/${existing.automation_id}` : '/api/automations', { + method: existing ? 'PUT' : 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (!res.ok || data.error) throw new Error(data.error || 'Failed'); + return data; +} + +function populateAutoSyncHistoryList(root = document) { + const list = root.querySelector('.auto-sync-history-list'); + if (!list) return; + const allHistory = Array.isArray(_autoSyncScheduleState.runHistory) ? _autoSyncScheduleState.runHistory : []; + const total = _autoSyncScheduleState.runHistoryTotal || 0; + const filter = _autoSyncHistoryFilter || 'all'; + const history = allHistory.filter(h => { + if (filter === 'error') return h.status === 'error' || h.status === 'skipped'; + if (filter === 'completed') return h.status === 'completed' || h.status === 'finished'; + return true; + }); + list.innerHTML = ''; + list.dataset.renderer = 'dom-cards'; + list.dataset.renderedCount = '0'; + if (filter !== 'all' && !history.length) { + const note = document.createElement('div'); + note.className = 'auto-sync-history-empty'; + const strong = document.createElement('strong'); + strong.textContent = filter === 'error' ? 'No failed runs in the loaded window' : 'No completed runs in the loaded window'; + const small = document.createElement('span'); + small.textContent = 'Switch filters or load more history.'; + note.append(strong, small); + list.appendChild(note); + return; + } + history.forEach((entry, index) => { + try { + list.appendChild(createAutoSyncHistoryEntryElement(entry, index)); + } catch (err) { + list.appendChild(createAutoSyncHistoryErrorElement(entry, index, err)); + } + }); + const renderedCount = list.querySelectorAll('.auto-sync-history-entry').length; + list.dataset.renderedCount = String(renderedCount); + if (history.length && !renderedCount) { + list.appendChild(createAutoSyncHistoryListFallback(history.length)); + } + if (total > history.length) { + const totalEl = document.createElement('div'); + totalEl.className = 'auto-sync-history-total'; + totalEl.textContent = `Showing ${history.length} of ${total} runs`; + list.appendChild(totalEl); + } +} + +function createAutoSyncHistoryListFallback(count) { + const fallback = document.createElement('div'); + fallback.className = 'auto-sync-history-empty'; + const title = document.createElement('strong'); + title.textContent = 'Run history could not render'; + const detail = document.createElement('span'); + detail.textContent = `${count} playlist pipeline run${count === 1 ? '' : 's'} loaded, but the card renderer did not complete. Refresh the page to reload the latest Auto-Sync assets.`; + fallback.append(title, detail); + return fallback; +} + +function createAutoSyncHistoryErrorElement(entry, index, err) { + const card = document.createElement('article'); + card.className = 'auto-sync-history-entry auto-sync-history-entry-error'; + const row = document.createElement('div'); + row.className = 'auto-sync-history-row'; + const head = document.createElement('div'); + head.className = 'auto-sync-history-card-head'; + const titleBlock = document.createElement('div'); + titleBlock.className = 'auto-sync-history-title-block'; + const title = document.createElement('div'); + title.className = 'auto-sync-history-title-row'; + const dot = document.createElement('span'); + dot.className = 'auto-sync-card-status-dot disabled'; + const name = document.createElement('strong'); + name.textContent = entry?.playlist_name || `Run #${entry?.id || index + 1}`; + const badge = document.createElement('span'); + badge.className = 'auto-sync-history-status error'; + badge.textContent = 'Render error'; + title.append(dot, name, badge); + const summary = document.createElement('small'); + summary.textContent = err?.message || 'This run history row could not be rendered.'; + titleBlock.append(title, summary); + head.appendChild(titleBlock); + row.appendChild(head); + card.appendChild(row); + return card; +} + +function createAutoSyncHistoryEntryElement(entry, index = 0) { + entry = autoSyncNormalizeHistoryEntry(entry, index); + const status = entry.status || 'completed'; + const before = entry.before_json || {}; + const after = entry.after_json || {}; + const result = entry.result_json || {}; + const started = entry.started_at ? _autoTimeAgo(entry.started_at) : ''; + const duration = entry.duration_seconds ? autoSyncDurationLabel(entry.duration_seconds) : ''; + const trackDelta = autoSyncDelta(after.track_count, before.track_count); + const discoveredDelta = autoSyncDelta(after.discovered_count, before.discovered_count); + const wishlistDelta = autoSyncDelta(after.wishlisted_count, before.wishlisted_count); + const libraryDelta = autoSyncDelta(after.in_library_count, before.in_library_count); + const entryId = `auto-sync-history-${entry.id}`; + const playlistName = entry.playlist_name || after.name || before.name || `Playlist #${entry.playlist_id || 'unknown'}`; + const triggerSource = entry.trigger_source || 'pipeline'; + + const card = document.createElement('article'); + card.className = `auto-sync-history-entry ${status === 'completed' || status === 'finished' ? '' : 'auto-sync-history-entry-' + status}`.trim(); + card.id = `${entryId}-card`; + card.dataset.historyEntry = entryId; + + const row = document.createElement('div'); + row.className = 'auto-sync-history-row'; + row.setAttribute('role', 'button'); + row.tabIndex = 0; + row.setAttribute('aria-expanded', 'false'); + row.setAttribute('aria-controls', entryId); + row.dataset.historyToggle = entryId; + + const dot = document.createElement('span'); + dot.className = `auto-sync-card-status-dot ${autoSyncHistoryStatusClass(status)}`; + + const info = document.createElement('div'); + info.className = 'auto-sync-history-info'; + + const name = document.createElement('div'); + name.className = 'auto-sync-history-name'; + name.textContent = playlistName; + + const flow = document.createElement('div'); + flow.className = 'auto-sync-history-flow'; + autoSyncAppendFlowChip(flow, triggerSource, 'flow-trigger'); + autoSyncAppendFlowArrow(flow); + autoSyncAppendFlowChip(flow, 'Refresh', 'flow-action'); + autoSyncAppendFlowArrow(flow); + autoSyncAppendFlowChip(flow, 'Discover', 'flow-action'); + autoSyncAppendFlowArrow(flow); + autoSyncAppendFlowChip(flow, 'Sync + wishlist', 'flow-notify'); + + const metaRow = document.createElement('div'); + metaRow.className = 'auto-sync-history-meta-inline'; + const statusBadge = document.createElement('span'); + statusBadge.className = `auto-sync-history-status ${status}`; + statusBadge.textContent = autoSyncHistoryStatusLabel(status); + metaRow.appendChild(statusBadge); + if (started) { + const t = document.createElement('span'); + t.className = 'auto-sync-history-time'; + t.textContent = started; + metaRow.appendChild(t); + } + if (duration) { + const d = document.createElement('span'); + d.className = 'auto-sync-history-duration'; + d.textContent = duration; + metaRow.appendChild(d); + } + const trackChip = document.createElement('span'); + const trackClass = trackDelta > 0 ? 'pos' : trackDelta < 0 ? 'neg' : 'zero'; + trackChip.className = `auto-sync-history-delta ${trackClass}`; + trackChip.textContent = autoSyncDeltaLabel(after.track_count, trackDelta, 'tracks'); + metaRow.appendChild(trackChip); + + info.append(name, flow, metaRow); + + const actions = document.createElement('div'); + actions.className = 'auto-sync-history-actions'; + const expand = document.createElement('button'); + expand.type = 'button'; + expand.className = 'auto-sync-history-expand-btn'; + expand.dataset.historyToggleButton = entryId; + expand.setAttribute('aria-label', 'Toggle details'); + expand.innerHTML = ''; + actions.appendChild(expand); + + row.append(dot, info, actions); + + const detail = document.createElement('div'); + detail.id = entryId; + detail.className = 'auto-sync-history-detail'; + detail.innerHTML = autoSyncHistoryDetailHtml(entry, before, after, result, { trackDelta, discoveredDelta, wishlistDelta, libraryDelta }); + + card.append(row, detail); + return card; +} + +function autoSyncDeltaLabel(after, delta, unit) { + const a = parseInt(after, 10) || 0; + if (!delta) return `${a} ${unit}`; + const sign = delta > 0 ? '+' : ''; + return `${a} ${unit} (${sign}${delta})`; +} + +function autoSyncAppendFlowChip(parent, text, className) { + const span = document.createElement('span'); + span.className = className; + span.textContent = text; + parent.appendChild(span); +} + +function autoSyncAppendFlowArrow(parent) { + const span = document.createElement('span'); + span.className = 'flow-arrow'; + span.textContent = '->'; + parent.appendChild(span); +} + +function autoSyncNormalizeHistoryEntry(entry, index) { + if (!entry || typeof entry !== 'object') { + return { + id: `unknown-${index}`, + status: 'completed', + playlist_name: 'Playlist pipeline run', + trigger_source: 'pipeline', + summary: 'Run history entry did not include detailed metadata.', + before_json: {}, + after_json: {}, + result_json: {}, + }; + } + return { + ...entry, + id: entry.id ?? `history-${index}`, + before_json: autoSyncParseHistoryObject(entry.before_json), + after_json: autoSyncParseHistoryObject(entry.after_json), + result_json: autoSyncParseHistoryObject(entry.result_json), + }; +} + +function bindAutoSyncHistoryCardInteractions(root = document) { + root.querySelectorAll('[data-history-toggle]').forEach(row => { + const entryId = row.dataset.historyToggle; + row.addEventListener('click', () => autoSyncToggleHistoryEntry(entryId)); + row.addEventListener('keydown', event => autoSyncHistoryEntryKeydown(event, entryId)); + }); + root.querySelectorAll('[data-history-toggle-button]').forEach(button => { + const entryId = button.dataset.historyToggleButton; + button.addEventListener('click', event => { + event.stopPropagation(); + autoSyncToggleHistoryEntry(entryId); + }); + }); +} + +function autoSyncParseHistoryObject(value) { + if (!value) return {}; + if (typeof value === 'object') return value; + if (typeof value !== 'string') return {}; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch (_err) { + return {}; + } +} + +function autoSyncHistoryFallbackSummary(before, after, status) { + const beforeTracks = parseInt(before.track_count, 10) || 0; + const afterTracks = parseInt(after.track_count, 10) || 0; + return `${autoSyncHistoryStatusLabel(status)} | ${beforeTracks} -> ${afterTracks} tracks`; +} + +function autoSyncToggleHistoryEntry(entryId) { + const el = document.getElementById(entryId); + const card = document.getElementById(`${entryId}-card`); + const row = card?.querySelector('.auto-sync-history-row'); + if (!el) return; + const expanded = el.classList.toggle('expanded'); + if (card) card.classList.toggle('expanded', expanded); + if (row) row.setAttribute('aria-expanded', expanded ? 'true' : 'false'); +} + +function autoSyncHistoryEntryKeydown(event, entryId) { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + autoSyncToggleHistoryEntry(entryId); +} + +function autoSyncHistoryStatusLabel(status) { + if (status === 'completed' || status === 'finished') return 'Completed'; + if (status === 'error') return 'Error'; + if (status === 'skipped') return 'Skipped'; + return status || 'Run'; +} + +function autoSyncHistoryStatusClass(status) { + if (status === 'completed' || status === 'finished') return 'enabled'; + if (status === 'error' || status === 'skipped') return 'disabled'; + return 'enabled'; +} + +function autoSyncDurationLabel(seconds) { + const total = Math.max(0, Math.round(parseFloat(seconds) || 0)); + if (total < 60) return `${total}s`; + const mins = Math.floor(total / 60); + const secs = total % 60; + return `${mins}m ${secs}s`; +} + +function autoSyncDelta(after, before) { + const a = parseInt(after, 10) || 0; + const b = parseInt(before, 10) || 0; + return a - b; +} + +function autoSyncHistoryStatHtml(label, before, after, delta) { + const beforeValue = parseInt(before, 10) || 0; + const afterValue = parseInt(after, 10) || 0; + const deltaText = delta ? ` (${delta > 0 ? '+' : ''}${delta})` : ''; + return ` +
+ ${_esc(label)} + ${beforeValue} -> ${afterValue}${_esc(deltaText)} +
+ `; +} + +function autoSyncHistoryPreviewPill(label, before, after, delta) { + return `${_esc(autoSyncHistoryPreviewText(label, before, after, delta))}`; +} + +function autoSyncHistoryPreviewText(label, before, after, delta) { + const beforeValue = parseInt(before, 10) || 0; + const afterValue = parseInt(after, 10) || 0; + const deltaText = delta ? ` ${delta > 0 ? '+' : ''}${delta}` : ''; + return `${label} ${beforeValue}->${afterValue}${deltaText}`; +} + +function autoSyncHistoryResultPill(label, value) { + if (value === undefined || value === null || value === '') return ''; + return `${_esc(label)}: ${_esc(String(value))}`; +} + +function autoSyncHistoryDetailHtml(entry, before, after, result, deltas) { + const stats = [ + ['Tracks', before.track_count, after.track_count, deltas.trackDelta], + ['Discovered', before.discovered_count, after.discovered_count, deltas.discoveredDelta], + ['Wishlisted', before.wishlisted_count, after.wishlisted_count, deltas.wishlistDelta], + ['In library', before.in_library_count, after.in_library_count, deltas.libraryDelta], + ]; + const statsHtml = stats.map(([label, b, a, d]) => autoSyncHistoryStatCardHtml(label, b, a, d)).join(''); + const factsHtml = [ + ['Started', autoSyncFormatDateTime(entry.started_at)], + ['Finished', autoSyncFormatDateTime(entry.finished_at)], + ['Duration', entry.duration_seconds ? autoSyncDurationLabel(entry.duration_seconds) : '—'], + ['Source', entry.source || after.source || before.source || '—'], + ].map(([k, v]) => `
${_esc(k)}${_esc(autoSyncValueLabel(v))}
`).join(''); + // `tracks_discovered` was a status STRING (e.g. "completed"), not a + // count — kept it out of the pills so the panel doesn't show a + // confusing "Discovered: completed" chip. Same data is already + // surfaced as a before/after stat card above. + const resultPills = [ + ['Refreshed', result.playlists_refreshed], + ['Synced', result.tracks_synced], + ['Skipped', result.sync_skipped], + ['Wishlisted', result.wishlist_queued], + ].filter(([, v]) => v !== undefined && v !== null && v !== '') + .map(([k, v]) => `${_esc(k)}${_esc(String(v))}`).join(''); + const errorBlock = result.error ? `
${_esc(result.error)}
` : ''; + const logsHtml = autoSyncHistoryLogsCompactHtml(entry.log_lines); + const playlistId = entry.playlist_id || after.playlist_id || before.playlist_id || ''; + const playlistName = entry.playlist_name || after.name || before.name || ''; + const runAgainHtml = playlistId + ? `
+ +
` + : ''; + return ` +
${statsHtml}
+
${factsHtml}
+ ${resultPills ? `
${resultPills}
` : ''} + ${runAgainHtml} + ${errorBlock} + ${logsHtml} + `; +} + +function autoSyncHistoryStatCardHtml(label, before, after, delta) { + const a = parseInt(after, 10) || 0; + const b = parseInt(before, 10) || 0; + const deltaClass = delta > 0 ? 'pos' : delta < 0 ? 'neg' : 'zero'; + const deltaText = delta ? `${delta > 0 ? '+' : ''}${delta}` : ''; + return ` +
+
${_esc(label)}
+
+ ${b} + + ${a} + ${deltaText ? `${deltaText}` : ''} +
+
+ `; +} + +function autoSyncHistoryLogsCompactHtml(logLines) { + if (!Array.isArray(logLines) || !logLines.length) return ''; + const lines = logLines.slice(-20).map(line => { + const text = typeof line === 'string' ? line : (line.message || line.log_line || JSON.stringify(line)); + const type = typeof line === 'object' ? (line.type || line.log_type || 'info') : 'info'; + return `
${_esc(text)}
`; + }).join(''); + return `
${lines}
`; +} + +function autoSyncHistoryFactHtml(label, value) { + return ` +
+ ${_esc(label)} + ${_esc(autoSyncValueLabel(value))} +
+ `; +} + +function autoSyncHistorySnapshotHtml(title, snapshot) { + const fields = [ + ['Name', snapshot.name], + ['Source', snapshot.source], + ['Tracks', snapshot.track_count], + ['Discovered', snapshot.discovered_count], + ['Wishlisted', snapshot.wishlisted_count], + ['In library', snapshot.in_library_count], + ]; + return ` +
+
${_esc(title)}
+
+ ${fields.map(([label, value]) => autoSyncHistoryFactHtml(label, value)).join('')} +
+
+ `; +} + +function autoSyncHistoryObjectHtml(title, obj, options = {}) { + if (!obj || typeof obj !== 'object') return ''; + const entries = Object.entries(obj) + .filter(([key, value]) => !(options.skipPrivate && key.startsWith('_')) && value !== undefined && value !== null && value !== '') + .slice(0, 24); + if (!entries.length) return ''; + return ` +
+
${_esc(title)}
+
+ ${entries.map(([key, value]) => ` +
+ ${_esc(autoSyncHumanizeKey(key))} + ${_esc(autoSyncValueLabel(value))} +
+ `).join('')} +
+
+ `; +} + +function autoSyncHistoryLogsHtml(logLines) { + if (!Array.isArray(logLines) || !logLines.length) return ''; + return ` +
+
Run Log
+
+ ${logLines.slice(-12).map(line => { + const text = typeof line === 'string' ? line : (line.message || line.log_line || JSON.stringify(line)); + const type = typeof line === 'object' ? (line.type || line.log_type || 'info') : 'info'; + return `
${_esc(text)}
`; + }).join('')} +
+
+ `; +} + +function autoSyncFormatDateTime(value) { + if (!value) return ''; + const ts = _autoParseUTC(value); + if (!Number.isFinite(ts)) return value; + return new Date(ts).toLocaleString(); +} + +function autoSyncHumanizeKey(key) { + return String(key || '') + .replace(/^_+/, '') + .replace(/_/g, ' ') + .replace(/\b\w/g, ch => ch.toUpperCase()); +} + +function autoSyncValueLabel(value) { + if (value === undefined || value === null || value === '') return 'Not recorded'; + if (typeof value === 'boolean') return value ? 'Yes' : 'No'; + if (Array.isArray(value)) return value.length ? value.map(autoSyncValueLabel).join(', ') : 'None'; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} + +function autoSyncAutomationCardHtml(auto, playlists) { + const cfg = auto.action_config || {}; + const playlistId = autoSyncPlaylistIdFromAutomation(auto); + const playlist = playlistId ? playlists.find(p => parseInt(p.id, 10) === playlistId) : null; + const target = cfg.all === true || cfg.all === 'true' + ? 'All refreshable mirrored playlists' + : playlist ? playlist.name : playlistId ? `Playlist #${playlistId}` : 'Custom pipeline target'; + const trigger = _autoFormatTrigger(auto.trigger_type, auto.trigger_config || {}); + const enabled = auto.enabled !== false && auto.enabled !== 0; + const next = auto.next_run ? autoSyncNextRunLabel(auto.next_run) : 'not scheduled'; + const sourceLabel = playlist ? autoSyncSourceLabel(playlist.source) : (cfg.all === true || cfg.all === 'true' ? 'All sources' : 'Pipeline'); + return ` +
+ +
+
+ ${_esc(auto.name || 'Playlist Pipeline')} +
+
+ ${_esc(trigger)} + + Playlist pipeline + + Refresh + sync +
+
+ ${enabled ? 'Enabled' : 'Disabled'} + ${_esc(sourceLabel)} + ${_esc(target)} + ${_esc(next)} +
+
+
Read only
+
+ `; +} + +function autoSyncScheduledCardHtml(playlist, schedule) { + const enabled = schedule?.enabled !== false; + const nextLabel = schedule?.next_run ? autoSyncNextRunLabel(schedule.next_run) : ''; + const isRunning = playlist.pipeline_state?.status === 'running'; + const health = autoSyncPlaylistHealth(playlist.id); + const healthClass = health.level === 'failing' ? 'failing' + : health.level === 'warning' ? 'warning' + : ''; + return ` +
+
+
+ ${health.level !== 'ok' ? `${health.level === 'failing' ? '!' : '⚠'}` : ''} + ${_esc(playlist.name)} +
+
${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks
+
+ ${_esc(autoSyncIntervalLabel(schedule?.hours || 24))} + ${nextLabel ? `${_esc(nextLabel)}` : ''} +
+
+
+ + +
+
+ `; +} + +function autoSyncPlaylistHealth(playlistId) { + // Look at the last 3 runs for this playlist in the loaded history. + // 3-in-a-row errors = failing (red dot). 1+ recent error = warning. + const history = _autoSyncScheduleState.runHistory || []; + const id = parseInt(playlistId, 10); + const recent = history + .filter(h => parseInt(h.playlist_id, 10) === id) + .slice(0, 3); + if (!recent.length) return { level: 'ok', tooltip: '' }; + const errored = recent.filter(h => h.status === 'error' || h.status === 'skipped'); + if (errored.length >= 3) { + return { level: 'failing', tooltip: `Last ${recent.length} runs failed — check Run History tab` }; + } + if (errored.length) { + return { level: 'warning', tooltip: `${errored.length} of last ${recent.length} runs failed` }; + } + return { level: 'ok', tooltip: '' }; +} + +function autoSyncNextRunLabel(nextRun) { + if (!nextRun) return ''; + const ts = _autoParseUTC(nextRun); + if (!Number.isFinite(ts)) return ''; + const diff = ts - Date.now(); + if (diff <= 0) return 'due now'; + const mins = Math.ceil(diff / 60000); + if (mins < 60) return `next in ${mins}m`; + const hours = Math.ceil(mins / 60); + if (hours < 24) return `next in ${hours}h`; + return `next in ${Math.ceil(hours / 24)}d`; +} + +function autoSyncDragStart(event) { + const playlistId = event.currentTarget?.dataset?.playlistId; + if (!playlistId) return; + _autoSyncIsDragging = true; + event.dataTransfer.setData('text/plain', playlistId); + event.dataTransfer.effectAllowed = 'move'; +} + +function autoSyncDragOver(event) { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + const col = event.currentTarget; + if (col && !col.classList.contains('drag-over')) { + col.classList.add('drag-over'); + } +} + +function autoSyncDragLeave(event) { + const col = event.currentTarget; + if (!col) return; + if (col.contains(event.relatedTarget)) return; + col.classList.remove('drag-over'); +} + +async function autoSyncDrop(event, hours) { + event.preventDefault(); + _autoSyncIsDragging = false; + const col = event.currentTarget; + if (col) col.classList.remove('drag-over'); + const playlistId = parseInt(event.dataTransfer.getData('text/plain'), 10); + if (!playlistId) return; + await saveAutoSyncPlaylistSchedule(playlistId, hours); +} + +function autoSyncDragEnd() { + _autoSyncIsDragging = false; +} + +async function saveAutoSyncPlaylistSchedule(playlistId, hours) { + const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); + if (!playlist) return; + if (!autoSyncCanSchedulePlaylist(playlist)) { + showToast('That playlist source cannot be refreshed by Auto-Sync.', 'info'); + return; + } + const existing = _autoSyncScheduleState.playlistSchedules[playlistId]; + const payload = { + name: `Auto-Sync: ${playlist.name}`, + trigger_type: 'schedule', + trigger_config: autoSyncTriggerForHours(hours), + action_type: 'playlist_pipeline', + action_config: { playlist_id: String(playlistId), all: false }, + then_actions: [], + group_name: 'Playlist Auto-Sync', + owned_by: 'auto_sync', + }; + try { + const res = await fetch(existing ? `/api/automations/${existing.automation_id}` : '/api/automations', { + method: existing ? 'PUT' : 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (!res.ok || data.error) throw new Error(data.error || 'Failed to save Auto-Sync schedule'); + showToast(`${playlist.name} scheduled every ${autoSyncBucketLabel(hours)}`, 'success'); + await refreshAutoSyncScheduleModal(); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } +} + +async function unscheduleAutoSyncPlaylist(playlistId) { + const schedule = _autoSyncScheduleState.playlistSchedules[playlistId]; + const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); + if (!schedule) return; + if (!await showConfirmDialog({ title: 'Remove Auto-Sync', message: `Remove Auto-Sync schedule for "${playlist?.name || 'this playlist'}"?` })) return; + try { + const res = await fetch(`/api/automations/${schedule.automation_id}`, { method: 'DELETE' }); + const data = await res.json(); + if (!res.ok || data.error) throw new Error(data.error || 'Failed to remove Auto-Sync schedule'); + showToast('Auto-Sync schedule removed', 'success'); + await refreshAutoSyncScheduleModal(); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } +} + +async function runAutoSyncScheduledPlaylist(playlistId) { + const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); + if (!playlist) return; + await runMirroredPlaylistPipeline(playlistId, playlist.name || `Playlist #${playlistId}`); + await refreshAutoSyncScheduleModal(); +} + +function manageAutoSyncStatusPolling() { + const overlay = document.getElementById('auto-sync-schedule-modal'); + if (!overlay) { + stopAutoSyncStatusPolling(); + return; + } + const hasRunning = _autoSyncScheduleState.playlists.some(p => p.pipeline_state?.status === 'running'); + if (!hasRunning) { + stopAutoSyncStatusPolling(); + return; + } + if (_autoSyncStatusPoller) return; + _autoSyncStatusPoller = setInterval(() => { + if (_autoSyncIsDragging) return; + refreshAutoSyncScheduleModal(); + }, 3000); +} + +function stopAutoSyncStatusPolling() { + if (!_autoSyncStatusPoller) return; + clearInterval(_autoSyncStatusPoller); + _autoSyncStatusPoller = null; +} + +async function parseMirroredPipelineResponse(res, fallbackMessage) { + const text = await res.text(); + let data = {}; + if (text) { + try { + data = JSON.parse(text); + } catch (_err) { + const detail = res.status === 404 + ? 'Auto-Sync endpoint not found. Restart the SoulSync server so the new backend routes load.' + : fallbackMessage; + throw new Error(detail); + } + } + if (!res.ok || data.error) { + throw new Error(data.error || fallbackMessage); + } + return data; +} + +async function editMirroredSourceRef(playlistId, name, source, currentRef) { + const label = (source === 'spotify_public' || source === 'youtube') + ? 'original playlist URL' + : 'original playlist ID or URL'; + const nextRef = window.prompt(`Update ${label} for "${name}"`, currentRef || ''); + if (nextRef === null) return; + const trimmed = nextRef.trim(); + if (!trimmed) { + showToast('Source link or ID is required', 'error'); + return; + } + + try { + const res = await fetch(`/api/mirrored-playlists/${playlistId}/source-ref`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source_ref: trimmed }) + }); + const data = await res.json(); + if (!res.ok || data.error) { + throw new Error(data.error || 'Failed to update source reference'); + } + showToast(`Updated source for ${name}`, 'success'); + loadMirroredPlaylists(); + const openModal = document.getElementById('mirrored-track-modal'); + if (openModal) { + closeMirroredModal(); + openMirroredPlaylistModal(playlistId); + } + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } +} + +function applyMirroredPipelineState(playlistId, state) { + const hash = `mirrored_${playlistId}`; + const existing = youtubePlaylistStates[hash] || {}; + const status = state.status || 'idle'; + let phase = existing.phase; + if (status === 'running') phase = 'pipeline_running'; + else if (status === 'finished') phase = 'pipeline_complete'; + else if (status === 'error' || status === 'skipped') phase = 'pipeline_error'; + + youtubePlaylistStates[hash] = { + ...existing, + phase, + pipeline_status: status, + pipeline_progress: state.progress || 0, + pipeline_phase: state.phase || '', + pipeline_error: state.error || '', + pipeline_log: state.log || [], + pipeline_result: state.result || null, + }; + + updateMirroredCardPhase(hash, phase); +} + +async function runMirroredPlaylistPipeline(playlistId, name) { + try { + const res = await fetch(`/api/mirrored-playlists/${playlistId}/pipeline/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + const data = await parseMirroredPipelineResponse(res, 'Failed to start Auto-Sync'); + applyMirroredPipelineState(playlistId, data.state || { status: 'running', progress: 0, phase: 'Starting pipeline...' }); + showToast(`Auto-Sync started for ${name}`, 'success'); + _autoSyncScheduleState.playlists = _autoSyncScheduleState.playlists.map(p => ( + parseInt(p.id, 10) === parseInt(playlistId, 10) + ? { ...p, pipeline_state: data.state || { status: 'running', progress: 0, phase: 'Starting pipeline...' } } + : p + )); + renderAutoSyncScheduleModal(); + manageAutoSyncStatusPolling(); + pollMirroredPipelineStatus(playlistId, name); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } +} + +function pollMirroredPipelineStatus(playlistId, name) { + const key = `mirrored_${playlistId}`; + if (mirroredPipelinePollers[key]) clearInterval(mirroredPipelinePollers[key]); + + const tick = async () => { + try { + const res = await fetch(`/api/mirrored-playlists/${playlistId}/pipeline/status`); + const state = await parseMirroredPipelineResponse(res, 'Failed to read Auto-Sync status'); + applyMirroredPipelineState(playlistId, state); + + if (state.status === 'finished') { + clearInterval(mirroredPipelinePollers[key]); + delete mirroredPipelinePollers[key]; + showToast(`Auto-Sync complete for ${name}`, 'success'); + loadMirroredPlaylists(); + refreshAutoSyncScheduleModal(); + } else if (state.status === 'error' || state.status === 'skipped') { + clearInterval(mirroredPipelinePollers[key]); + delete mirroredPipelinePollers[key]; + showToast(state.error || `Pipeline stopped for ${name}`, 'error'); + loadMirroredPlaylists(); + refreshAutoSyncScheduleModal(); + } else if (state.status === 'idle') { + clearInterval(mirroredPipelinePollers[key]); + delete mirroredPipelinePollers[key]; + } + } catch (err) { + clearInterval(mirroredPipelinePollers[key]); + delete mirroredPipelinePollers[key]; + showToast(`Pipeline status error: ${err.message}`, 'error'); + } + }; + + tick(); + mirroredPipelinePollers[key] = setInterval(tick, 2500); +} diff --git a/webui/static/helper.js b/webui/static/helper.js index 3f9568f6..6ec49872 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3413,6 +3413,39 @@ 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.3': [ + { unreleased: true }, + { title: 'Library: Enhanced / Standard view toggle now sticks per browser', desc: 'the artist detail page used to revert to Standard view every time you clicked into a new artist — flipping to Enhanced was a per-click thing with no memory. now the choice is persisted in localStorage and reapplied automatically on every artist open. survives page reloads too. non-admins (who can\'t toggle Enhanced anyway) are unaffected; admins who never touched the Enhanced view stay on Standard since the saved value defaults to it.' }, + { title: 'Fix popup: manual matches survive Playlist Pipeline runs', desc: 'manually mapping a mirrored-playlist track via the Fix popup (search or MBID) saved correctly and the download went through, but the next Playlist Pipeline run kept reverting the match — the track flipped back to "Provider Changed" and you had to map it again. three things were ganging up on it: the manual-fix save always stamped `provider: \'spotify\'` even when the match came from MusicBrainz / iTunes / Deezer, so prepare-discovery saw a provider mismatch on the next run; the discovery worker also re-queued any matched_data that lacked `track_number` / `album.id` / `release_date` for re-discovery, and Fix-popup saves never carry those fields by design (search results don\'t include track_number, MBID lookups don\'t include album.id) — so manual fixes always looked "incomplete" and got overwritten; and the prepare-discovery provider check didn\'t honour the `manual_match` flag at all. all three patched — manual matches now stamp the actual source, the worker short-circuits on `manual_match` before the incomplete-data branch, and prepare-discovery treats manual fixes as cached regardless of provider drift.' }, + { title: 'Fix popup: artist + track fields no longer surface unrelated covers', desc: 'separate artist / track inputs in the Fix popup used to dump both into a bare MusicBrainz keyword query — and MB\'s scorer heavily favours title matches, so searching "Coffee Break" + "Zeds Dead" surfaced random "Coffee Break" tracks by other artists ahead of the canonical Zeds Dead one. fields-mode now uses a field-scoped Lucene query that actually anchors the artist, with the old bare query kept as a fallback for diacritic mismatches like "Bjork" vs "Björk" where strict phrase match misses. results also stable-prefer entries with known track length so the canonical 3:04 sibling sits above the 0:00 duplicate.' }, + { title: 'Groundwork: unified playlist source layer', desc: 'first slice of a refactor that\'ll let ListenBrainz, Last.fm radio, and SoulSync Discovery playlists live as Sync-page tabs alongside Spotify / Tidal / Qobuz / YouTube — so they can be mirrored + scheduled like the rest. this commit adds the shared adapter layer all those sources will plug into; no UI changes yet. nothing to do on your end.' }, + { title: 'Auto-Sync refresh now routes through the unified source layer', desc: 'follow-up to the groundwork above. the mirrored-playlist auto-refresh handler used to have a ~190-line if/elif chain branching per source (one branch each for Spotify, Spotify public, Deezer, Tidal, YouTube). now it asks the source registry for the right adapter and calls one refresh method. behavior identical — same matched_data, same Tidal-skip-on-no-auth log, same Spotify-public-prefers-authed-API fallback. unlocks ListenBrainz / Last.fm / SoulSync Discovery as future Sync-page mirror sources without a fresh elif branch each time.' }, + { title: 'Discovery folded into the unified source contract', desc: 'next slice of the groundwork. each playlist source can now answer one extra question — "match these raw tracks against Spotify / iTunes" — through the same adapter interface. Spotify / Tidal / Qobuz / YouTube / Deezer / Spotify-public / iTunes-link / SoulSync-Discovery all answer trivially (their tracks already have provider IDs); ListenBrainz + Last.fm run the matching engine. mirror-refresh now calls this automatically when a source returns MB-metadata-only tracks, so when ListenBrainz becomes a Sync-page tab next commit, its mirrors land already discovered + ready to sync — no separate Discover-page round-trip needed.' }, + { title: 'ListenBrainz Sync tab', desc: 'new ListenBrainz tab on the Sync page, between Beatport and Import. lists your "For You" / "My Playlists" / "Collaborative" LB playlists in one place. clicking a card kicks off the same discovery → sync → mirror flow you already get from the Discover page (no duplicate UI behind the scenes, just a new entry point). once mirrored, LB playlists participate in Auto-Sync schedules + pipeline automations like any other source. needs ListenBrainz connected in Settings → Connections.', page: 'sync' }, + { title: 'Last.fm Radio Sync tab', desc: 'sibling to the ListenBrainz tab — lists your generated Last.fm Radio playlists alongside the rest of the Sync sources. same discovery → mirror flow under the hood, just a different entry point. new Last.fm radios are still generated from the Discover page by picking a seed track; this tab is for syncing existing ones. mirrors auto-trim when Last.fm Radio cache rotates so old radios don\'t pile up.', page: 'sync' }, + { title: 'SoulSync Discovery Sync tab', desc: 'last of the unified-tab trio. surfaces your personalized SoulSync Discovery playlists (decade mixes, hidden gems, popular picks, daily mixes, discovery shuffle, etc.) on the Sync page. clicking a card regenerates the playlist + mirrors it under a stable synthetic id, so the same mirror updates in place every Auto-Sync refresh. tracks come out already matched against Spotify / iTunes / Deezer so there\'s no discovery hop — straight to download / sync.', page: 'sync' }, + { title: 'Fix: album-bundle downloads all landing as track 1', desc: 'soulseek album-bundle downloads (and any other untagged release-staging path) were importing every track with track_number=1. the staging-file reader was using the auto-import\'s filename extractor that defaults to 1 when no NN- prefix is present in the filename — so for albums like Ryoto\'s "Cha-La Head-Cha-La" where slskd hands you bare titles, every file got "track 1" stamped on it. now the staging path uses a strict extractor that returns 0 when it can\'t see an explicit prefix, so the downstream resolver correctly falls through to the authoritative Spotify metadata and the right track numbers land in the library.' }, + { title: 'Wishlist albums-cycle: one album-bundle search per album', desc: 'auto-wishlist runs in two cycles — albums + singles. previously the albums cycle dumped every missing track from every album into one big batch and ran a separate per-track Soulseek / Prowlarr search for each (~50 searches for a typical scan). now the albums cycle splits the wishlist into per-album sub-batches at submission time, and each one engages the existing slskd / torrent / usenet album-bundle release-first flow with that album\'s context. tracks without resolvable album metadata stay on the classic per-track residual batch so nothing falls off. singles cycle is unchanged.' }, + { title: 'Wishlist cycle toggles once per run, not per sub-batch', desc: 'follow-up to the per-album split above. with N sub-batches per wishlist run, the cycle toggle (albums → singles or vice versa) was firing once per sub-batch as each one completed — so the cycle could flip mid-run and the automation completion event got emitted multiple times. each wishlist invocation now stamps every sub-batch with a shared run id; the completion handler waits until the LAST sibling finishes before toggling the cycle + emitting the run-complete event. cleaner state machine, future-proofs frontend grouping of sub-batches under one logical run.' }, + { title: 'Wishlist download modal: keeps tracking across every album', desc: 'with the per-album sub-batch split, the modal\'s polling kept hitting the original batch id only and went stale as soon as the first sub-batch finished — subsequent albums downloaded fine but the modal\'s rows stopped updating. backend now merges every sibling sub-batch\'s tasks + analysis results into the response under the original batch id (re-indexes track positions globally so the table doesn\'t collide on row keys, picks whichever bundle is currently active, aggregates phase across siblings so the moment ANY sibling reaches the task stage the modal renders task rows — earlier rule waited for the slowest sibling and left the modal looking frozen while both albums were already downloading on slskd). frontend untouched — the modal sees one unified view of the whole run.' }, + ], + '2.6.2': [ + { date: 'May 24, 2026 — 2.6.2 release' }, + { title: 'Fix: songs stuck in quarantine loop when picking a different candidate', desc: 'when a track failed AcoustID verification and got quarantined, opening the candidates modal and manually picking a different file would just re-quarantine it — the manual pick path ran full AcoustID verification with no bypass, so if the alternate file disagreed with AcoustID\'s stored metadata too (common for live versions, remasters, regional title differences) the file landed right back in quarantine. user got stuck in the loop. manual picks via the candidates modal now skip AcoustID for that one post-process pass (matching what the Approve button already does for restored quarantine files). integrity and bit-depth gates still run because those check the new file\'s actual condition, not its identity. closes #701.' }, + { title: 'Fix: whole-album downloads ending up "failed" with empty queues', desc: 'the Soulseek album-bundle path routes whole-album downloads through a private staging dir, then per-track workers claim the staged files. when slskd files arrived without ID3 tags, the staging cache fell back to filename stems like "Artist - Album - 03 - Title" — too noisy to clear the title-similarity threshold against the clean Spotify title, so every track went not_found and the batch ended failed even with all files on disk. staging match now pulls the trailing-title segment when a bare track-number is present between " - " delimiters, so slskd filename patterns match cleanly. closes #700.' }, + { title: 'Fix: album tracks getting requeued as singles by the wishlist', desc: 'downstream of the album-bundle fix above. failed album-batch tracks were going to the wishlist with `source_type=\'playlist\'` hardcoded, and a couple of fallback paths were stamping `album_type=\'single\'` on the stored album dict. on requeue the path builder saw single → routed to the Singles tree even though the track belonged to an album (running Reorganize would patch it because the DB still knew). album batches now carry `source_type=\'album\'`, source-context preserves `album_context` / `artist_context`, and the non-dict-album + slskd-reconstruction fallbacks default to `album` instead of lying with `single`. closes #698.' }, + { title: 'Fix: Redownload Album button on the enhanced artist view was dead', desc: 'the Redownload button on the enhanced artist-page album row was throwing a silent ReferenceError on click — no popup, no toast, no log line, button just did nothing. the underlying function was lost when script.js got split into 17 domain modules and nothing caught it for a while. restored. closes #699.' }, + { title: 'iTunes / Apple Music link import', desc: 'new iTunes Link tab on the Sync page, between Deezer Link and YouTube. paste an Apple Music album, track, or playlist URL and SoulSync pulls the tracklist, runs it through the same discovery → sync → download flow as the other link tabs. handles the new Apple Music SPA token shape — token gets scraped from the JS bundle on first use and cached for 6 hours, with a 401 retry that refetches if Apple rotates mid-session.' }, + { title: 'Auto-Sync: bulk schedule by source + custom interval columns', desc: 'each source group in the sidebar gets a Bulk button — opens a popover that lets you schedule every playlist in that group at a chosen interval in one click (1h / 2h / 4h / 8h / 12h / 16h / 24h / 48h / 72h / weekly) or "Custom interval…" for an arbitrary number of hours. also includes "Unschedule all" to clear a source\'s schedules. on the board itself, a schedule with a non-standard interval (e.g. 6h or 36h, created via Automations page or the custom prompt) now renders as its own dashed-border column instead of disappearing because it didn\'t match a hardcoded bucket.' }, + { title: 'Auto-Sync manager: filter, failure indicators, history filters + load more', desc: 'a handful of UX additions on the Playlist Auto-Sync modal. sidebar gets a "Filter playlists…" search input so you can narrow down a long mirrored-playlist list. scheduled cards on the board now show a red ! badge when the last three pipeline runs all failed (yellow ⚠ if at least one of the last few failed) so chronically broken schedules surface visually instead of getting buried in the run-history tab. run history tab title shows a red error count when there are failed runs in the loaded window. the history tab itself gains All / Errors / Completed filter pills, a "Load more" footer that pulls another 50 entries, and a "Run pipeline again" button inside the expanded detail panel so you can re-trigger a specific playlist without leaving the modal. also dropped the "Discovered: completed" pill — `tracks_discovered` was a status string, not a count, and the same data is already in the before/after stats grid above.' }, + { title: 'Auto-Sync manager redesigned to match the rest of the app', desc: 'the Auto-Sync modal got a top-to-bottom restyle so it stops feeling like it lives in a different app. modal shell now uses the standard SoulSync gradient + accent-tinted border + 20px radius, the KPI summary became inset stat tiles, the live pipeline monitor is auto-fill instead of a fixed 4-col grid, tabs are now underline-style instead of pill buttons, and the schedule board sidebar/columns use the dense dark-card pattern that Automations + Library pages already use. modal also fills more of the screen since there was a lot of unused real estate. run history cards got the same treatment — slim horizontal row matching `.automation-card` (status dot + name + flow chips + meta row), and the expanded panel uses the same stats-grid / log-section structure as the Automations run-history modal.' }, + { title: 'Fix: Auto-Sync manager hung forever on "Loading schedule..."', desc: 'a regression while wiring up the new "in library" count: the join used `COLLATE NOCASE` on `tracks.title` and `artists.name`, which can\'t use the indexes those columns have. on a 300k-track library each playlist took ~18s. with 30 mirrored playlists the modal\'s `/api/mirrored-playlists` call would never come back. switched to case-sensitive equality so SQLite uses `idx_artists_name` + `idx_tracks_title` (6ms per playlist). misses pure-case-different matches but Spotify names are canonical against library imports, so it\'s a rounding-error tradeoff for the 3000× speedup.' }, + { title: 'Fix: Auto-Sync run history `in library` always showed 0', desc: 'the SQL query was referencing `tracks.artist` — a column that hasn\'t existed since the schema went relational. `tracks` has `artist_id` (FK to `artists`), so the query threw "no such column", got swallowed by the surrounding try/except, and the count silently defaulted to 0 on every playlist. rewired the join through `artists` so the count actually reflects how many mirrored tracks already live in your library.' }, + { title: 'Auto-Sync modal opens faster (1.5s → ~280ms)', desc: 'opening the Auto-Sync manager used to spend ~1.5s in `/api/mirrored-playlists` because each mirrored playlist row triggered its own `get_mirrored_playlist_status_counts` call, and each of those opened a fresh SQLite connection with PRAGMA setup. new `get_all_mirrored_playlist_status_counts(profile_id)` does the totals / discovered / wishlisted / in-library counts across every playlist for the active profile in 4 batched `GROUP BY` queries over one connection. about 5× faster on a 30-playlist account.' }, + { title: 'Playlist sync is way faster on partial-match playlists', desc: 'syncing a playlist where most tracks weren\'t in the library used to take forever — a 30-track playlist with only 9 matches was burning 4+ minutes because every unmatched track ran the full title-variation × artist-variation SQL grid against the tracks table (~30 SQL queries per missed track). cache only covered matches, so misses re-paid the cost every sync. sync now uses a per-artist track pool that fills in lazily — only tracks that miss the sync match cache trigger a one-time fetch of their artist\'s library tracks, and later misses for the same artist reuse the in-memory list. playlists where every track is already cached pay zero pool cost (no upfront delay). benefits every sync entry point — manual, auto-sync, the playlist_pipeline automation action, and the Sync All button.' }, + { title: 'Fix: Auto-Sync "next in 8h" timezone bug', desc: 'scheduled Auto-Sync playlists were all showing "next in 8h" regardless of the interval — Every 1 hour, Every 1 day, anything. backend stores next-run as a naive UTC string and the frontend was parsing it as local time, which on Pacific time offset the displayed countdown by ~8 hours. Auto-Sync now uses the existing UTC-aware parser the rest of the Automations page already uses. as a separate correctness fix, the automation update endpoint also now blanks the stored next-run whenever the trigger type or trigger config changes, so the engine recomputes from scratch instead of preserving a leftover timestamp from the previous schedule.' }, + { title: 'Auto-Sync modal restyled to your accent color', desc: 'the Playlist Auto-Sync manager now picks up your chosen accent (the same one used everywhere else in the app) instead of the hardcoded sky-blue palette it shipped with. tabs, drop zones, scheduled-playlist chips, scrollbars, and the modal glow all follow the accent theme. drop targets also light up clearly while dragging.' }, + ], '2.6.1': [ { date: 'May 24, 2026 — 2.6.1 release' }, { title: 'React Import page polish', desc: 'Import now runs through the React route stack with album, singles, and auto-import tabs plus the state fixes needed for reliable Vite builds.' }, @@ -3506,11 +3539,22 @@ const WHATS_NEW = { // Section shape: { title, description, features: [bullet strings], // usage_note?: 'optional hint shown at the bottom' } const VERSION_MODAL_SECTIONS = [ + { + title: "iTunes / Apple Music Link Import", + description: "new iTunes Link tab on the Sync page. paste an Apple Music album, track, or playlist URL and SoulSync pulls the tracklist, runs it through discovery, and lets you sync or download like any other link source.", + features: [ + "new iTunes Link tab on the Sync page, sitting between Deezer Link and YouTube", + "accepts album, track, and playlist URLs from music.apple.com or iTunes", + "tracklist runs through the same discovery → sync → download pipeline as Deezer Link / YouTube", + "handles the current Apple Music SPA token flow — token is scraped from the JS bundle on first use, cached for 6 hours, and refetched automatically on 401 if Apple rotates", + ], + usage_note: "Sync → iTunes Link → paste URL → Load", + }, { title: "Qobuz Playlist Sync", description: "Qobuz joins Tidal and Deezer as a first-class playlist sync source on the Sync page. Browse your Qobuz playlists and Favorite Tracks, run them through the same discovery flow as Tidal, sync the resulting Spotify-matched tracks, and queue downloads — same multi-step pipeline you already know.", features: [ - "new Qobuz tab on the Sync page, listed between Deezer and Deezer Link", + "new Qobuz tab on the Sync page, grouped with Tidal alongside the other lossless services", "lists your Qobuz user playlists plus a Favorite Tracks entry (same virtual-playlist treatment Tidal gets)", "click any card to fire discovery (Spotify-preferred, your primary metadata fallback otherwise), then sync or download just like Tidal / Deezer playlists", "uses the Qobuz auth token you already configured for downloads — no extra connection step", diff --git a/webui/static/library.js b/webui/static/library.js index 7a4cae2a..a979233c 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -879,6 +879,15 @@ function navigateToArtistDetail(artistId, artistName, sourceOverride = null, opt const bulkBar = document.getElementById('enhanced-bulk-bar'); if (bulkBar) bulkBar.classList.remove('visible'); + // Restore persisted view preference. Non-admins can't see / toggle the + // Enhanced control so only honour the saved choice for admins; default + // is still Standard. Wrapped in try/catch because localStorage can be + // disabled in private-browsing modes. + let _preferEnhanced = false; + try { + _preferEnhanced = localStorage.getItem(_libraryViewModeKey()) === 'enhanced'; + } catch (_) { /* localStorage unavailable */ } + // Navigate to artist detail page navigateToPage('artist-detail', { artistId, @@ -895,6 +904,13 @@ function navigateToArtistDetail(artistId, artistName, sourceOverride = null, opt // Load artist data loadArtistDetailData(artistId, artistName); + + // Apply persisted Enhanced view after the standard data load is kicked off. + // toggleEnhancedView() triggers its own loadEnhancedViewData() in parallel; + // the brief Standard render is hidden as soon as the toggle flips. + if (_preferEnhanced && isEnhancedAdmin()) { + toggleEnhancedView(true); + } } function _updateArtistDetailBackButtonLabel() { @@ -2905,6 +2921,25 @@ function toggleEnhancedView(enabled) { const bulkBar = document.getElementById('enhanced-bulk-bar'); if (bulkBar) bulkBar.classList.remove('visible'); } + + // Persist the choice so the next artist click (and the next page reload) + // honours it instead of always reverting to Standard. + try { + localStorage.setItem(_libraryViewModeKey(), enabled ? 'enhanced' : 'standard'); + } catch (_) { /* localStorage unavailable */ } +} + +// localStorage key for the Enhanced/Standard toggle, scoped to the active +// profile so different admin profiles can keep different defaults. Falls +// back to an unsuffixed key when no profile is loaded (matches the original +// behaviour for any pre-multi-profile saved value). +function _libraryViewModeKey() { + const pid = (typeof currentProfile === 'object' && currentProfile && currentProfile.id != null) + ? currentProfile.id + : null; + return pid != null + ? `soulsync-library-view-mode:${pid}` + : 'soulsync-library-view-mode'; } async function loadEnhancedViewData(artistId) { @@ -5299,6 +5334,99 @@ function _pollRedownloadProgress(taskId, overlay) { }, 300000); } +async function redownloadLibraryAlbum(album, artistName, btn) { + const albumName = album.title || ''; + const spotifyAlbumId = album.spotify_album_id || ''; + + if (!spotifyAlbumId && !albumName) { + showToast('No album ID or name available for redownload', 'warning'); + return; + } + + const origText = btn ? btn.innerHTML : ''; + try { + if (btn) { btn.disabled = true; btn.textContent = 'Loading...'; } + + let response; + if (spotifyAlbumId) { + const params = new URLSearchParams({ name: albumName, artist: artistName || '' }); + response = await fetch(`/api/spotify/album/${encodeURIComponent(spotifyAlbumId)}?${params}`); + } + + if (!response || !response.ok) { + const query = `${artistName || ''} ${albumName}`.trim(); + const searchResp = await fetch('/api/enhanced-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }) + }); + if (!searchResp.ok) throw new Error('Album search failed'); + const searchData = await searchResp.json(); + const found = searchData.spotify_albums?.[0] || searchData.itunes_albums?.[0]; + if (!found || !found.id) { + showToast(`Could not find "${albumName}" by ${artistName || 'unknown'}`, 'warning'); + return; + } + const params = new URLSearchParams({ name: found.name || albumName, artist: found.artist || artistName || '' }); + response = await fetch(`/api/spotify/album/${encodeURIComponent(found.id)}?${params}`); + } + + if (!response.ok) throw new Error(`Failed to load album: ${response.status}`); + + const albumData = await response.json(); + if (!albumData || !albumData.tracks || albumData.tracks.length === 0) { + showToast(`No tracks found for "${albumName}"`, 'warning'); + return; + } + + const resolvedId = albumData.id || spotifyAlbumId || album.id; + const virtualPlaylistId = `library_redownload_${resolvedId}`; + const playlistName = `[${artistName || 'Unknown'}] ${albumData.name}`; + + const enrichedTracks = albumData.tracks.map(track => ({ + ...track, + album: { + name: albumData.name, + id: albumData.id, + album_type: albumData.album_type || 'album', + images: albumData.images || [], + release_date: albumData.release_date, + total_tracks: albumData.total_tracks + } + })); + + const enhancedArtist = artistDetailPageState.enhancedData?.artist; + const artistObject = { + id: artistDetailPageState.currentArtistId || `library_${artistName || album.id}`, + name: artistName || '', + image_url: enhancedArtist?.thumb_url || '' + }; + const fullAlbumObject = { + name: albumData.name, + id: albumData.id, + album_type: albumData.album_type || 'album', + images: albumData.images || [], + image_url: albumData.images?.[0]?.url || null, + release_date: albumData.release_date, + total_tracks: albumData.total_tracks, + artists: albumData.artists || [{ name: artistName || '' }] + }; + + await openDownloadMissingModalForArtistAlbum( + virtualPlaylistId, playlistName, enrichedTracks, fullAlbumObject, artistObject, true + ); + + const albumType = fullAlbumObject.album_type || 'album'; + registerArtistDownload(artistObject, fullAlbumObject, virtualPlaylistId, albumType); + + } catch (error) { + console.error('Redownload album error:', error); + showToast(`Error: ${error.message}`, 'error'); + } finally { + if (btn) { btn.disabled = false; btn.innerHTML = origText; } + } +} + async function deleteLibraryAlbum(albumId) { const choice = await _showAlbumDeleteDialog(); if (!choice) return; diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 9723c549..daab421e 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -380,6 +380,10 @@ function importFileSubmit() { // ── Mirrored Playlists ──────────────────────────────────────────────── let mirroredPlaylistsLoaded = false; +// Auto-Sync state + functions moved to webui/static/auto-sync.js; declared +// as globals there so the older mirrored-playlist render path below can +// still reach `mirroredPipelinePollers`, `runMirroredPlaylistPipeline`, +// `editMirroredSourceRef`, `getMirroredSourceRef`, and `pollMirroredPipelineStatus`. /** * Fire-and-forget helper: send parsed playlist data to be mirrored on the backend. @@ -444,12 +448,34 @@ async function loadMirroredPlaylists() { function renderMirroredCard(p, container) { const ago = timeAgo(p.updated_at || p.mirrored_at); const hash = `mirrored_${p.id}`; - const state = youtubePlaylistStates[hash]; + const pipelineState = p.pipeline_state || null; + const pipelinePhase = pipelineState && pipelineState.status === 'running' ? 'pipeline_running' + : pipelineState && pipelineState.status === 'finished' ? 'pipeline_complete' + : pipelineState && (pipelineState.status === 'error' || pipelineState.status === 'skipped') ? 'pipeline_error' + : null; + const state = youtubePlaylistStates[hash] || (pipelinePhase ? { + phase: pipelinePhase, + pipeline_status: pipelineState.status, + pipeline_progress: pipelineState.progress || 0, + pipeline_phase: pipelineState.phase || '', + pipeline_error: pipelineState.error || '', + pipeline_log: pipelineState.log || [], + pipeline_result: pipelineState.result || null, + } : null); const phase = state ? state.phase : null; + const sourceRef = getMirroredSourceRef(p); // Build phase indicator let phaseHtml = ''; - if (phase === 'discovering') { + if (phase === 'pipeline_running') { + const pct = state.pipeline_progress || state.progress || 0; + const label = state.pipeline_phase || state.phase_label || 'Pipeline running'; + phaseHtml = `${_esc(label)} ${pct}%`; + } else if (phase === 'pipeline_complete') { + phaseHtml = `Pipeline complete`; + } else if (phase === 'pipeline_error') { + phaseHtml = `Pipeline error`; + } else if (phase === 'discovering') { const pct = state.discoveryProgress || state.discovery_progress || 0; phaseHtml = `Discovering ${pct}%`; } else if (phase === 'discovered') { @@ -493,6 +519,8 @@ function renderMirroredCard(p, container) {
${disc > 0 ? `` : ''} + + `; card.addEventListener('click', () => { @@ -530,6 +558,10 @@ function renderMirroredCard(p, container) { } }); container.appendChild(card); + + if (pipelineState && pipelineState.status === 'running' && !mirroredPipelinePollers[hash]) { + pollMirroredPipelineStatus(p.id, p.name); + } } function updateMirroredCardPhase(urlHash, phase) { @@ -552,6 +584,17 @@ function updateMirroredCardPhase(urlHash, phase) { // Add new phase indicator let phaseHtml = ''; switch (phase) { + case 'pipeline_running': + const pipelineProgress = state?.pipeline_progress || 0; + const pipelinePhase = state?.pipeline_phase || 'Pipeline running'; + phaseHtml = `${_esc(pipelinePhase)} ${pipelineProgress}%`; + break; + case 'pipeline_complete': + phaseHtml = `Pipeline complete`; + break; + case 'pipeline_error': + phaseHtml = `Pipeline error`; + break; case 'discovering': phaseHtml = `Discovering...`; break; @@ -785,6 +828,7 @@ async function openMirroredPlaylistModal(playlistId) { const tracks = data.tracks || []; const source = data.source || 'unknown'; + const sourceRef = getMirroredSourceRef(data); const sourceIcons = { spotify: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛' }; const sourceIcon = sourceIcons[source] || '📋'; @@ -827,6 +871,8 @@ async function openMirroredPlaylistModal(playlistId) { @@ -2939,7 +2985,7 @@ function _promptNotifyConfig(groupName) { if (type === 'discord_webhook') { fieldsDiv.innerHTML = ''; } else if (type === 'telegram') { - fieldsDiv.innerHTML = ''; + fieldsDiv.innerHTML = ''; } else if (type === 'pushbullet') { fieldsDiv.innerHTML = ''; } else { @@ -2960,7 +3006,7 @@ function _promptNotifyConfig(groupName) { if (type === 'discord_webhook') { config = { webhook_url: (overlay.querySelector('#deploy-notify-url')?.value || '').trim() }; } else if (type === 'telegram') { - config = { bot_token: (overlay.querySelector('#deploy-notify-token')?.value || '').trim(), chat_id: (overlay.querySelector('#deploy-notify-chat')?.value || '').trim() }; + config = { bot_token: (overlay.querySelector('#deploy-notify-token')?.value || '').trim(), chat_id: (overlay.querySelector('#deploy-notify-chat')?.value || '').trim(), thread_id: (overlay.querySelector('#deploy-notify-thread')?.value || '').trim() }; } else if (type === 'pushbullet') { config = { access_token: (overlay.querySelector('#deploy-notify-token')?.value || '').trim() }; } else { @@ -4092,6 +4138,7 @@ function _renderBlockConfigFields(slotKey, blockType, config) { if (blockType === 'telegram') { const botToken = _escAttr(config.bot_token || ''); const chatId = _escAttr(config.chat_id || ''); + const threadId = _escAttr(config.thread_id || ''); return `
@@ -4100,6 +4147,10 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
+
+ + +
@@ -4479,6 +4530,7 @@ function _readPlacedConfig(slotKey) { return { bot_token: document.getElementById('cfg-' + slotKey + '-bot_token')?.value?.trim() || '', chat_id: document.getElementById('cfg-' + slotKey + '-chat_id')?.value?.trim() || '', + thread_id: document.getElementById('cfg-' + slotKey + '-thread_id')?.value?.trim() || '', message: document.getElementById('cfg-' + slotKey + '-message')?.value || '', }; } diff --git a/webui/static/style.css b/webui/static/style.css index 7706efc0..3c6c0843 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -11178,6 +11178,1810 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .sync-history-btn:hover { color: rgb(var(--accent-rgb)); background: rgba(var(--accent-rgb), 0.1); border-color: rgba(var(--accent-rgb), 0.3); } +.auto-sync-manager-btn { + color: rgb(var(--accent-light-rgb)); + border-color: rgba(var(--accent-rgb), 0.32); + background: rgba(var(--accent-rgb), 0.12); +} + +.auto-sync-manager-btn:hover { + border-color: rgba(var(--accent-rgb), 0.5); + background: rgba(var(--accent-rgb), 0.2); + color: rgb(var(--accent-neon-rgb)); +} + +.auto-sync-overlay { + position: fixed; + inset: 0; + z-index: 10000; + display: none; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.72); + backdrop-filter: blur(12px); +} + +.auto-sync-modal { + width: calc(100vw - 96px); + height: calc(100vh - 64px); + background: linear-gradient(135deg, #1a1a1a 0%, #121212 100%); + border: 1px solid rgba(var(--accent-rgb), 0.2); + border-radius: 20px; + box-shadow: + 0 25px 80px rgba(0, 0, 0, 0.7), + 0 0 0 1px rgba(var(--accent-rgb), 0.1); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.auto-sync-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + padding: 24px 28px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + flex-shrink: 0; + position: relative; +} + +.auto-sync-eyebrow { + margin-bottom: 4px; + color: rgba(var(--accent-rgb), 0.85); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; +} + +.auto-sync-header h3 { + margin: 0 0 4px; + color: #fff; + font-size: 22px; + font-weight: 700; + letter-spacing: -0.01em; +} + +.auto-sync-header p { + margin: 0; + color: rgba(255, 255, 255, 0.55); + font-size: 13px; + line-height: 1.45; +} + +.auto-sync-close { + width: 32px; + height: 32px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 50%; + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + font-size: 22px; + line-height: 1; +} + +.auto-sync-close:hover { + background: rgba(239, 68, 68, 0.2); + border-color: rgba(239, 68, 68, 0.4); + color: #ef4444; +} + +.auto-sync-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + padding: 14px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + flex-shrink: 0; +} + +.auto-sync-summary div { + padding: 10px 14px; + background: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 10px; +} + +.auto-sync-summary span { + display: block; + color: #fff; + font-size: 18px; + font-weight: 700; + letter-spacing: -0.01em; +} + +.auto-sync-summary small { + display: block; + margin-top: 2px; + color: rgba(255, 255, 255, 0.45); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.auto-sync-monitor { + flex-shrink: 0; + padding: 12px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.auto-sync-monitor-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 0; +} + +.auto-sync-monitor-head > div { + min-width: 0; +} + +.auto-sync-monitor-head strong, +.auto-sync-monitor-head small, +.auto-sync-monitor-kicker { + display: block; +} + +.auto-sync-monitor-kicker { + margin-bottom: 3px; + color: rgba(var(--accent-rgb), 0.85); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.auto-sync-monitor-head strong { + color: rgba(255, 255, 255, 0.92); + font-size: 13px; + font-weight: 600; +} + +.auto-sync-monitor-head small { + margin-top: 2px; + color: rgba(255, 255, 255, 0.45); + font-size: 11px; +} + +.auto-sync-monitor-head button, +.auto-sync-board-intro button, +.auto-sync-history-intro button { + height: 30px; + padding: 0 14px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.65); + cursor: pointer; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.2px; + transition: all 0.2s ease; +} + +.auto-sync-monitor-head button:hover, +.auto-sync-board-intro button:hover, +.auto-sync-history-intro button:hover { + background: rgba(var(--accent-rgb), 0.18); + border-color: rgba(var(--accent-rgb), 0.4); + color: rgb(var(--accent-light-rgb)); +} + +.auto-sync-monitor-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 8px; + margin-top: 10px; +} + +.auto-sync-monitor-card, +.auto-sync-monitor-empty { + min-width: 0; + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 10px; + background: rgba(22, 22, 22, 0.95); +} + +.auto-sync-monitor-card { + display: flex; + align-items: stretch; + justify-content: space-between; + gap: 10px; + padding: 10px; +} + +.auto-sync-monitor-card.running { + border-color: rgba(var(--accent-rgb), 0.34); + background: rgba(var(--accent-rgb), 0.08); + box-shadow: 0 0 0 1px rgba(var(--accent-rgb), 0.08), 0 8px 24px rgba(var(--accent-rgb), 0.08); +} + +.auto-sync-monitor-card.error { + border-color: rgba(239, 68, 68, 0.28); + background: rgba(239, 68, 68, 0.07); +} + +.auto-sync-monitor-card.finished { + border-color: rgba(34, 197, 94, 0.22); + background: rgba(34, 197, 94, 0.055); +} + +.auto-sync-monitor-card-main { + min-width: 0; + flex: 1; +} + +.auto-sync-monitor-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +.auto-sync-monitor-title-row strong { + min-width: 0; + color: rgba(255, 255, 255, 0.88); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auto-sync-monitor-title-row span { + flex-shrink: 0; + color: rgb(var(--accent-light-rgb)); + font-size: 10px; + font-weight: 800; + text-transform: uppercase; +} + +.auto-sync-monitor-phase { + margin-top: 5px; + color: rgba(255, 255, 255, 0.52); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auto-sync-monitor-progress { + height: 5px; + margin-top: 8px; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); +} + +.auto-sync-monitor-progress div { + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, rgb(var(--accent-rgb)), rgb(var(--accent-light-rgb))); + transition: width 0.25s ease; +} + +.auto-sync-monitor-card small { + display: block; + margin-top: 6px; + color: rgba(255, 255, 255, 0.38); + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auto-sync-monitor-card button { + align-self: center; + height: 28px; + padding: 0 9px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + background: rgba(255, 255, 255, 0.055); + color: rgba(255, 255, 255, 0.64); + cursor: pointer; + font-size: 11px; + font-weight: 800; +} + +.auto-sync-monitor-card button:hover { + border-color: rgba(var(--accent-rgb), 0.36); + background: rgba(var(--accent-rgb), 0.14); + color: rgb(var(--accent-light-rgb)); +} + +.auto-sync-monitor-empty { + display: flex; + align-items: center; + gap: 10px; + width: fit-content; + max-width: 100%; + margin-top: 8px; + padding: 8px 12px; +} + +.auto-sync-monitor-empty span, +.auto-sync-monitor-empty small { + display: block; +} + +.auto-sync-monitor-empty span { + color: rgba(255, 255, 255, 0.65); + font-size: 12px; + font-weight: 600; +} + +.auto-sync-monitor-empty small { + margin-top: 0; + color: rgba(255, 255, 255, 0.35); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auto-sync-tabs { + display: flex; + gap: 0; + padding: 0 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + flex-shrink: 0; +} + +.auto-sync-tabs button { + height: 40px; + padding: 0 18px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: rgba(255, 255, 255, 0.5); + cursor: pointer; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.2px; + transition: color 0.2s ease, border-color 0.2s ease; + position: relative; + top: 1px; +} + +.auto-sync-tabs button:hover { + color: rgba(255, 255, 255, 0.85); +} + +.auto-sync-tabs button.active { + color: rgb(var(--accent-light-rgb)); + border-bottom-color: rgb(var(--accent-rgb)); +} + +.auto-sync-tab-panel { + display: none; + min-height: 0; + flex: 1; +} + +.auto-sync-tab-panel.active { + display: flex; + flex-direction: column; +} + +.auto-sync-board-intro, +.auto-sync-automation-intro, +.auto-sync-history-intro { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + flex-shrink: 0; +} + +.auto-sync-board-intro strong, +.auto-sync-automation-intro strong, +.auto-sync-history-intro strong { + display: block; + color: rgba(255, 255, 255, 0.85); + font-size: 13px; + font-weight: 600; +} + +.auto-sync-board-intro span, +.auto-sync-automation-intro span, +.auto-sync-history-intro span { + display: block; + margin-top: 2px; + color: rgba(255, 255, 255, 0.4); + font-size: 11px; + line-height: 1.4; +} + +.auto-sync-body { + min-height: 0; + flex: 1; + display: grid; + grid-template-columns: 340px minmax(0, 1fr); +} + +.auto-sync-sidebar { + min-height: 0; + border-right: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(0, 0, 0, 0.2); + display: flex; + flex-direction: column; +} + +.auto-sync-sidebar-title { + padding: 14px 18px 8px; + color: rgba(255, 255, 255, 0.55); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.auto-sync-sidebar-filter { + position: relative; + padding: 4px 16px 10px; +} + +.auto-sync-sidebar-search { + width: 100%; + height: 30px; + padding: 0 28px 0 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: rgba(0, 0, 0, 0.3); + color: #fff; + font-size: 12px; + font-family: inherit; + outline: none; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.auto-sync-sidebar-search:focus { + border-color: rgba(var(--accent-rgb), 0.45); + background: rgba(var(--accent-rgb), 0.04); +} + +.auto-sync-sidebar-search::placeholder { + color: rgba(255, 255, 255, 0.3); +} + +.auto-sync-sidebar-filter-clear { + position: absolute; + right: 22px; + top: 50%; + transform: translateY(-50%); + width: 18px; + height: 18px; + border: 0; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.65); + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 0; +} + +.auto-sync-sidebar-filter-clear:hover { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; +} + +.auto-sync-source-list { + min-height: 0; + overflow-y: auto; + padding: 0 12px 16px; +} + +.auto-sync-source-group { + margin-bottom: 14px; +} + +.auto-sync-source-group-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + padding: 6px 4px 4px; +} + +.auto-sync-source-title { + color: rgba(255, 255, 255, 0.38); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.auto-sync-source-bulk-btn { + height: 20px; + padding: 0 8px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 6px; + background: transparent; + color: rgba(255, 255, 255, 0.45); + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.3px; + cursor: pointer; + font-family: inherit; + transition: all 0.2s ease; +} + +.auto-sync-source-bulk-btn:hover { + background: rgba(var(--accent-rgb), 0.16); + border-color: rgba(var(--accent-rgb), 0.35); + color: rgb(var(--accent-light-rgb)); +} + +/* Bulk-schedule popover for a source group */ +.auto-sync-bulk-menu { + position: fixed; + z-index: 10001; + min-width: 220px; + padding: 8px; + background: linear-gradient(135deg, #1f1f1f 0%, #161616 100%); + border: 1px solid rgba(var(--accent-rgb), 0.25); + border-radius: 10px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(var(--accent-rgb), 0.08); + display: flex; + flex-direction: column; + gap: 6px; +} + +.auto-sync-bulk-menu-title { + padding: 6px 8px 4px; + color: rgba(255, 255, 255, 0.55); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.auto-sync-bulk-menu-buckets { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px; +} + +.auto-sync-bulk-menu button { + padding: 6px 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 6px; + background: rgba(255, 255, 255, 0.03); + color: rgba(255, 255, 255, 0.75); + cursor: pointer; + font-size: 11px; + font-weight: 600; + text-align: left; + font-family: inherit; + transition: all 0.15s ease; +} + +.auto-sync-bulk-menu button:hover { + background: rgba(var(--accent-rgb), 0.16); + border-color: rgba(var(--accent-rgb), 0.35); + color: rgb(var(--accent-light-rgb)); +} + +.auto-sync-bulk-menu-custom { + margin-top: 2px; +} + +.auto-sync-bulk-menu-unschedule { + color: rgba(255, 255, 255, 0.55) !important; +} + +.auto-sync-bulk-menu-unschedule:hover { + background: rgba(239, 68, 68, 0.18) !important; + border-color: rgba(239, 68, 68, 0.4) !important; + color: #ef4444 !important; +} + +/* Custom-interval column tag */ +.auto-sync-column.custom { + border-style: dashed; +} + +.auto-sync-column-head em { + font-style: normal; + color: rgba(var(--accent-rgb), 0.7); + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.3px; + margin-left: 4px; +} + +.auto-sync-playlist, +.auto-sync-scheduled-card { + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 10px; + background: rgba(22, 22, 22, 0.95); + cursor: grab; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.auto-sync-playlist { + padding: 12px 14px; + margin-bottom: 8px; + display: flex; + align-items: flex-start; + gap: 10px; +} + +.auto-sync-playlist::before { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.18); + flex-shrink: 0; + /* Pin the status dot to the first line of the name so multi-line + * playlist titles flow underneath it instead of pushing it down. */ + margin-top: 6px; +} + +.auto-sync-playlist.scheduled::before { + background: rgb(var(--accent-rgb)); + box-shadow: 0 0 6px rgba(var(--accent-rgb), 0.5); +} + +.auto-sync-playlist > div { + min-width: 0; + flex: 1; +} + +.auto-sync-playlist:hover, +.auto-sync-scheduled-card:hover { + border-color: rgba(var(--accent-rgb), 0.25); + background: rgba(28, 28, 28, 0.98); +} + +.auto-sync-playlist.scheduled { + border-color: rgba(var(--accent-rgb), 0.22); +} + +.auto-sync-playlist.unavailable { + cursor: default; + opacity: 0.5; +} + +.auto-sync-playlist.unavailable::before { + background: rgba(255, 255, 255, 0.1); +} + +.auto-sync-playlist.unavailable:hover { + border-color: rgba(255, 255, 255, 0.07); + background: rgba(22, 22, 22, 0.95); +} + +.auto-sync-source-group-disabled { + padding-top: 10px; + border-top: 1px solid rgba(255, 255, 255, 0.05); +} + +.auto-sync-playlist-name, +.auto-sync-scheduled-name { + color: #fff; + font-size: 13px; + font-weight: 600; + line-height: 1.35; + /* Wrap long names instead of truncating with ellipsis — the + * sidebar is narrow and long ListenBrainz / Spotify titles were + * getting clipped beyond recognition. */ + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; +} + +.auto-sync-scheduled-name { + line-height: 1.3; +} + +.auto-sync-playlist-meta, +.auto-sync-scheduled-meta { + margin-top: 4px; + color: rgba(255, 255, 255, 0.55); + font-size: 11px; + line-height: 1.3; +} + +.auto-sync-scheduled-meta { + overflow-wrap: anywhere; + line-height: 1.3; +} + +.auto-sync-scheduled-timing { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 8px; +} + +.auto-sync-scheduled-timing span, +.auto-sync-scheduled-timing small { + max-width: 100%; + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + line-height: 1.5; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auto-sync-scheduled-timing span { + background: rgba(var(--accent-rgb), 0.12); + color: rgb(var(--accent-light-rgb)); +} + +.auto-sync-scheduled-timing small { + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.45); +} + +.auto-sync-board { + min-width: 0; + min-height: 0; + overflow-x: auto; + overflow-y: hidden; + padding: 20px; + display: grid; + grid-template-columns: repeat(10, minmax(220px, 1fr)); + grid-auto-rows: minmax(0, 1fr); + gap: 12px; +} + +.auto-sync-column { + min-height: 0; + max-height: 100%; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + background: rgba(0, 0, 0, 0.2); + display: flex; + flex-direction: column; + transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; +} + +.auto-sync-column.drag-over { + border-color: rgba(var(--accent-rgb), 0.5); + background: rgba(var(--accent-rgb), 0.06); + box-shadow: inset 0 0 0 1px rgba(var(--accent-rgb), 0.3), 0 6px 18px rgba(var(--accent-rgb), 0.12); +} + +.auto-sync-column-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.85); + font-size: 13px; + font-weight: 700; + flex-shrink: 0; +} + +.auto-sync-column-head small { + color: rgba(255, 255, 255, 0.4); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.auto-sync-column-list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 14px; +} + +.auto-sync-column-list::-webkit-scrollbar, +.auto-sync-board::-webkit-scrollbar, +.auto-sync-source-list::-webkit-scrollbar, +.auto-sync-automation-list::-webkit-scrollbar, +.auto-sync-history-list::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.auto-sync-column-list::-webkit-scrollbar-thumb, +.auto-sync-board::-webkit-scrollbar-thumb, +.auto-sync-source-list::-webkit-scrollbar-thumb, +.auto-sync-automation-list::-webkit-scrollbar-thumb, +.auto-sync-history-list::-webkit-scrollbar-thumb { + background: rgba(var(--accent-rgb), 0.3); + border-radius: 999px; +} + +.auto-sync-column-list::-webkit-scrollbar-thumb:hover, +.auto-sync-board::-webkit-scrollbar-thumb:hover, +.auto-sync-source-list::-webkit-scrollbar-thumb:hover, +.auto-sync-automation-list::-webkit-scrollbar-thumb:hover, +.auto-sync-history-list::-webkit-scrollbar-thumb:hover { + background: rgba(var(--accent-rgb), 0.5); +} + +.auto-sync-drop-hint, +.auto-sync-empty, +.auto-sync-loading, +.auto-sync-error { + color: rgba(255, 255, 255, 0.35); + font-size: 12px; + text-align: center; +} + +.auto-sync-drop-hint { + border: 1px dashed rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 20px 12px; +} + +.auto-sync-drop-hint strong, +.auto-sync-drop-hint span { + display: block; +} + +.auto-sync-drop-hint strong { + color: rgba(255, 255, 255, 0.5); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.auto-sync-drop-hint span { + margin-top: 4px; + color: rgba(255, 255, 255, 0.3); + font-size: 11px; +} + +.auto-sync-loading, +.auto-sync-error { + padding: 48px; +} + +.auto-sync-scheduled-card { + box-sizing: border-box; + width: 100%; + min-width: 0; + padding: 12px; + margin-bottom: 8px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.auto-sync-scheduled-card.disabled { + opacity: 0.52; +} + +.auto-sync-scheduled-main { + min-width: 0; + width: 100%; +} + +.auto-sync-scheduled-actions { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; +} + +.auto-sync-scheduled-card button { + width: 26px; + height: 26px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 6px; + background: transparent; + color: rgba(255, 255, 255, 0.45); + cursor: pointer; + flex-shrink: 0; + font-size: 12px; + transition: all 0.2s ease; +} + +.auto-sync-scheduled-card button.run { + width: auto; + min-width: 0; + height: 26px; + flex: 1; + padding: 0 10px; + color: rgb(var(--accent-light-rgb)); + background: rgba(var(--accent-rgb), 0.12); + border: 1px solid rgba(var(--accent-rgb), 0.25); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.3px; + text-transform: uppercase; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auto-sync-scheduled-card button.run:disabled { + cursor: default; + opacity: 0.62; +} + +.auto-sync-scheduled-card button.run:not(:disabled):hover { + color: rgb(var(--accent-neon-rgb)); + background: rgba(var(--accent-rgb), 0.2); + border-color: rgba(var(--accent-rgb), 0.4); +} + +.auto-sync-scheduled-card button:not(.run):hover { + color: #ef4444; + background: rgba(239, 68, 68, 0.2); + border-color: rgba(239, 68, 68, 0.4); +} + +.auto-sync-scheduled-card.failing { + border-color: rgba(239, 68, 68, 0.4); + box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.15); +} + +.auto-sync-scheduled-card.warning { + border-color: rgba(250, 204, 21, 0.3); +} + +.auto-sync-scheduled-health { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 50%; + margin-right: 6px; + font-size: 10px; + font-weight: 800; + line-height: 1; + vertical-align: middle; +} + +.auto-sync-scheduled-health.failing { + background: rgba(239, 68, 68, 0.18); + color: #ef4444; + box-shadow: 0 0 6px rgba(239, 68, 68, 0.35); +} + +.auto-sync-scheduled-health.warning { + background: rgba(250, 204, 21, 0.18); + color: #facc15; +} + +/* Run history filters + load more + tab badge */ +.auto-sync-history-intro-controls { + display: flex; + align-items: center; + gap: 10px; +} + +.auto-sync-history-filters { + display: flex; + gap: 4px; + padding: 3px; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; + background: rgba(0, 0, 0, 0.25); +} + +.auto-sync-history-filter-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 26px; + padding: 0 10px; + border: 0; + border-radius: 7px; + background: transparent; + color: rgba(255, 255, 255, 0.5); + cursor: pointer; + font-size: 11px; + font-weight: 600; + font-family: inherit; + transition: color 0.15s ease, background 0.15s ease; +} + +.auto-sync-history-filter-btn:hover { + color: rgba(255, 255, 255, 0.85); +} + +.auto-sync-history-filter-btn.active { + background: rgba(var(--accent-rgb), 0.18); + color: rgb(var(--accent-light-rgb)); +} + +.auto-sync-history-filter-btn em { + font-style: normal; + padding: 1px 6px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.55); + font-size: 10px; + font-weight: 700; +} + +.auto-sync-history-filter-btn.active em { + background: rgba(var(--accent-rgb), 0.25); + color: rgb(var(--accent-light-rgb)); +} + +.auto-sync-history-filter-btn.has-errors:not(.active) em { + background: rgba(239, 68, 68, 0.18); + color: #ef4444; +} + +.auto-sync-history-filter-btn.has-errors.active { + background: rgba(239, 68, 68, 0.16); + color: #ef4444; +} + +.auto-sync-history-load-more-row { + display: flex; + justify-content: center; + padding: 12px 14px 20px; + border-top: 1px solid rgba(255, 255, 255, 0.04); +} + +.auto-sync-history-load-more { + height: 32px; + padding: 0 18px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.2px; + transition: all 0.2s ease; + font-family: inherit; +} + +.auto-sync-history-load-more:hover { + background: rgba(var(--accent-rgb), 0.16); + border-color: rgba(var(--accent-rgb), 0.4); + color: rgb(var(--accent-light-rgb)); +} + +/* Tab badge (error count on Run History tab) */ +.auto-sync-tab-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 16px; + padding: 0 5px; + margin-left: 6px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.7); + font-size: 10px; + font-weight: 700; + line-height: 1; + vertical-align: middle; +} + +.auto-sync-tab-badge.error { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; +} + +/* Run-again button inside history expanded detail */ +.auto-sync-history-detail-actions { + display: flex; + justify-content: flex-end; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.04); +} + +.auto-sync-history-run-again { + height: 30px; + padding: 0 14px; + border: 1px solid rgba(var(--accent-rgb), 0.3); + border-radius: 8px; + background: rgba(var(--accent-rgb), 0.12); + color: rgb(var(--accent-light-rgb)); + cursor: pointer; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.3px; + text-transform: uppercase; + font-family: inherit; + transition: all 0.2s ease; +} + +.auto-sync-history-run-again:hover { + background: rgba(var(--accent-rgb), 0.22); + border-color: rgba(var(--accent-rgb), 0.5); + color: rgb(var(--accent-neon-rgb)); +} + +.auto-sync-automation-list { + min-height: 0; + overflow-y: auto; + padding: 18px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.auto-sync-automation-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 10px; + background: rgba(22, 22, 22, 0.95); + overflow: hidden; +} + +.auto-sync-automation-card:hover { + border-color: rgba(var(--accent-rgb), 0.2); + background: rgba(28, 28, 28, 0.98); +} + +.auto-sync-automation-main { + min-width: 0; + flex: 1; +} + +.auto-sync-automation-title-row { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.auto-sync-automation-title-row strong { + color: rgba(255, 255, 255, 0.88); + font-size: 13px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auto-sync-card-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.auto-sync-card-status-dot.enabled { + background: #4ade80; + box-shadow: 0 0 6px rgba(74, 222, 128, 0.4); +} + +.auto-sync-card-status-dot.disabled { + background: #555; +} + +.auto-sync-card-flow { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + margin-top: 5px; +} + +.auto-sync-card-flow .flow-trigger, +.auto-sync-card-flow .flow-action, +.auto-sync-card-flow .flow-notify { + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; + white-space: nowrap; +} + +.auto-sync-card-flow .flow-trigger { + background: rgba(var(--accent-rgb), 0.12); + color: rgb(var(--accent-light-rgb)); +} + +.auto-sync-card-flow .flow-action { + background: rgba(88, 101, 242, 0.12); + color: #7289da; +} + +.auto-sync-card-flow .flow-notify { + background: rgba(250, 204, 21, 0.1); + color: #fbbf24; +} + +.auto-sync-card-flow .flow-arrow { + color: rgba(255, 255, 255, 0.25); + font-size: 12px; +} + +.auto-sync-status { + padding: 3px 7px; + border-radius: 999px; + font-size: 10px; + font-weight: 800; + text-transform: uppercase; + flex-shrink: 0; +} + +.auto-sync-status.enabled { + background: rgba(34, 197, 94, 0.14); + color: #4ade80; +} + +.auto-sync-status.disabled { + background: rgba(148, 163, 184, 0.14); + color: #94a3b8; +} + +.auto-sync-automation-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 7px; +} + +.auto-sync-automation-meta span { + padding: 3px 7px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.045); + color: rgba(255, 255, 255, 0.42); + font-size: 10px; + font-weight: 700; +} + +.auto-sync-automation-lock { + padding: 6px 9px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.42); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + flex-shrink: 0; +} + +.auto-sync-automation-empty { + margin: 24px; + padding: 44px; + border: 1px dashed rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: rgba(255, 255, 255, 0.42); + text-align: center; +} + +.auto-sync-history-list { + min-height: 0; + overflow-y: auto; + padding: 20px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.auto-sync-history-loading { + padding: 28px; + border: 1px dashed rgba(255, 255, 255, 0.12); + border-radius: 12px; + color: rgba(255, 255, 255, 0.48); + font-size: 13px; + font-weight: 700; + text-align: center; +} + +/* Run history cards — modeled on .automation-card / .history-entry styling. + Slim horizontal row, click to expand, expanded panel mirrors the + automation-history-modal grid + log layout. */ +.auto-sync-history-entry { + background: rgba(22, 22, 22, 0.95); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 10px; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.auto-sync-history-entry:hover { + border-color: rgba(var(--accent-rgb), 0.22); + background: rgba(28, 28, 28, 0.98); +} + +.auto-sync-history-entry.expanded { + border-color: rgba(var(--accent-rgb), 0.32); + background: rgba(26, 26, 26, 0.98); +} + +.auto-sync-history-entry-error { border-color: rgba(239, 68, 68, 0.22); } +.auto-sync-history-entry-skipped { border-color: rgba(250, 204, 21, 0.18); } + +.auto-sync-history-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + cursor: pointer; + user-select: none; +} + +.auto-sync-history-row:focus-visible { + outline: 2px solid rgba(var(--accent-rgb), 0.45); + outline-offset: -2px; + border-radius: 10px; +} + +.auto-sync-history-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.auto-sync-history-name { + font-size: 13px; + font-weight: 600; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.auto-sync-history-flow { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.auto-sync-history-flow .flow-trigger, +.auto-sync-history-flow .flow-action, +.auto-sync-history-flow .flow-notify { + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; + white-space: nowrap; +} + +.auto-sync-history-flow .flow-trigger { background: rgba(var(--accent-rgb), 0.12); color: rgb(var(--accent-light-rgb)); } +.auto-sync-history-flow .flow-action { background: rgba(88, 101, 242, 0.12); color: #7289da; } +.auto-sync-history-flow .flow-notify { background: rgba(250, 204, 21, 0.10); color: #fbbf24; } +.auto-sync-history-flow .flow-arrow { color: rgba(255, 255, 255, 0.25); font-size: 12px; } + +.auto-sync-history-meta-inline { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + font-size: 10px; + color: rgba(255, 255, 255, 0.4); +} + +.auto-sync-history-status { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + padding: 2px 8px; + border-radius: 10px; + background: rgba(148, 163, 184, 0.14); + color: #94a3b8; +} + +.auto-sync-history-status.completed, +.auto-sync-history-status.finished { background: rgba(74, 222, 128, 0.15); color: #4ade80; } +.auto-sync-history-status.error { background: rgba(239, 68, 68, 0.15); color: #ef4444; } +.auto-sync-history-status.skipped { background: rgba(250, 204, 21, 0.15); color: #facc15; } + +.auto-sync-history-duration { + background: rgba(255, 255, 255, 0.05); + padding: 2px 6px; + border-radius: 6px; + color: rgba(255, 255, 255, 0.55); +} + +.auto-sync-history-delta { + font-size: 10px; + font-weight: 700; + padding: 2px 8px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.6); + margin-left: auto; +} + +.auto-sync-history-delta.pos { background: rgba(74, 222, 128, 0.14); color: #4ade80; } +.auto-sync-history-delta.neg { background: rgba(239, 68, 68, 0.14); color: #ef4444; } + +.auto-sync-history-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.auto-sync-history-expand-btn { + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 6px; + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: rgba(255, 255, 255, 0.45); + transition: all 0.2s ease; +} + +.auto-sync-history-expand-btn:hover { + background: rgba(var(--accent-rgb), 0.2); + border-color: rgba(var(--accent-rgb), 0.4); + color: rgb(var(--accent-light-rgb)); +} + +.auto-sync-history-expand-icon { + font-size: 9px; + transition: transform 0.2s ease; + display: inline-block; +} + +.auto-sync-history-entry.expanded .auto-sync-history-expand-icon { + transform: rotate(180deg); +} + +/* Expanded detail — mirrors .history-stats-grid / .history-log-section + from the Automations page run-history modal. */ +.auto-sync-history-detail { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease; + border-top: 1px solid transparent; +} + +.auto-sync-history-detail.expanded { + max-height: 800px; + border-top-color: rgba(255, 255, 255, 0.06); + padding: 12px 14px 14px; +} + +.auto-sync-history-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 8px; +} + +.auto-sync-history-stat { + background: rgba(0, 0, 0, 0.25); + border-radius: 8px; + padding: 8px 12px; +} + +.auto-sync-history-stat-label { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.3px; + color: rgba(255, 255, 255, 0.45); + margin-bottom: 4px; +} + +.auto-sync-history-stat-value { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 600; + color: #e0e0e0; +} + +.auto-sync-history-stat-value .stat-arrow { + color: rgba(255, 255, 255, 0.3); + font-size: 11px; +} + +.auto-sync-history-stat-value .stat-delta { + font-size: 10px; + font-weight: 700; + padding: 1px 6px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.5); +} + +.auto-sync-history-stat-value .stat-delta.pos { background: rgba(74, 222, 128, 0.14); color: #4ade80; } +.auto-sync-history-stat-value .stat-delta.neg { background: rgba(239, 68, 68, 0.14); color: #ef4444; } + +.auto-sync-history-facts-row { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 6px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgba(255, 255, 255, 0.04); +} + +.auto-sync-history-fact { + display: flex; + flex-direction: column; + gap: 2px; +} + +.auto-sync-history-fact span { + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.3px; + color: rgba(255, 255, 255, 0.35); +} + +.auto-sync-history-fact strong { + font-size: 11px; + font-weight: 600; + color: rgba(255, 255, 255, 0.78); + overflow-wrap: anywhere; +} + +.auto-sync-history-result-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; +} + +.auto-sync-history-result-pill { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 8px; + border-radius: 10px; + background: rgba(var(--accent-rgb), 0.10); + border: 1px solid rgba(var(--accent-rgb), 0.18); + color: rgb(var(--accent-light-rgb)); + font-size: 11px; + font-weight: 600; +} + +.auto-sync-history-result-pill em { + font-style: normal; + color: rgba(255, 255, 255, 0.5); + font-weight: 700; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.auto-sync-history-error { + margin-top: 10px; + padding: 8px 12px; + border-radius: 8px; + background: rgba(239, 68, 68, 0.10); + border: 1px solid rgba(239, 68, 68, 0.22); + color: #f87171; + font-size: 11px; + line-height: 1.45; +} + +.auto-sync-history-log-section { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgba(255, 255, 255, 0.04); + max-height: 240px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.1) transparent; +} + +.auto-sync-history-log-line { + padding: 3px 0; + font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', ui-monospace, monospace; + font-size: 10px; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.auto-sync-history-log-info { color: rgba(255, 255, 255, 0.5); } +.auto-sync-history-log-success { color: #4ade80; } +.auto-sync-history-log-error { color: #ef4444; } +.auto-sync-history-log-warning, +.auto-sync-history-log-warn { color: #facc15; } +.auto-sync-history-log-skip { color: rgba(255, 255, 255, 0.35); } + +.auto-sync-history-empty { + margin: 24px; + padding: 44px; + border: 1px dashed rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: rgba(255, 255, 255, 0.42); + text-align: center; +} + +.auto-sync-history-empty strong, +.auto-sync-history-empty span { + display: block; +} + +.auto-sync-history-empty strong { + color: rgba(255, 255, 255, 0.72); + font-size: 14px; +} + +.auto-sync-history-empty span { + margin-top: 6px; + font-size: 12px; +} + +.auto-sync-history-total { + padding: 12px; + color: rgba(255, 255, 255, 0.38); + font-size: 12px; + text-align: center; +} + +@media (max-width: 1100px) { + .auto-sync-modal { + width: calc(100vw - 18px); + height: calc(100vh - 18px); + } + + .auto-sync-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .auto-sync-monitor-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .auto-sync-body { + grid-template-columns: 1fr; + grid-template-rows: minmax(150px, 30%) 1fr; + } + + .auto-sync-sidebar { + border-right: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + } + + .auto-sync-source-list { + display: flex; + gap: 12px; + overflow-x: auto; + overflow-y: hidden; + padding: 0 12px 12px; + } + + .auto-sync-source-group { + min-width: 220px; + margin-bottom: 0; + } + + .auto-sync-board { + grid-template-columns: repeat(10, minmax(230px, 250px)); + padding: 18px; + } + + .auto-sync-history-detail-grid, + .auto-sync-history-snapshots { + grid-template-columns: 1fr; + } + + .auto-sync-history-facts.compact { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-height: 760px) { + .auto-sync-header { + padding: 14px 18px 12px; + } + + .auto-sync-header p { + display: none; + } + + .auto-sync-summary div { + padding: 9px 16px; + } + + .auto-sync-monitor { + padding: 9px 14px; + } + + .auto-sync-monitor-empty, + .auto-sync-monitor-card { + padding: 9px; + } + + .auto-sync-tabs { + padding: 9px 14px; + } + + .auto-sync-board-intro, + .auto-sync-automation-intro, + .auto-sync-history-intro { + padding: 10px 14px; + } +} + +@media (max-width: 720px) { + .auto-sync-monitor-list { + grid-template-columns: 1fr; + } + + .auto-sync-monitor-head { + align-items: flex-start; + flex-direction: column; + } + + .auto-sync-monitor-empty { + align-items: flex-start; + flex-direction: column; + width: 100%; + } + + .auto-sync-monitor-empty small { + white-space: normal; + } + + .auto-sync-monitor-card { + flex-direction: column; + } + + .auto-sync-monitor-card button { + align-self: flex-start; + } + + .auto-sync-history-row { + padding: 16px; + } + + .auto-sync-history-card-head { + flex-direction: column; + } + + .auto-sync-history-meta { + justify-content: flex-start; + min-width: 0; + max-width: none; + } + + .auto-sync-history-detail { + padding: 16px; + } + + .auto-sync-history-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .auto-sync-history-facts, + .auto-sync-history-facts.compact, + .auto-sync-history-payload { + grid-template-columns: 1fr; + } + + .auto-sync-scheduled-card { + grid-template-columns: 1fr; + } + + .auto-sync-scheduled-actions { + align-items: center; + justify-content: space-between; + width: 100%; + } +} + /* Enhanced Progress Bar Animation */ .progress-bar-fill { height: 100%; @@ -11395,6 +13199,12 @@ body.helper-mode-active #dashboard-activity-feed:hover { box-shadow: 0 4px 15px rgba(29, 185, 84, 0.3); } +.sync-tab-button[data-tab="itunes-link"].active { + background: #fa586a; + color: #fff; + box-shadow: 0 4px 15px rgba(250, 88, 106, 0.3); +} + /* Server tab — prominent first tab */ .sync-tab-server { flex: 1.4 !important; @@ -11460,6 +13270,66 @@ body.helper-mode-active #dashboard-activity-feed:hover { background-image: url('data:image/svg+xml;charset=utf-8,'); } +.listenbrainz-icon { + background-image: url('data:image/svg+xml;charset=utf-8,'); +} + +.sync-tab-button.active .listenbrainz-icon { + background-image: url('data:image/svg+xml;charset=utf-8,'); +} + +.lastfm-icon { + background-image: url('data:image/svg+xml;charset=utf-8,'); +} + +.sync-tab-button.active .lastfm-icon { + background-image: url('data:image/svg+xml;charset=utf-8,'); +} + +.soulsync-discovery-icon { + background-image: url('data:image/svg+xml;charset=utf-8,'); +} + +.sync-tab-button.active .soulsync-discovery-icon { + background-image: url('data:image/svg+xml;charset=utf-8,'); +} + +/* ListenBrainz Sync tab sub-tabs (For You / My Playlists / Collaborative). + * Neutral dark surface; orange used only as a subtle accent on the + * active state — matches the rest of the app's tone. */ +.listenbrainz-sub-tabs { + display: inline-flex; + gap: 6px; + margin-left: 16px; +} + +.listenbrainz-sub-tab-btn { + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.65); + border: 1px solid rgba(255, 255, 255, 0.08); + padding: 6px 12px; + border-radius: 6px; + font-size: 12px; + cursor: pointer; + transition: all 0.15s ease; +} + +.listenbrainz-sub-tab-btn:hover { + background: rgba(255, 255, 255, 0.07); + color: #fff; +} + +.listenbrainz-sub-tab-btn.active { + background: rgba(255, 255, 255, 0.08); + color: #fff; + border-color: rgba(235, 116, 59, 0.45); + box-shadow: 0 0 0 1px rgba(235, 116, 59, 0.15) inset; +} + +.itunes-icon { + background-image: url('data:image/svg+xml;charset=utf-8,'); +} + /* Active tab icons - make them white for better contrast */ .sync-tab-button.active .spotify-icon { background-image: url('data:image/svg+xml;charset=utf-8,'); @@ -12047,7 +13917,31 @@ body.helper-mode-active #dashboard-activity-feed:hover { letter-spacing: 0.3px; } -.mirrored-card-delete { +.mirrored-card-pipeline { + background: rgba(56, 189, 248, 0.12); + border: 1px solid rgba(56, 189, 248, 0.24); + border-radius: 6px; + color: #7dd3fc; + cursor: pointer; + font-size: 11px; + font-weight: 700; + height: 24px; + min-width: 76px; + padding: 0 10px; + transition: all 0.2s ease; + flex-shrink: 0; + opacity: 0; +} + +.mirrored-card-pipeline:hover { + background: rgba(56, 189, 248, 0.2); + border-color: rgba(56, 189, 248, 0.4); + color: #e0f2fe; +} + +.mirrored-card-delete, +.mirrored-card-clear, +.mirrored-card-link { background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.08); color: #555; @@ -12065,7 +13959,10 @@ body.helper-mode-active #dashboard-activity-feed:hover { opacity: 0; } -.mirrored-playlist-card:hover .mirrored-card-delete { +.mirrored-playlist-card:hover .mirrored-card-delete, +.mirrored-playlist-card:hover .mirrored-card-clear, +.mirrored-playlist-card:hover .mirrored-card-link, +.mirrored-playlist-card:hover .mirrored-card-pipeline { opacity: 1; } @@ -12076,28 +13973,6 @@ body.helper-mode-active #dashboard-activity-feed:hover { transform: scale(1.1); } -.mirrored-card-clear { - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.08); - color: #555; - cursor: pointer; - font-size: 14px; - padding: 0; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - transition: all 0.2s ease; - flex-shrink: 0; - opacity: 0; -} - -.mirrored-playlist-card:hover .mirrored-card-clear { - opacity: 1; -} - .mirrored-card-clear:hover { color: #a78bfa; background: rgba(167, 139, 250, 0.15); @@ -12105,6 +13980,13 @@ body.helper-mode-active #dashboard-activity-feed:hover { transform: scale(1.1); } +.mirrored-card-link:hover { + color: #64b5f6; + background: rgba(100, 181, 246, 0.15); + border-color: rgba(100, 181, 246, 0.3); + transform: scale(1.1); +} + .discovery-ratio { font-size: 0.8em; color: rgba(255, 255, 255, 0.4); @@ -13237,6 +15119,19 @@ body.helper-mode-active #dashboard-activity-feed:hover { box-shadow: 0 8px 24px rgba(var(--accent-rgb), 0.4); } +.mirrored-btn-pipeline { + background: rgba(56, 189, 248, 0.12); + color: #7dd3fc; + border: 1px solid rgba(56, 189, 248, 0.28) !important; +} + +.mirrored-btn-pipeline:hover { + background: rgba(56, 189, 248, 0.2); + border-color: rgba(56, 189, 248, 0.45) !important; + color: #e0f2fe; + transform: translateY(-1px); +} + .mirrored-btn-delete { background: rgba(244, 67, 54, 0.12); color: #ef5350; @@ -15269,6 +17164,10 @@ body.helper-mode-active #dashboard-activity-feed:hover { border-color: rgba(29, 185, 84, 0.3); } +#itunes-link-url-history .url-history-pill:hover { + border-color: rgba(250, 88, 106, 0.3); +} + /* Playlist URL input section (YouTube, Deezer) */ .youtube-input-section { display: flex; @@ -15288,7 +17187,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { #youtube-url-input, #deezer-url-input, -#spotify-public-url-input { +#spotify-public-url-input, +#itunes-link-url-input { flex: 1; background: transparent; border: none; @@ -15302,14 +17202,16 @@ body.helper-mode-active #dashboard-activity-feed:hover { #youtube-url-input::placeholder, #deezer-url-input::placeholder, -#spotify-public-url-input::placeholder { +#spotify-public-url-input::placeholder, +#itunes-link-url-input::placeholder { color: rgba(255, 255, 255, 0.3); font-weight: 400; } #youtube-parse-btn, #deezer-parse-btn, -#spotify-public-parse-btn { +#spotify-public-parse-btn, +#itunes-link-parse-btn { flex-shrink: 0; padding: 10px 22px; border: none; @@ -15359,7 +17261,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { #youtube-parse-btn:disabled, #deezer-parse-btn:disabled, -#spotify-public-parse-btn:disabled { +#spotify-public-parse-btn:disabled, +#itunes-link-parse-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; @@ -15383,22 +17286,44 @@ body.helper-mode-active #dashboard-activity-feed:hover { box-shadow: 0 2px 8px rgba(29, 185, 84, 0.2); } +#itunes-link-parse-btn { + background: linear-gradient(135deg, #fa586a, #ff7a59); + color: #fff; + box-shadow: 0 2px 8px rgba(250, 88, 106, 0.2); +} + #spotify-public-parse-btn:hover { background: linear-gradient(135deg, #1ed760, #2de86e); box-shadow: 0 4px 16px rgba(29, 185, 84, 0.3); transform: translateY(-1px); } +#itunes-link-parse-btn:hover { + background: linear-gradient(135deg, #ff6a7a, #ff8b6c); + box-shadow: 0 4px 16px rgba(250, 88, 106, 0.3); + transform: translateY(-1px); +} + #spotify-public-parse-btn:active { transform: translateY(0); box-shadow: 0 1px 4px rgba(29, 185, 84, 0.2); } +#itunes-link-parse-btn:active { + transform: translateY(0); + box-shadow: 0 1px 4px rgba(250, 88, 106, 0.2); +} + #spotify-public-tab-content .youtube-input-section:focus-within { border-color: rgba(29, 185, 84, 0.25); box-shadow: 0 0 16px rgba(29, 185, 84, 0.08); } +#itunes-link-tab-content .youtube-input-section:focus-within { + border-color: rgba(250, 88, 106, 0.25); + box-shadow: 0 0 16px rgba(250, 88, 106, 0.08); +} + /* Right Sidebar */ .sync-sidebar { gap: 20px; @@ -15632,7 +17557,10 @@ body.helper-mode-active #dashboard-activity-feed:hover { .youtube-playlist-card, .tidal-playlist-card, .deezer-playlist-card, -.spotify-public-card { +.spotify-public-card, +.listenbrainz-playlist-card, +.lastfm-playlist-card, +.soulsync-discovery-playlist-card { background: rgba(18, 18, 22, 0.9); backdrop-filter: blur(12px); border-radius: 16px; @@ -15653,7 +17581,10 @@ body.helper-mode-active #dashboard-activity-feed:hover { .youtube-playlist-card::before, .tidal-playlist-card::before, .deezer-playlist-card::before, -.spotify-public-card::before { +.spotify-public-card::before, +.listenbrainz-playlist-card::before, +.lastfm-playlist-card::before, +.soulsync-discovery-playlist-card::before { content: ''; position: absolute; top: 0; @@ -15668,23 +17599,35 @@ body.helper-mode-active #dashboard-activity-feed:hover { .tidal-playlist-card::before { background: linear-gradient(90deg, transparent, rgba(255, 102, 0, 0.4), transparent); } .deezer-playlist-card::before { background: linear-gradient(90deg, transparent, rgba(162, 56, 255, 0.4), transparent); } .spotify-public-card::before { background: linear-gradient(90deg, transparent, rgba(29, 185, 84, 0.4), transparent); } +.listenbrainz-playlist-card::before { background: linear-gradient(90deg, transparent, rgba(235, 116, 59, 0.4), transparent); } +.lastfm-playlist-card::before { background: linear-gradient(90deg, transparent, rgba(213, 16, 7, 0.4), transparent); } +.soulsync-discovery-playlist-card::before { background: linear-gradient(90deg, transparent, rgba(20, 184, 166, 0.4), transparent); } /* Hover — brand glow */ .youtube-playlist-card:hover { border-color: rgba(255, 0, 0, 0.15); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px rgba(255, 0, 0, 0.06); transform: translateY(-2px); } .tidal-playlist-card:hover { border-color: rgba(255, 102, 0, 0.15); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px rgba(255, 102, 0, 0.06); transform: translateY(-2px); } .deezer-playlist-card:hover { border-color: rgba(162, 56, 255, 0.15); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px rgba(162, 56, 255, 0.06); transform: translateY(-2px); } .spotify-public-card:hover { border-color: rgba(29, 185, 84, 0.15); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px rgba(29, 185, 84, 0.06); transform: translateY(-2px); } +.listenbrainz-playlist-card:hover { border-color: rgba(235, 116, 59, 0.15); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px rgba(235, 116, 59, 0.06); transform: translateY(-2px); } +.lastfm-playlist-card:hover { border-color: rgba(213, 16, 7, 0.15); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px rgba(213, 16, 7, 0.06); transform: translateY(-2px); } +.soulsync-discovery-playlist-card:hover { border-color: rgba(20, 184, 166, 0.15); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px rgba(20, 184, 166, 0.06); transform: translateY(-2px); } .youtube-playlist-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(255, 0, 0, 0.7), transparent); } .tidal-playlist-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(255, 102, 0, 0.7), transparent); } .deezer-playlist-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(162, 56, 255, 0.7), transparent); } .spotify-public-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(29, 185, 84, 0.7), transparent); } +.listenbrainz-playlist-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(235, 116, 59, 0.7), transparent); } +.lastfm-playlist-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(213, 16, 7, 0.7), transparent); } +.soulsync-discovery-playlist-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(20, 184, 166, 0.7), transparent); } /* Source icons */ .youtube-playlist-card .playlist-card-icon, .tidal-playlist-card .playlist-card-icon, .deezer-playlist-card .playlist-card-icon, -.spotify-public-card .playlist-card-icon { +.spotify-public-card .playlist-card-icon, +.listenbrainz-playlist-card .playlist-card-icon, +.lastfm-playlist-card .playlist-card-icon, +.soulsync-discovery-playlist-card .playlist-card-icon { width: 40px; height: 40px; border-radius: 10px; @@ -15702,21 +17645,33 @@ body.helper-mode-active #dashboard-activity-feed:hover { .tidal-playlist-card .playlist-card-icon { background: rgba(255, 102, 0, 0.12); border: 1px solid rgba(255, 102, 0, 0.2); color: #ff6600; font-size: 16px; } .deezer-playlist-card .playlist-card-icon { background: rgba(162, 56, 255, 0.12); border: 1px solid rgba(162, 56, 255, 0.2); color: #a238ff; } .spotify-public-card .playlist-card-icon { background: rgba(29, 185, 84, 0.12); border: 1px solid rgba(29, 185, 84, 0.2); color: #1DB954; } +.listenbrainz-playlist-card .playlist-card-icon { background: rgba(235, 116, 59, 0.12); border: 1px solid rgba(235, 116, 59, 0.2); color: #eb743b; } +.lastfm-playlist-card .playlist-card-icon { background: rgba(213, 16, 7, 0.12); border: 1px solid rgba(213, 16, 7, 0.2); color: #d51007; } +.soulsync-discovery-playlist-card .playlist-card-icon { background: rgba(20, 184, 166, 0.12); border: 1px solid rgba(20, 184, 166, 0.2); color: #14b8a6; } .youtube-playlist-card .playlist-card-content, .tidal-playlist-card .playlist-card-content, .deezer-playlist-card .playlist-card-content, -.spotify-public-card .playlist-card-content { flex: 1; min-width: 0; } +.spotify-public-card .playlist-card-content, +.listenbrainz-playlist-card .playlist-card-content, +.lastfm-playlist-card .playlist-card-content, +.soulsync-discovery-playlist-card .playlist-card-content { flex: 1; min-width: 0; } .youtube-playlist-card .playlist-card-name, .tidal-playlist-card .playlist-card-name, .deezer-playlist-card .playlist-card-name, -.spotify-public-card .playlist-card-name { font-size: 15px; font-weight: 600; color: rgba(255, 255, 255, 0.9); margin-bottom: 4px; } +.spotify-public-card .playlist-card-name, +.listenbrainz-playlist-card .playlist-card-name, +.lastfm-playlist-card .playlist-card-name, +.soulsync-discovery-playlist-card .playlist-card-name { font-size: 15px; font-weight: 600; color: rgba(255, 255, 255, 0.9); margin-bottom: 4px; } .youtube-playlist-card .playlist-card-info, .tidal-playlist-card .playlist-card-info, .deezer-playlist-card .playlist-card-info, -.spotify-public-card .playlist-card-info { font-size: 13px; color: rgba(255, 255, 255, 0.4); display: flex; align-items: center; gap: 10px; } +.spotify-public-card .playlist-card-info, +.listenbrainz-playlist-card .playlist-card-info, +.lastfm-playlist-card .playlist-card-info, +.soulsync-discovery-playlist-card .playlist-card-info { font-size: 13px; color: rgba(255, 255, 255, 0.4); display: flex; align-items: center; gap: 10px; } .youtube-playlist-card .playlist-card-track-count { color: rgba(255, 255, 255, 0.7); } .youtube-playlist-card .playlist-card-phase-text { font-weight: 500; } @@ -15726,7 +17681,10 @@ body.helper-mode-active #dashboard-activity-feed:hover { .youtube-playlist-card .playlist-card-action-btn, .tidal-playlist-card .playlist-card-action-btn, .deezer-playlist-card .playlist-card-action-btn, -.spotify-public-card .playlist-card-action-btn { +.spotify-public-card .playlist-card-action-btn, +.listenbrainz-playlist-card .playlist-card-action-btn, +.lastfm-playlist-card .playlist-card-action-btn, +.soulsync-discovery-playlist-card .playlist-card-action-btn { background: linear-gradient(135deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 100%); border: 1px solid rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.8); @@ -15745,7 +17703,10 @@ body.helper-mode-active #dashboard-activity-feed:hover { .youtube-playlist-card .playlist-card-action-btn::before, .tidal-playlist-card .playlist-card-action-btn::before, .deezer-playlist-card .playlist-card-action-btn::before, -.spotify-public-card .playlist-card-action-btn::before { +.spotify-public-card .playlist-card-action-btn::before, +.listenbrainz-playlist-card .playlist-card-action-btn::before, +.lastfm-playlist-card .playlist-card-action-btn::before, +.soulsync-discovery-playlist-card .playlist-card-action-btn::before { content: ''; position: absolute; inset: 0; @@ -15758,21 +17719,33 @@ body.helper-mode-active #dashboard-activity-feed:hover { .tidal-playlist-card .playlist-card-action-btn::before { background: linear-gradient(135deg, rgba(255, 102, 0, 0.2), rgba(255, 102, 0, 0.05)); } .deezer-playlist-card .playlist-card-action-btn::before { background: linear-gradient(135deg, rgba(162, 56, 255, 0.2), rgba(162, 56, 255, 0.05)); } .spotify-public-card .playlist-card-action-btn::before { background: linear-gradient(135deg, rgba(29, 185, 84, 0.2), rgba(29, 185, 84, 0.05)); } +.listenbrainz-playlist-card .playlist-card-action-btn::before { background: linear-gradient(135deg, rgba(235, 116, 59, 0.2), rgba(235, 116, 59, 0.05)); } +.lastfm-playlist-card .playlist-card-action-btn::before { background: linear-gradient(135deg, rgba(213, 16, 7, 0.2), rgba(213, 16, 7, 0.05)); } +.soulsync-discovery-playlist-card .playlist-card-action-btn::before { background: linear-gradient(135deg, rgba(20, 184, 166, 0.2), rgba(20, 184, 166, 0.05)); } .youtube-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before, .tidal-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before, .deezer-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before, -.spotify-public-card .playlist-card-action-btn:hover:not(:disabled)::before { opacity: 1; } +.spotify-public-card .playlist-card-action-btn:hover:not(:disabled)::before, +.listenbrainz-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before, +.lastfm-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before, +.soulsync-discovery-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before { opacity: 1; } .youtube-playlist-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(255, 0, 0, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(255, 0, 0, 0.15); } .tidal-playlist-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(255, 102, 0, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(255, 102, 0, 0.15); } .deezer-playlist-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(162, 56, 255, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(162, 56, 255, 0.15); } .spotify-public-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(29, 185, 84, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(29, 185, 84, 0.15); } +.listenbrainz-playlist-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(235, 116, 59, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(235, 116, 59, 0.15); } +.lastfm-playlist-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(213, 16, 7, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(213, 16, 7, 0.15); } +.soulsync-discovery-playlist-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(20, 184, 166, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(20, 184, 166, 0.15); } .youtube-playlist-card .playlist-card-action-btn:disabled, .tidal-playlist-card .playlist-card-action-btn:disabled, .deezer-playlist-card .playlist-card-action-btn:disabled, -.spotify-public-card .playlist-card-action-btn:disabled { +.spotify-public-card .playlist-card-action-btn:disabled, +.listenbrainz-playlist-card .playlist-card-action-btn:disabled, +.lastfm-playlist-card .playlist-card-action-btn:disabled, +.soulsync-discovery-playlist-card .playlist-card-action-btn:disabled { background: rgba(255, 255, 255, 0.03); border-color: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.2); @@ -34233,44 +36206,10 @@ div.artist-hero-badge { margin-top: 60px; } -.listenbrainz-playlist-card { - background: linear-gradient(135deg, #eb743b 0%, #d26230 100%); - display: flex; - align-items: center; - justify-content: center; - position: relative; - overflow: hidden; -} - -.listenbrainz-playlist-card::before { - content: ''; - position: absolute; - top: -50%; - left: -50%; - width: 200%; - height: 200%; - background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%); - animation: pulse 3s ease-in-out infinite; -} - -.listenbrainz-icon { - font-size: 48px; - position: relative; - z-index: 1; - filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2)); -} - -@keyframes pulse { - - 0%, - 100% { - opacity: 0.5; - } - - 50% { - opacity: 0.8; - } -} +/* (Removed dead Discover-page LB card styling — solid orange gradient + * + .listenbrainz-icon { font-size: 48px } were orphaned rules that + * collided with the Sync-page LB tab cards. The legacy class was + * never instantiated in JS or HTML outside the new Sync tab.) */ /* ========================================= */ /* ========================================= */ @@ -59153,6 +61092,724 @@ body.reduce-effects .dash-card::after { /* Body overrides for embedded sub-grids — make them compact for bento */ +/* ===================================================================== + Quick Actions — asymmetric bento with per-tile signature animations. + Auto-Sync hero takes 2 rows on the left (equalizer bars rising). + Tools (rotating gear) + Automations (pulsing trigger→action flow) + stack on the right. Container-query driven, all sizes scale with + card width. + ===================================================================== */ +.dash-card--quick-actions { + overflow: hidden; + container-type: inline-size; + container-name: qaroot; +} + +.dash-card--quick-actions .dash-card__body { + min-height: 0; +} + +.qa-bento { + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); + gap: clamp(8px, 0.8cqw + 4px, 14px); + height: clamp(280px, 18cqw + 200px, 340px); +} + +.qa-tile { + position: relative; + isolation: isolate; + overflow: hidden; + min-width: 0; + border: 1px solid rgba(255, 255, 255, 0.08); + border-top: 1px solid rgba(255, 255, 255, 0.14); + border-radius: clamp(12px, 0.8cqw + 8px, 16px); + background: linear-gradient(135deg, + rgba(20, 20, 20, 0.95) 0%, + rgba(13, 13, 13, 0.98) 100%); + box-shadow: + 0 6px 24px rgba(0, 0, 0, 0.35), + inset 0 1px 0 rgba(255, 255, 255, 0.06); + color: #fff; + cursor: pointer; + text-align: left; + font: inherit; + display: grid; + transition: border-color 0.3s ease, box-shadow 0.3s ease, transform 0.3s ease; +} + +.qa-tile::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, + transparent 0%, + rgba(var(--accent-rgb), 0.4) 30%, + rgb(var(--accent-light-rgb)) 50%, + rgba(var(--accent-rgb), 0.4) 70%, + transparent 100%); + opacity: 0.55; + transition: opacity 0.35s ease; + z-index: 3; + pointer-events: none; +} + +.qa-tile:hover, +.qa-tile:focus-visible { + transform: translateY(-2px); + border-color: rgba(var(--accent-rgb), 0.32); + box-shadow: + 0 16px 40px rgba(0, 0, 0, 0.5), + 0 0 0 1px rgba(var(--accent-rgb), 0.18), + 0 0 26px rgba(var(--accent-rgb), 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.12); + outline: none; +} + +.qa-tile:hover::before, +.qa-tile:focus-visible::before { + opacity: 1; +} + +.qa-tile--hero { + grid-row: span 2; + padding: clamp(20px, 1.6cqw + 14px, 32px); + grid-template-rows: auto auto 1fr auto; + gap: clamp(10px, 1cqw + 4px, 18px); +} + +.qa-tile--minor { + padding: clamp(14px, 1.2cqw + 10px, 22px); + grid-template-columns: auto 1fr; + grid-template-rows: auto auto auto; + column-gap: 14px; + row-gap: clamp(4px, 0.5cqw, 8px); + align-items: center; +} + +.qa-tile__bg { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + overflow: hidden; +} + +/* Hero equalizer: 20 vertical bars at the bottom, each pulsing on its + own offset so the whole thing reads as a live audio waveform. */ +.qa-tile__eq { + position: absolute; + inset: auto 0 0 0; + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: clamp(3px, 0.4cqw, 6px); + padding: 0 clamp(14px, 1.4cqw + 8px, 24px); + height: 60%; + opacity: 0.35; + -webkit-mask-image: linear-gradient(180deg, transparent 0%, black 70%); + mask-image: linear-gradient(180deg, transparent 0%, black 70%); + transition: opacity 0.4s ease; +} + +.qa-tile__eq span { + flex: 1; + min-width: 2px; + border-radius: 3px 3px 0 0; + background: linear-gradient(180deg, + rgb(var(--accent-light-rgb)), + rgba(var(--accent-rgb), 0.3)); + transform-origin: bottom; + animation: qa-eq 3.2s ease-in-out infinite; +} + +.qa-tile__eq span:nth-child(1) { animation-delay: -0.10s; --eq-h: 0.30; } +.qa-tile__eq span:nth-child(2) { animation-delay: -0.30s; --eq-h: 0.55; } +.qa-tile__eq span:nth-child(3) { animation-delay: -0.50s; --eq-h: 0.80; } +.qa-tile__eq span:nth-child(4) { animation-delay: -0.70s; --eq-h: 0.45; } +.qa-tile__eq span:nth-child(5) { animation-delay: -0.20s; --eq-h: 0.70; } +.qa-tile__eq span:nth-child(6) { animation-delay: -0.60s; --eq-h: 0.95; } +.qa-tile__eq span:nth-child(7) { animation-delay: -0.40s; --eq-h: 0.50; } +.qa-tile__eq span:nth-child(8) { animation-delay: -0.80s; --eq-h: 0.75; } +.qa-tile__eq span:nth-child(9) { animation-delay: -1.10s; --eq-h: 0.40; } +.qa-tile__eq span:nth-child(10) { animation-delay: -0.35s; --eq-h: 0.65; } +.qa-tile__eq span:nth-child(11) { animation-delay: -0.55s; --eq-h: 0.90; } +.qa-tile__eq span:nth-child(12) { animation-delay: -0.15s; --eq-h: 0.35; } +.qa-tile__eq span:nth-child(13) { animation-delay: -0.75s; --eq-h: 0.60; } +.qa-tile__eq span:nth-child(14) { animation-delay: -0.95s; --eq-h: 0.85; } +.qa-tile__eq span:nth-child(15) { animation-delay: -0.25s; --eq-h: 0.50; } +.qa-tile__eq span:nth-child(16) { animation-delay: -0.45s; --eq-h: 0.70; } +.qa-tile__eq span:nth-child(17) { animation-delay: -0.65s; --eq-h: 0.40; } +.qa-tile__eq span:nth-child(18) { animation-delay: -0.85s; --eq-h: 0.80; } +.qa-tile__eq span:nth-child(19) { animation-delay: -1.05s; --eq-h: 0.55; } +.qa-tile__eq span:nth-child(20) { animation-delay: -0.18s; --eq-h: 0.45; } + +@keyframes qa-eq { + 0%, 100% { transform: scaleY(calc(var(--eq-h, 0.5) * 0.55)); } + 50% { transform: scaleY(calc(var(--eq-h, 0.5) * 0.85)); } +} + +.qa-tile--hero:hover .qa-tile__eq, +.qa-tile--hero:focus-visible .qa-tile__eq { opacity: 0.65; } + +/* Hero playhead — slow vertical accent line that drifts across the + equalizer like a now-playing scrubber. Fades in/out at the edges so + the loop reset isn't visible. */ +.qa-tile--hero .qa-tile__bg::after { + content: ''; + position: absolute; + inset: auto 0 0 0; + height: 65%; + width: 1.5px; + background: linear-gradient(180deg, + transparent 0%, + rgba(var(--accent-rgb), 0.7) 40%, + rgba(var(--accent-rgb), 0.7) 85%, + transparent 100%); + filter: drop-shadow(0 0 4px rgba(var(--accent-rgb), 0.35)); + opacity: 0; + animation: qa-playhead 9s linear infinite; + pointer-events: none; +} + +@keyframes qa-playhead { + 0% { left: -4%; opacity: 0; } + 10% { opacity: 0.5; } + 85% { opacity: 0.5; } + 100% { left: 102%; opacity: 0; } +} + +@media (prefers-reduced-motion: reduce) { + .qa-tile--hero .qa-tile__bg::after { animation: none; opacity: 0; } + .qa-flow-line { animation: none; } +} + +/* Tools background: large gear icon rotating slowly off the right edge */ +.qa-tile__gear { + position: absolute; + top: 50%; + right: -38%; + width: 200%; + aspect-ratio: 1; + transform: translateY(-50%); + color: rgba(var(--accent-rgb), 0.08); + animation: qa-gear-rotate 28s linear infinite; +} + +.qa-tile__gear svg { width: 100%; height: 100%; } + +@keyframes qa-gear-rotate { + from { transform: translateY(-50%) rotate(0deg); } + to { transform: translateY(-50%) rotate(360deg); } +} + +.qa-tile--tools:hover .qa-tile__gear, +.qa-tile--tools:focus-visible .qa-tile__gear { + color: rgba(var(--accent-rgb), 0.14); + animation-duration: 12s; +} + +/* Automations background: 3-node trigger→action→notify flow centered + horizontally across the lower portion of the tile. Stays inside the + visible area regardless of how small the minor tile gets. Connecting + lines have a moving glow that travels left-to-right like a signal + propagating along a circuit. */ +.qa-tile__flow { + position: absolute; + left: clamp(10px, 1.4cqw + 6px, 18px); + right: clamp(10px, 1.4cqw + 6px, 18px); + bottom: clamp(10px, 1.4cqw + 6px, 18px); + display: flex; + align-items: center; + justify-content: space-between; + gap: clamp(4px, 0.5cqw, 8px); + opacity: 0.45; + transition: opacity 0.35s ease; +} + +.qa-flow-node { + width: clamp(16px, 1.4cqw + 8px, 24px); + aspect-ratio: 1; + border: 1.5px solid rgba(var(--accent-rgb), 0.6); + border-radius: 50%; + background: rgba(var(--accent-rgb), 0.1); + flex-shrink: 0; +} + +.qa-flow-node:nth-of-type(1) { animation: qa-flow-pulse 3.2s ease-in-out infinite; } +.qa-flow-node:nth-of-type(2) { animation: qa-flow-pulse 3.2s ease-in-out infinite; animation-delay: -1.07s; } +.qa-flow-node:nth-of-type(3) { animation: qa-flow-pulse 3.2s ease-in-out infinite; animation-delay: -2.13s; } + +.qa-flow-line { + position: relative; + flex: 1; + height: 2px; + border-radius: 2px; + background: rgba(var(--accent-rgb), 0.2); + overflow: hidden; +} + +/* Sweep is a separate pseudo so opacity can fade independently of the + position. Without the fade the reset jump from 100% → -60% was + visible as a snap each cycle. */ +.qa-flow-line::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 60%; + height: 100%; + background: linear-gradient(90deg, + transparent 0%, + rgb(var(--accent-light-rgb)) 50%, + transparent 100%); + transform: translateX(-100%); + opacity: 0; + animation: qa-flow-line-sweep 3.2s ease-in-out infinite; +} + +.qa-flow-line:nth-of-type(2)::before { animation-delay: -1.0s; } + +@keyframes qa-flow-line-sweep { + 0% { transform: translateX(-100%); opacity: 0; } + 15% { opacity: 1; } + 85% { transform: translateX(220%); opacity: 1; } + 100% { transform: translateX(220%); opacity: 0; } +} + +@keyframes qa-flow-pulse { + 0%, 100% { background: rgba(var(--accent-rgb), 0.1); box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0); } + 50% { background: rgba(var(--accent-rgb), 0.5); box-shadow: 0 0 0 5px rgba(var(--accent-rgb), 0.18); } +} + +.qa-tile--auto:hover .qa-tile__flow, +.qa-tile--auto:focus-visible .qa-tile__flow { opacity: 0.85; } + +@media (prefers-reduced-motion: reduce) { + .qa-tile__eq span, + .qa-tile__gear, + .qa-flow-node, + .qa-tile__pulse::after { animation: none !important; } +} + +/* Foreground content */ +.qa-tile__topline { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 8px; + grid-column: 1 / -1; + grid-row: 1; + min-width: 0; +} + +.qa-tile__kicker { + color: rgba(var(--accent-rgb), 0.85); + font-size: clamp(9px, 0.35cqw + 7px, 11px); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.18em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.qa-tile__pulse { + position: relative; + width: 8px; + height: 8px; + border-radius: 50%; + background: rgb(var(--accent-rgb)); + flex-shrink: 0; + box-shadow: 0 0 0 2px rgba(var(--accent-rgb), 0.22); +} + +.qa-tile__pulse::after { + content: ''; + position: absolute; + inset: -3px; + border-radius: 50%; + background: rgba(var(--accent-rgb), 0.5); + animation: qa-pulse-ring 2.2s ease-out infinite; + z-index: -1; +} + +@keyframes qa-pulse-ring { + 0% { transform: scale(0.5); opacity: 0.7; } + 100% { transform: scale(2.4); opacity: 0; } +} + +.qa-tile__icon { + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: clamp(44px, 3.2cqw + 26px, 56px); + height: clamp(44px, 3.2cqw + 26px, 56px); + border-radius: clamp(11px, 0.8cqw + 7px, 14px); + background: linear-gradient(135deg, + rgba(var(--accent-rgb), 0.18), + rgba(var(--accent-rgb), 0.05)); + border: 1px solid rgba(var(--accent-rgb), 0.2); + color: rgb(var(--accent-light-rgb)); + transition: background 0.3s ease, border-color 0.3s ease, transform 0.3s ease; +} + +.qa-tile__icon svg { width: 56%; height: 56%; } + +.qa-tile--hero .qa-tile__icon { grid-row: 2; } + +.qa-tile--minor .qa-tile__topline { grid-column: 2; grid-row: 1; } +.qa-tile--minor .qa-tile__icon { + grid-column: 1; + grid-row: 1 / span 3; + width: clamp(40px, 2.8cqw + 22px, 48px); + height: clamp(40px, 2.8cqw + 22px, 48px); +} +.qa-tile--minor .qa-tile__heading { grid-column: 2; grid-row: 2; } +.qa-tile--minor .qa-tile__cta { + grid-column: 2; + grid-row: 3; + margin-top: clamp(4px, 0.4cqw, 6px); +} + +.qa-tile:hover .qa-tile__icon, +.qa-tile:focus-visible .qa-tile__icon { + background: linear-gradient(135deg, + rgba(var(--accent-rgb), 0.32), + rgba(var(--accent-rgb), 0.1)); + border-color: rgba(var(--accent-rgb), 0.45); + transform: rotate(-3deg); +} + +.qa-tile__heading { + position: relative; + z-index: 1; + min-width: 0; +} + +.qa-tile--hero .qa-tile__heading { + grid-row: 3; + align-self: end; +} + +.qa-tile__title { + display: block; + color: #fff; + font-size: clamp(20px, 2.4cqw + 12px, 34px); + font-weight: 700; + line-height: 1.02; + letter-spacing: -0.026em; + margin-bottom: clamp(4px, 0.5cqw, 8px); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.qa-tile--minor .qa-tile__title { + font-size: clamp(16px, 1.4cqw + 10px, 22px); + margin-bottom: 2px; +} + +.qa-tile__desc { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + margin: 0; + color: rgba(255, 255, 255, 0.5); + font-size: clamp(11px, 0.5cqw + 9px, 13px); + line-height: 1.5; + min-width: 0; +} + +.qa-tile--minor .qa-tile__desc { + -webkit-line-clamp: 1; + font-size: clamp(10px, 0.3cqw + 9px, 11.5px); +} + +.qa-tile__cta { + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + gap: 8px; + color: rgb(var(--accent-light-rgb)); + font-size: clamp(11px, 0.4cqw + 9px, 13px); + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + min-width: 0; +} + +.qa-tile--hero .qa-tile__cta { + grid-row: 4; + padding-top: clamp(10px, 1cqw + 4px, 16px); + border-top: 1px solid rgba(255, 255, 255, 0.07); +} + +.qa-tile__cta-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.qa-tile__cta-arrow { + display: inline-flex; + align-items: center; + justify-content: center; + width: clamp(22px, 1.6cqw + 12px, 30px); + height: clamp(22px, 1.6cqw + 12px, 30px); + border-radius: 50%; + background: rgba(var(--accent-rgb), 0.14); + border: 1px solid rgba(var(--accent-rgb), 0.28); + color: rgb(var(--accent-light-rgb)); + transition: transform 0.3s ease, background 0.3s ease, border-color 0.3s ease; + flex-shrink: 0; +} + +.qa-tile__cta-arrow svg { width: 55%; height: 55%; } + +.qa-tile:hover .qa-tile__cta-arrow, +.qa-tile:focus-visible .qa-tile__cta-arrow { + transform: translateX(4px); + background: rgba(var(--accent-rgb), 0.28); + border-color: rgba(var(--accent-rgb), 0.5); +} + +@container qaroot (max-width: 560px) { + .qa-bento { + grid-template-columns: 1fr; + grid-template-rows: auto auto auto; + height: auto; + } + .qa-tile--hero { grid-row: auto; aspect-ratio: 4 / 3; } + .qa-tile--minor { min-height: 90px; } +} + +/* Legacy lane markup retired — keep an inert reference. */ +.dash-card--quick-actions-legacy { + overflow: hidden; +} + +.dash-card--quick-actions-legacy .dash-card__body { + min-height: 128px; +} + +.dashboard-action-grid { + position: relative; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + overflow: hidden; + isolation: isolate; + background: + radial-gradient(circle at 50% -20%, rgba(var(--accent-rgb), 0.16), transparent 44%), + linear-gradient(135deg, rgba(255,255,255,0.12), rgba(255,255,255,0.025)), + rgba(255, 255, 255, 0.035); + box-shadow: + inset 0 1px 0 rgba(255,255,255,0.08), + 0 12px 28px rgba(0,0,0,0.16), + 0 0 0 1px rgba(var(--accent-rgb), 0.04); +} + +.dashboard-action-grid::before { + content: ''; + position: absolute; + inset: 0 0 auto; + height: 2px; + background: linear-gradient(90deg, + transparent, + rgba(var(--accent-rgb), 0.4), + rgb(var(--accent-light-rgb)), + rgba(var(--accent-rgb), 0.4), + transparent); + opacity: 0.85; + pointer-events: none; + z-index: 2; +} + +.dashboard-action-lane { + position: relative; + z-index: 1; + min-width: 0; + min-height: 126px; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + padding: 16px; + border: 0; + background: + radial-gradient(circle at var(--lane-glow-x, 22%) 10%, var(--lane-glow), transparent 42%), + rgba(255, 255, 255, 0.032); + color: rgba(255, 255, 255, 0.88); + cursor: pointer; + text-align: left; + transition: background 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; +} + +.dashboard-action-lane + .dashboard-action-lane { + box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.07); +} + +.dashboard-action-lane::after { + content: ''; + position: absolute; + inset: 10px; + border-radius: 11px; + border: 1px solid transparent; + pointer-events: none; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.dashboard-action-lane:hover, +.dashboard-action-lane:focus-visible { + background: + radial-gradient(circle at var(--lane-glow-x, 22%) 10%, var(--lane-glow-strong), transparent 46%), + rgba(255, 255, 255, 0.06); + transform: translateY(-1px); +} + +.dashboard-action-lane:focus-visible { + outline: 2px solid rgba(var(--accent-rgb), 0.55); + outline-offset: -4px; +} + +.dashboard-action-lane:hover::after, +.dashboard-action-lane:focus-visible::after { + border-color: var(--lane-border); + background: rgba(255, 255, 255, 0.025); +} + +.dashboard-action-lane.tools { + --lane-glow-x: 18%; + --lane-glow: rgba(var(--accent-rgb), 0.12); + --lane-glow-strong: rgba(var(--accent-rgb), 0.22); + --lane-border: rgba(var(--accent-rgb), 0.26); + --lane-accent: rgb(var(--accent-light-rgb)); +} + +.dashboard-action-lane.auto-sync { + --lane-glow-x: 50%; + --lane-glow: rgba(var(--accent-rgb), 0.18); + --lane-glow-strong: rgba(var(--accent-rgb), 0.3); + --lane-border: rgba(var(--accent-rgb), 0.34); + --lane-accent: rgb(var(--accent-light-rgb)); +} + +.dashboard-action-lane.automations { + --lane-glow-x: 82%; + --lane-glow: rgba(var(--accent-rgb), 0.12); + --lane-glow-strong: rgba(var(--accent-rgb), 0.22); + --lane-border: rgba(var(--accent-rgb), 0.26); + --lane-accent: rgb(var(--accent-light-rgb)); +} + +.dashboard-action-icon { + width: 38px; + height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 11px; + background: rgba(var(--accent-rgb), 0.1); + border: 1px solid rgba(var(--accent-rgb), 0.18); + color: var(--lane-accent); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 0 16px rgba(var(--accent-rgb), 0.08); +} + +.dashboard-action-label { + display: block; + color: rgba(255, 255, 255, 0.92); + font-size: 14px; + font-weight: 800; + line-height: 1.1; +} + +.dashboard-action-copy { + display: block; + min-height: 28px; + margin-top: 4px; + color: rgba(255, 255, 255, 0.48); + font-size: 11px; + font-weight: 600; + line-height: 1.25; +} + +.dashboard-action-arrow { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--lane-accent); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.01em; +} + +.dashboard-action-arrow::after { + content: '›'; + font-size: 16px; + line-height: 1; + transform: translateY(-1px); + transition: transform 0.18s ease; +} + +.dashboard-action-lane:hover .dashboard-action-arrow::after, +.dashboard-action-lane:focus-visible .dashboard-action-arrow::after { + transform: translate(3px, -1px); +} + +@media (max-width: 1180px) { + .dashboard-action-lane { + padding: 14px 12px; + } + + .dashboard-action-copy { + min-height: 42px; + } +} + +@media (max-width: 760px) { + .dashboard-action-grid { + grid-template-columns: 1fr; + gap: 1px; + } + + .dashboard-action-lane { + min-height: 86px; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 12px; + } + + .dashboard-action-lane + .dashboard-action-lane { + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.07); + } + + .dashboard-action-copy { + min-height: 0; + } + + .dashboard-action-arrow { + align-self: center; + } +} + /* Service status: 3 service cards side-by-side in a tighter grid */ .dash-card[data-card="services"] .service-status-grid { display: grid; diff --git a/webui/static/sync-lastfm.js b/webui/static/sync-lastfm.js new file mode 100644 index 00000000..b54379a1 --- /dev/null +++ b/webui/static/sync-lastfm.js @@ -0,0 +1,131 @@ +// =================================================================== +// LAST.FM RADIO SYNC TAB +// =================================================================== +// Phase 1c.2 of the Discover-to-Sync unification. Surfaces the user's +// generated Last.fm Radio playlists as a Sync-page tab so they can be +// discovered + mirrored alongside ListenBrainz, Tidal, Qobuz, etc. +// +// Last.fm Radio playlists live in the same ``listenbrainz_playlists`` +// SQLite table as ListenBrainz playlists (with +// ``playlist_type='lastfm_radio'``) and run through the same +// ``openDownloadModalForListenBrainzPlaylist`` discovery flow. So this +// module is intentionally thin — list + render + click handoff. +// The refresh loop, discovery polling, sync→mirror creation, and the +// modal itself are all shared with the ListenBrainz tab. +// +// New Last.fm radios are GENERATED from the Discover page (with a +// seed track). This tab is for listing existing radios + syncing +// them to a mirror — not for generation. + +let _lastfmSyncPlaylists = []; + +async function loadLastfmSyncPlaylists() { + const container = document.getElementById('lastfm-sync-playlist-container'); + const refreshBtn = document.getElementById('lastfm-sync-refresh-btn'); + if (!container) return; + + container.innerHTML = `
🔄 Loading Last.fm Radio playlists...
`; + if (refreshBtn) { + refreshBtn.disabled = true; + refreshBtn.textContent = '🔄 Loading...'; + } + + try { + const resp = await fetch('/api/discover/listenbrainz/lastfm-radio'); + const data = await resp.json(); + if (!data.success && data.error) { + container.innerHTML = `
❌ ${escapeHtml(data.error)}
`; + return; + } + _lastfmSyncPlaylists = data.playlists || []; + renderLastfmSyncPlaylists(); + console.log(`📻 Last.fm Sync tab loaded: ${_lastfmSyncPlaylists.length} radios`); + } catch (err) { + container.innerHTML = `
❌ Error loading Last.fm radios: ${err.message}
`; + if (typeof showToast === 'function') { + showToast(`Error loading Last.fm radios: ${err.message}`, 'error'); + } + } finally { + if (refreshBtn) { + refreshBtn.disabled = false; + refreshBtn.textContent = '🔄 Refresh'; + } + } +} + +function renderLastfmSyncPlaylists() { + const container = document.getElementById('lastfm-sync-playlist-container'); + if (!container) return; + + if (_lastfmSyncPlaylists.length === 0) { + container.innerHTML = `
No Last.fm Radio playlists yet. Generate one from the Discover page by picking a seed track.
`; + return; + } + + container.innerHTML = _lastfmSyncPlaylists.map(p => { + const inner = p.playlist || p; + const mbid = (inner.identifier || '').split('/').pop() || inner.id || ''; + const title = inner.title || 'Last.fm Radio'; + const creator = inner.creator || 'Last.fm'; + let count = 0; + if (inner.track_count) count = inner.track_count; + else if (inner.annotation && inner.annotation.track_count) count = inner.annotation.track_count; + else if (Array.isArray(inner.track) && inner.track.length > 0) count = inner.track.length; + + const state = (typeof listenbrainzPlaylistStates !== 'undefined' + && listenbrainzPlaylistStates[mbid]) || null; + const phase = state && state.phase ? state.phase : 'fresh'; + const phaseText = (typeof getPhaseText === 'function') + ? getPhaseText(phase) : (phase === 'fresh' ? 'Ready to discover' : phase); + const phaseColor = (typeof getPhaseColor === 'function') + ? getPhaseColor(phase) : '#999'; + const buttonText = (typeof getActionButtonText === 'function') + ? getActionButtonText(phase) : 'Discover'; + + return ` +
+
📻
+
+
${escapeHtml(title)}
+
+ ${count} tracks + by ${escapeHtml(creator)} + ${phaseText} +
+
+
+ +
+ `; + }).join(''); + + container.querySelectorAll('.lastfm-playlist-card').forEach(card => { + card.addEventListener('click', () => { + const mbid = card.dataset.lbMbid; + const title = card.dataset.lbTitle; + // Reuses the LB Sync-tab click handler — Last.fm radios are + // stored in the same table + matched by the same discovery + // worker, so the click flow is byte-identical. + if (typeof handleListenBrainzSyncCardClick === 'function') { + handleListenBrainzSyncCardClick(mbid, title); + } + }); + }); + + // Reuse the shared refresh loop from sync-listenbrainz.js — it + // already iterates Last.fm cards alongside LB cards. + if (typeof _startLbSyncCardRefreshLoop === 'function') { + const tab = document.getElementById('lastfm-sync-tab-content'); + if (tab && tab.classList.contains('active')) { + _startLbSyncCardRefreshLoop(); + } + } +} + +document.addEventListener('DOMContentLoaded', () => { + const btn = document.getElementById('lastfm-sync-refresh-btn'); + if (btn) btn.addEventListener('click', loadLastfmSyncPlaylists); +}); diff --git a/webui/static/sync-listenbrainz.js b/webui/static/sync-listenbrainz.js new file mode 100644 index 00000000..b8415ce6 --- /dev/null +++ b/webui/static/sync-listenbrainz.js @@ -0,0 +1,364 @@ +// =================================================================== +// LISTENBRAINZ SYNC TAB +// =================================================================== +// Phase 1c.1 of the Discover-to-Sync unification. Renders the user's +// cached ListenBrainz playlists as a Sync-page tab so they participate +// in the same discovery → mirror → auto-sync pipeline as Spotify / +// Tidal / Qobuz / etc. — without forcing the user to detour through +// the Discover page. +// +// All the heavy lifting (modal, discovery state machine, sync) already +// lives in sync-services.js + discover.js. This file is just the +// Sync-page entry point: list the cached playlists, render cards, +// pre-fetch tracks on click, then hand off to +// ``openDownloadModalForListenBrainzPlaylist`` which owns the rest. + +let _lbSyncCurrentType = 'created_for_user'; +let _lbSyncPlaylistsByType = {}; // {type: [playlist...]} cache + +async function loadListenBrainzSyncPlaylists() { + const container = document.getElementById('listenbrainz-sync-playlist-container'); + const refreshBtn = document.getElementById('listenbrainz-sync-refresh-btn'); + if (!container) return; + + container.innerHTML = `
🔄 Loading ListenBrainz playlists...
`; + if (refreshBtn) { + refreshBtn.disabled = true; + refreshBtn.textContent = '🔄 Loading...'; + } + + // Fetch all three LB playlist categories in parallel. The Discover + // page does the same; we mirror its behavior for state-cache parity. + try { + const [createdFor, userPl, collab] = await Promise.all([ + fetch('/api/discover/listenbrainz/created-for').then(r => r.json()), + fetch('/api/discover/listenbrainz/user-playlists').then(r => r.json()), + fetch('/api/discover/listenbrainz/collaborative').then(r => r.json()), + ]); + + // Auth-failure responses look like `{success:false, error:'...'}`. + // Surface them to the user instead of pretending the list was empty. + const anyUnauthed = !createdFor.success && ( + (createdFor.error || '').toLowerCase().includes('not authenticated') + ); + if (anyUnauthed) { + container.innerHTML = `
ListenBrainz not connected. Add your token in Settings → Connections to see your playlists here.
`; + return; + } + + _lbSyncPlaylistsByType = { + created_for_user: createdFor.playlists || [], + user_created: userPl.playlists || [], + collaborative: collab.playlists || [], + }; + renderListenBrainzSyncPlaylists(); + + console.log( + `🎧 ListenBrainz Sync tab loaded: ${_lbSyncPlaylistsByType.created_for_user.length} for-you, ` + + `${_lbSyncPlaylistsByType.user_created.length} user, ` + + `${_lbSyncPlaylistsByType.collaborative.length} collaborative` + ); + } catch (err) { + container.innerHTML = `
❌ Error loading ListenBrainz playlists: ${err.message}
`; + if (typeof showToast === 'function') { + showToast(`Error loading ListenBrainz playlists: ${err.message}`, 'error'); + } + } finally { + if (refreshBtn) { + refreshBtn.disabled = false; + refreshBtn.textContent = '🔄 Refresh'; + } + } +} + +function renderListenBrainzSyncPlaylists() { + const container = document.getElementById('listenbrainz-sync-playlist-container'); + if (!container) return; + + const playlists = _lbSyncPlaylistsByType[_lbSyncCurrentType] || []; + if (playlists.length === 0) { + const empty = { + created_for_user: 'No "For You" playlists yet. ListenBrainz publishes Weekly Exploration / Top Discoveries on its own schedule.', + user_created: 'You haven\'t created any ListenBrainz playlists yet.', + collaborative: 'No collaborative playlists.', + }[_lbSyncCurrentType] || 'No playlists.'; + container.innerHTML = `
${empty}
`; + return; + } + + container.innerHTML = playlists.map(p => { + // The Discover-page endpoints wrap each entry in JSPF shape: + // { playlist: { identifier: 'https://.../', title, creator, + // annotation: {track_count}, track: [...] } } + // Pull out the inner playlist object + extract the mbid from the URL. + const inner = p.playlist || p; + const mbid = (inner.identifier || '').split('/').pop() || inner.id || ''; + const title = inner.title || inner.name || 'ListenBrainz Playlist'; + const creator = inner.creator || 'ListenBrainz'; + let count = 0; + if (inner.track_count) { + count = inner.track_count; + } else if (inner.annotation && inner.annotation.track_count) { + count = inner.annotation.track_count; + } else if (Array.isArray(inner.track) && inner.track.length > 0) { + count = inner.track.length; + } + // Reuse listenbrainzPlaylistStates so the modal state survives + // tab switches (matches Discover-page behavior). + const state = (typeof listenbrainzPlaylistStates !== 'undefined' + && listenbrainzPlaylistStates[mbid]) || null; + const phase = state && state.phase ? state.phase : 'fresh'; + const phaseText = (typeof getPhaseText === 'function') + ? getPhaseText(phase) + : (phase === 'fresh' ? 'Ready to discover' : phase); + const phaseColor = (typeof getPhaseColor === 'function') + ? getPhaseColor(phase) + : '#999'; + const buttonText = (typeof getActionButtonText === 'function') + ? getActionButtonText(phase) + : 'Discover'; + + return ` +
+
🎧
+
+
${escapeHtml(title)}
+
+ ${count} tracks + by ${escapeHtml(creator)} + ${phaseText} +
+
+
+ +
+ `; + }).join(''); + + // Wire click handlers. + container.querySelectorAll('.listenbrainz-playlist-card').forEach(card => { + card.addEventListener('click', () => { + const mbid = card.dataset.lbMbid; + const title = card.dataset.lbTitle; + handleListenBrainzSyncCardClick(mbid, title); + }); + }); + + // If the tab is currently visible, kick the refresh loop so cards + // start showing live state immediately. ``_startLbSyncCardRefreshLoop`` + // is idempotent + self-stops when the tab loses focus. + const tab = document.getElementById('listenbrainz-sync-tab-content'); + if (tab && tab.classList.contains('active')) { + _startLbSyncCardRefreshLoop(); + } +} + +async function handleListenBrainzSyncCardClick(playlistMbid, playlistTitle) { + if (!playlistMbid) { + if (typeof showToast === 'function') showToast('Missing playlist ID', 'error'); + return; + } + + // The Discover-page LB flow expects ``listenbrainzTracksCache[mbid]`` + // to be populated before opening the modal — it pulls tracks from + // there when constructing the discovery state. On the Sync tab the + // user may click an LB card without ever visiting Discover, so we + // fetch + cache the tracks on demand here. + try { + if (typeof showLoadingOverlay === 'function') { + showLoadingOverlay(`Loading ${playlistTitle}...`); + } + + if (typeof listenbrainzTracksCache === 'undefined') { + window.listenbrainzTracksCache = {}; + } + let tracks = listenbrainzTracksCache[playlistMbid]; + if (!tracks || tracks.length === 0) { + const resp = await fetch(`/api/discover/listenbrainz/playlist/${encodeURIComponent(playlistMbid)}`); + if (!resp.ok) { + throw new Error(`Failed to load playlist tracks (${resp.status})`); + } + const data = await resp.json(); + tracks = (data.tracks || []).map(t => ({ + track_name: t.track_name || '', + artist_name: t.artist_name || '', + album_name: t.album_name || '', + duration_ms: t.duration_ms || 0, + mbid: t.recording_mbid || t.mbid || '', + release_mbid: t.release_mbid || '', + album_cover_url: t.album_cover_url || '', + })); + listenbrainzTracksCache[playlistMbid] = tracks; + } + + if (!tracks || tracks.length === 0) { + throw new Error('Playlist has no tracks'); + } + + if (typeof hideLoadingOverlay === 'function') hideLoadingOverlay(); + + // Hand off to the existing Discover-page modal opener. It owns + // state init, discovery kickoff, polling, and the sync→mirror + // step. The Sync tab is just a different entry point. + if (typeof openDownloadModalForListenBrainzPlaylist === 'function') { + await openDownloadModalForListenBrainzPlaylist(playlistMbid, playlistTitle); + } else { + throw new Error('LB discovery modal not available — discover.js may be missing'); + } + } catch (err) { + if (typeof hideLoadingOverlay === 'function') hideLoadingOverlay(); + console.error('Error opening LB playlist from Sync tab:', err); + if (typeof showToast === 'function') { + showToast(`Could not open playlist: ${err.message}`, 'error'); + } + } +} + +// Live card refresh — keeps the Sync-tab cards in sync with the +// canonical ``listenbrainzPlaylistStates`` dict that the discovery / +// sync polling loops own. Tidal does this via explicit +// ``updateTidalCardPhase`` / ``updateTidalCardProgress`` calls +// sprinkled through its polling code; we get the same UX with a +// single 500ms tick that reads the shared state. The loop only runs +// while the LB tab is the active Sync tab so it's cheap. + +let _lbSyncCardRefreshInterval = null; + +function _refreshOneLbSyncCard(card) { + const mbid = card.dataset.lbMbid; + if (!mbid) return; + const state = (typeof listenbrainzPlaylistStates !== 'undefined') + ? listenbrainzPlaylistStates[mbid] : null; + if (!state) return; + + const phase = state.phase || 'fresh'; + const phaseEl = card.querySelector('.playlist-card-phase-text'); + if (phaseEl) { + const text = (typeof getPhaseText === 'function') ? getPhaseText(phase) : phase; + const color = (typeof getPhaseColor === 'function') ? getPhaseColor(phase) : ''; + if (phaseEl.textContent !== text) phaseEl.textContent = text; + if (color) phaseEl.style.color = color; + } + + const btnEl = card.querySelector('.playlist-card-action-btn'); + if (btnEl) { + const btnText = (typeof getActionButtonText === 'function') + ? getActionButtonText(phase) : btnEl.textContent; + if (btnEl.textContent !== btnText) btnEl.textContent = btnText; + } + + // Discovery progress mirrors Tidal's per-card text: + // "♪ / ✓ / ✗ / %". + // During sync, swap to the sync progress payload the LB sync poller + // writes into state.lastSyncProgress (same shape Tidal uses). + const progEl = card.querySelector('.playlist-card-progress'); + if (!progEl) return; + if (phase === 'fresh') { + progEl.classList.add('hidden'); + progEl.textContent = ''; + return; + } + + if ((phase === 'syncing' || phase === 'sync_complete') && state.lastSyncProgress) { + const sp = state.lastSyncProgress; + const matched = sp.matched_tracks || sp.spotify_matches || 0; + const total = sp.total_tracks || sp.spotify_total || 0; + const failed = (sp.failed_tracks !== undefined) + ? sp.failed_tracks : Math.max(0, total - matched); + const pct = total > 0 ? Math.round((matched / total) * 100) : 0; + progEl.textContent = `♪ ${total} / ✓ ${matched} / ✗ ${failed} / ${pct}%`; + progEl.classList.remove('hidden'); + return; + } + + const total = state.spotify_total || state.spotifyTotal || 0; + const matched = state.spotify_matches || state.spotifyMatches || 0; + const failed = Math.max(0, total - matched); + const pct = total > 0 ? Math.round((matched / total) * 100) + : (state.discovery_progress || state.discoveryProgress || 0); + progEl.textContent = `♪ ${total} / ✓ ${matched} / ✗ ${failed} / ${pct}%`; + progEl.classList.remove('hidden'); +} + +function _refreshAllLbSyncCards() { + // Both LB and Last.fm-radio tabs render MB-track cards that share + // the ``listenbrainzPlaylistStates`` state machine (Last.fm radios + // are stored in the same listenbrainz_playlists table with + // ``playlist_type='lastfm_radio'``). The refresh loop iterates + // visible cards from either tab so we don't need a second loop. + document.querySelectorAll( + '#listenbrainz-sync-tab-content .listenbrainz-playlist-card, ' + + '#lastfm-sync-tab-content .lastfm-playlist-card' + ).forEach(_refreshOneLbSyncCard); +} + +function _isMbStyleSyncTabActive() { + const lb = document.getElementById('listenbrainz-sync-tab-content'); + const lfm = document.getElementById('lastfm-sync-tab-content'); + return (lb && lb.classList.contains('active')) + || (lfm && lfm.classList.contains('active')); +} + +function _startLbSyncCardRefreshLoop() { + if (_lbSyncCardRefreshInterval) return; + _lbSyncCardRefreshInterval = setInterval(() => { + if (!_isMbStyleSyncTabActive()) { + _stopLbSyncCardRefreshLoop(); + return; + } + _refreshAllLbSyncCards(); + }, 500); + // Initial tick so the user doesn't wait 500ms for the first update. + _refreshAllLbSyncCards(); +} + +function _stopLbSyncCardRefreshLoop() { + if (_lbSyncCardRefreshInterval) { + clearInterval(_lbSyncCardRefreshInterval); + _lbSyncCardRefreshInterval = null; + } +} + +// Sub-tab switching (For You / My Playlists / Collaborative). +function _initListenBrainzSyncSubTabs() { + const subTabContainer = document.querySelector('#listenbrainz-sync-tab-content .listenbrainz-sub-tabs'); + if (!subTabContainer) return; + subTabContainer.addEventListener('click', (e) => { + const btn = e.target.closest('.listenbrainz-sub-tab-btn'); + if (!btn) return; + const newType = btn.dataset.lbType; + if (!newType || newType === _lbSyncCurrentType) return; + + subTabContainer.querySelectorAll('.listenbrainz-sub-tab-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + + _lbSyncCurrentType = newType; + renderListenBrainzSyncPlaylists(); + }); +} + +// Refresh button. +function _initListenBrainzSyncRefreshBtn() { + const btn = document.getElementById('listenbrainz-sync-refresh-btn'); + if (!btn) return; + btn.addEventListener('click', async () => { + // Trigger backend refetch + re-render. + try { + await fetch('/api/discover/listenbrainz/refresh', { method: 'POST' }); + } catch (e) { + // Non-fatal; we still re-load from the cache endpoints. + console.warn('LB cache refresh failed (non-fatal):', e); + } + loadListenBrainzSyncPlaylists(); + }); +} + +// Bootstrap once when sync page DOM is ready. ``initializeSyncPage`` +// runs at app boot; we hook our subtab + refresh listeners on top. +document.addEventListener('DOMContentLoaded', () => { + _initListenBrainzSyncSubTabs(); + _initListenBrainzSyncRefreshBtn(); +}); diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js index 41ab7761..63f18be3 100644 --- a/webui/static/sync-services.js +++ b/webui/static/sync-services.js @@ -1365,14 +1365,15 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, virtualPlaylistId.startsWith('qobuz_') ? 'Qobuz' : virtualPlaylistId.startsWith('listenbrainz_') ? 'ListenBrainz' : virtualPlaylistId.startsWith('spotify_public_') ? 'Spotify' : - virtualPlaylistId.startsWith('spotify:') ? 'Spotify' : - virtualPlaylistId.startsWith('discover_') ? 'SoulSync' : - virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' : - virtualPlaylistId.startsWith('spotify_library_') ? 'SoulSync' : - virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' : - virtualPlaylistId.startsWith('decade_') ? 'SoulSync' : - virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' : - 'YouTube'; + virtualPlaylistId.startsWith('itunes_link_') ? 'iTunes' : + virtualPlaylistId.startsWith('spotify:') ? 'Spotify' : + virtualPlaylistId.startsWith('discover_') ? 'SoulSync' : + virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' : + virtualPlaylistId.startsWith('spotify_library_') ? 'SoulSync' : + virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' : + virtualPlaylistId.startsWith('decade_') ? 'SoulSync' : + virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' : + 'YouTube'; const heroContext = { type: 'playlist', @@ -3730,6 +3731,50 @@ function initializeSyncPage() { if (tabId === 'beatport') { ensureBeatportContentLoaded(); } + + // Auto-load ListenBrainz Sync-tab playlists on first activation. + // Reuses the LB discovery + sync flow already wired up for the + // Discover page — the tab is purely a Sync-page entry point. + // Tab id is ``listenbrainz-sync`` (not ``listenbrainz``) so the + // ``${tabId}-tab-content`` lookup doesn't collide with the + // Discover page's own ``id="listenbrainz-tab-content"``. + if (tabId === 'listenbrainz-sync') { + if (typeof loadListenBrainzSyncPlaylists === 'function' + && !window._listenbrainzSyncTabLoaded) { + window._listenbrainzSyncTabLoaded = true; + loadListenBrainzSyncPlaylists(); + } + // Cards mirror canonical listenbrainzPlaylistStates via a + // 500ms refresh loop that auto-stops when the tab loses + // active state — gives parity with Tidal/Qobuz live updates. + if (typeof _startLbSyncCardRefreshLoop === 'function') { + _startLbSyncCardRefreshLoop(); + } + } + + // Last.fm Sync tab — same MB-track shape as LB, shares the + // listenbrainzPlaylistStates machinery + refresh loop. + if (tabId === 'lastfm-sync') { + if (typeof loadLastfmSyncPlaylists === 'function' + && !window._lastfmSyncTabLoaded) { + window._lastfmSyncTabLoaded = true; + loadLastfmSyncPlaylists(); + } + if (typeof _startLbSyncCardRefreshLoop === 'function') { + _startLbSyncCardRefreshLoop(); + } + } + + // SoulSync Discovery Sync tab — personalized_playlists pre- + // matched, no discovery hop needed; click → refresh kind → + // mirror under synthetic id. + if (tabId === 'soulsync-discovery-sync') { + if (typeof loadSoulsyncDiscoverySyncPlaylists === 'function' + && !window._soulsyncDiscoverySyncTabLoaded) { + window._soulsyncDiscoverySyncTabLoaded = true; + loadSoulsyncDiscoverySyncPlaylists(); + } + } }); }); @@ -3934,6 +3979,22 @@ function initializeSyncPage() { }); } + // Logic for iTunes Link parse button + const itunesLinkParseBtn = document.getElementById('itunes-link-parse-btn'); + if (itunesLinkParseBtn) { + itunesLinkParseBtn.addEventListener('click', parseITunesLinkUrl); + } + + // Logic for iTunes Link URL input (Enter key support) + const itunesLinkUrlInput = document.getElementById('itunes-link-url-input'); + if (itunesLinkUrlInput) { + itunesLinkUrlInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + parseITunesLinkUrl(); + } + }); + } + // Logic for Beatport Top 100 button const beatportTop100Btn = document.getElementById('beatport-top100-btn'); if (beatportTop100Btn) { @@ -7587,6 +7648,1031 @@ async function startSpotifyPublicDownloadMissing(urlHash) { } } + +// =============================== +// ITUNES LINK FUNCTIONALITY +// =============================== + +let itunesLinkPlaylists = []; // Array of loaded iTunes Link playlist objects +let itunesLinkPlaylistStates = {}; // Key: url_hash, Value: state dict + +async function parseITunesLinkUrl() { + const urlInput = document.getElementById('itunes-link-url-input'); + const url = urlInput.value.trim(); + + if (!url) { + showToast('Please enter a iTunes URL', 'error'); + return; + } + + // Basic URL validation + const lowerUrl = url.toLowerCase(); + if (!lowerUrl.includes('itunes.apple.com') && !lowerUrl.includes('music.apple.com') && + !lowerUrl.startsWith('itunes:album:') && !lowerUrl.startsWith('itunes:track:') && + !lowerUrl.startsWith('itunes:playlist:') && !lowerUrl.startsWith('applemusic:album:') && + !lowerUrl.startsWith('applemusic:track:') && !lowerUrl.startsWith('applemusic:playlist:')) { + showToast('Please enter a valid iTunes or Apple Music URL', 'error'); + return; + } + + // Check if already loaded + if (_isUrlAlreadyLoaded('itunes-link', url)) { + showToast('This playlist is already loaded', 'info'); + urlInput.value = ''; + return; + } + + const parseBtn = document.getElementById('itunes-link-parse-btn'); + if (parseBtn) { + parseBtn.disabled = true; + parseBtn.textContent = 'Loading...'; + } + + try { + console.log('🎵 Parsing public iTunes URL:', url); + + const response = await fetch('/api/itunes-link/parse', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }) + }); + + const result = await response.json(); + + if (result.error) { + showToast(`Error: ${result.error}`, 'error'); + return; + } + + // Check if already loaded + if (itunesLinkPlaylists.find(p => String(p.url_hash) === String(result.url_hash))) { + showToast('This playlist is already loaded', 'info'); + urlInput.value = ''; + return; + } + + console.log(`✅ iTunes ${result.type} parsed: ${result.name} (${result.track_count} tracks)`); + + itunesLinkPlaylists.push(result); + + // Auto-mirror + if (result.tracks && result.tracks.length > 0) { + mirrorPlaylist('itunes_link', result.url_hash, result.name, result.tracks.map(t => ({ + track_name: t.name || '', + artist_name: Array.isArray(t.artists) ? t.artists.map(a => (typeof a === 'object' && a !== null) ? a.name : a).filter(Boolean).join(', ') : '', + album_name: t.album?.name || '', + duration_ms: t.duration_ms || 0, + source_track_id: t.id || '' + })), { owner: result.subtitle || '', image_url: '', description: result.url || '' }); + } + + // Save to URL history + saveUrlHistory('itunes-link', url, result.name); + + renderITunesLinkPlaylists(); + await loadITunesLinkPlaylistStatesFromBackend(); + + urlInput.value = ''; + showToast(`Loaded: ${result.name} (${result.track_count} tracks)`, 'success'); + console.log(`🎵 Loaded iTunes link: ${result.name}`); + + } catch (error) { + console.error('❌ Error parsing iTunes URL:', error); + showToast(`Error parsing iTunes URL: ${error.message}`, 'error'); + } finally { + if (parseBtn) { + parseBtn.disabled = false; + parseBtn.textContent = 'Load'; + } + } +} + +function renderITunesLinkPlaylists() { + const container = document.getElementById('itunes-link-playlist-container'); + if (itunesLinkPlaylists.length === 0) { + container.innerHTML = `
Paste an iTunes or Apple Music album/track URL above to load tracks.
`; + return; + } + + container.innerHTML = itunesLinkPlaylists.map(p => { + if (!itunesLinkPlaylistStates[p.url_hash]) { + itunesLinkPlaylistStates[p.url_hash] = { + phase: 'fresh', + playlist: p + }; + } + return createITunesLinkCard(p); + }).join(''); + + // Add click handlers to cards + itunesLinkPlaylists.forEach(p => { + const card = document.getElementById(`itunes-link-card-${p.url_hash}`); + if (card) { + card.addEventListener('click', () => handleITunesLinkCardClick(p.url_hash)); + } + }); +} + +function createITunesLinkCard(playlist) { + const state = itunesLinkPlaylistStates[playlist.url_hash]; + const phase = state ? state.phase : 'fresh'; + const isAlbum = playlist.type === 'album'; + const isPlaylist = playlist.type === 'playlist'; + + let buttonText = getActionButtonText(phase); + let phaseText = getPhaseText(phase); + let phaseColor = getPhaseColor(phase); + + return ` + + `; +} + +async function handleITunesLinkCardClick(urlHash) { + const state = itunesLinkPlaylistStates[urlHash]; + if (!state) { + console.error(`No state found for iTunes Link playlist: ${urlHash}`); + showToast('Playlist state not found - try refreshing the page', 'error'); + return; + } + + if (!state.playlist) { + console.error(`No playlist data found for iTunes Link playlist: ${urlHash}`); + showToast('Playlist data missing - try refreshing the page', 'error'); + return; + } + + if (!state.phase) { + state.phase = 'fresh'; + } + + console.log(`🎵 [Card Click] iTunes Link card clicked: ${urlHash}, Phase: ${state.phase}`); + + if (state.phase === 'fresh') { + console.log(`🎵 Using pre-loaded iTunes Link playlist data for: ${state.playlist.name}`); + openITunesLinkDiscoveryModal(urlHash, state.playlist); + + } else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') { + console.log(`🎵 [Card Click] Opening iTunes Link discovery modal for ${state.phase} phase`); + + if (state.phase === 'discovered' && (!state.discovery_results || state.discovery_results.length === 0)) { + try { + const stateResponse = await fetch(`/api/itunes-link/state/${urlHash}`); + if (stateResponse.ok) { + const fullState = await stateResponse.json(); + if (fullState.discovery_results) { + state.discovery_results = fullState.discovery_results; + state.spotify_matches = fullState.spotify_matches || state.spotify_matches; + state.discovery_progress = fullState.discovery_progress || state.discovery_progress; + itunesLinkPlaylistStates[urlHash] = { ...itunesLinkPlaylistStates[urlHash], ...state }; + console.log(`Restored ${fullState.discovery_results.length} discovery results from backend`); + } + } + } catch (error) { + console.error(`Failed to fetch discovery results from backend: ${error}`); + } + } + + openITunesLinkDiscoveryModal(urlHash, state.playlist); + } else if (state.phase === 'downloading' || state.phase === 'download_complete') { + if (state.convertedSpotifyPlaylistId) { + if (activeDownloadProcesses[state.convertedSpotifyPlaylistId]) { + const process = activeDownloadProcesses[state.convertedSpotifyPlaylistId]; + if (process.modalElement) { + process.modalElement.style.display = 'flex'; + } else { + await rehydrateITunesLinkDownloadModal(urlHash, state); + } + } else { + await rehydrateITunesLinkDownloadModal(urlHash, state); + } + } else { + if (state.discovery_results && state.discovery_results.length > 0) { + openITunesLinkDiscoveryModal(urlHash, state.playlist); + } else { + showToast('Unable to open download modal - missing playlist data', 'error'); + } + } + } +} + +async function rehydrateITunesLinkDownloadModal(urlHash, state) { + try { + if (!state || !state.playlist) { + showToast('Cannot open download modal - invalid playlist data', 'error'); + return; + } + + const spotifyTracks = state.discovery_results + ?.filter(result => result.spotify_data) + ?.map(result => result.spotify_data) || []; + + if (spotifyTracks.length > 0) { + const virtualPlaylistId = state.convertedSpotifyPlaylistId || `itunes_link_${urlHash}`; + await openDownloadMissingModalForTidal(virtualPlaylistId, state.playlist.name, spotifyTracks); + + if (state.download_process_id) { + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process) { + process.status = 'running'; + process.batchId = state.download_process_id; + const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + startModalDownloadPolling(virtualPlaylistId); + } + } + } else { + showToast('No Spotify tracks found for download', 'error'); + } + } catch (error) { + console.error(`Error rehydrating iTunes Link download modal: ${error}`); + } +} + +async function openITunesLinkDiscoveryModal(urlHash, playlistData) { + console.log(`🎵 Opening iTunes Link discovery modal (reusing YouTube modal): ${playlistData.name}`); + + const fakeUrlHash = `ituneslink_${urlHash}`; + + const cardState = itunesLinkPlaylistStates[urlHash]; + const isAlreadyDiscovered = cardState && (cardState.phase === 'discovered' || cardState.phase === 'syncing' || cardState.phase === 'sync_complete'); + const isCurrentlyDiscovering = cardState && cardState.phase === 'discovering'; + + let transformedResults = []; + let actualMatches = 0; + if (isAlreadyDiscovered && cardState.discovery_results) { + transformedResults = cardState.discovery_results.map((result, index) => { + const isFound = result.status === 'found' || + result.status === '✅ Found' || + result.status_class === 'found' || + result.spotify_data || + result.spotify_track; + if (isFound) actualMatches++; + + return { + index: index, + yt_track: result.itunes_link_track ? result.itunes_link_track.name : 'Unknown', + yt_artist: result.itunes_link_track ? (result.itunes_link_track.artists ? result.itunes_link_track.artists.join(', ') : 'Unknown') : 'Unknown', + status: isFound ? '✅ Found' : '❌ Not Found', + status_class: isFound ? 'found' : 'not-found', + spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'), + spotify_artist: result.spotify_data && result.spotify_data.artists ? + (Array.isArray(result.spotify_data.artists) + ? result.spotify_data.artists + .map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : a) + .filter(Boolean) + .join(', ') || '-' + : result.spotify_data.artists) + : (result.spotify_artist || '-'), + spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'), + spotify_data: result.spotify_data, + spotify_id: result.spotify_id, + manual_match: result.manual_match + }; + }); + console.log(`🎵 iTunes Link modal: Calculated ${actualMatches} matches from ${transformedResults.length} results`); + } + + // Normalize artist objects to strings for the discovery modal table + const normalizedTracks = playlistData.tracks.map(t => ({ + ...t, + artists: Array.isArray(t.artists) + ? t.artists.map(a => typeof a === 'object' ? a.name : a) + : t.artists + })); + + const modalPhase = cardState ? cardState.phase : 'fresh'; + youtubePlaylistStates[fakeUrlHash] = { + phase: modalPhase, + playlist: { + name: playlistData.name, + tracks: normalizedTracks + }, + is_itunes_link_playlist: true, + itunes_link_playlist_id: urlHash, + discovery_progress: isAlreadyDiscovered ? 100 : 0, + spotify_matches: isAlreadyDiscovered ? actualMatches : 0, + spotifyMatches: isAlreadyDiscovered ? actualMatches : 0, + spotify_total: playlistData.tracks.length, + discovery_results: transformedResults, + discoveryResults: transformedResults, + discoveryProgress: isAlreadyDiscovered ? 100 : 0 + }; + + if (!isAlreadyDiscovered && !isCurrentlyDiscovering) { + try { + console.log(`🔍 Starting iTunes Link discovery for: ${playlistData.name}`); + + const response = await fetch(`/api/itunes-link/discovery/start/${urlHash}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.error) { + console.error('Error starting iTunes Link discovery:', result.error); + showToast(`Error starting discovery: ${result.error}`, 'error'); + return; + } + + console.log('iTunes Link discovery started, beginning polling...'); + + itunesLinkPlaylistStates[urlHash].phase = 'discovering'; + updateITunesLinkCardPhase(urlHash, 'discovering'); + youtubePlaylistStates[fakeUrlHash].phase = 'discovering'; + + startITunesLinkDiscoveryPolling(fakeUrlHash, urlHash); + + } catch (error) { + console.error('Error starting iTunes Link discovery:', error); + showToast(`Error starting discovery: ${error.message}`, 'error'); + } + } else if (isCurrentlyDiscovering) { + console.log(`🔄 Resuming iTunes Link discovery polling for: ${playlistData.name}`); + startITunesLinkDiscoveryPolling(fakeUrlHash, urlHash); + } else if (cardState && cardState.phase === 'syncing') { + console.log(`🔄 Resuming iTunes Link sync polling for: ${playlistData.name}`); + startITunesLinkSyncPolling(fakeUrlHash); + } else { + console.log('Using existing results - no need to re-discover'); + } + + openYouTubeDiscoveryModal(fakeUrlHash); +} + +function startITunesLinkDiscoveryPolling(fakeUrlHash, urlHash) { + console.log(`🔄 Starting iTunes Link discovery polling for: ${urlHash}`); + + if (activeYouTubePollers[fakeUrlHash]) { + clearInterval(activeYouTubePollers[fakeUrlHash]); + } + + // WebSocket subscription + if (socketConnected) { + socket.emit('discovery:subscribe', { ids: [urlHash] }); + _discoveryProgressCallbacks[urlHash] = (data) => { + if (data.error) { + if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; } + socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash]; + return; + } + const transformed = { + progress: data.progress, spotify_matches: data.spotify_matches, spotify_total: data.spotify_total, + complete: data.complete, + results: (data.results || []).map((r, i) => { + const isWingIt = r.wing_it_fallback || r.status_class === 'wing-it'; + const isFound = !isWingIt && (r.status === 'found' || r.status === '✅ Found' || r.status_class === 'found' || r.spotify_data || r.spotify_track); + return { + index: i, yt_track: r.itunes_link_track ? r.itunes_link_track.name : 'Unknown', + yt_artist: r.itunes_link_track ? (r.itunes_link_track.artists ? r.itunes_link_track.artists.join(', ') : 'Unknown') : 'Unknown', + status: isWingIt ? '🎯 Wing It' : (isFound ? '✅ Found' : '❌ Not Found'), + status_class: isWingIt ? 'wing-it' : (isFound ? 'found' : 'not-found'), + spotify_track: r.spotify_data ? r.spotify_data.name : (r.spotify_track || '-'), + spotify_artist: r.spotify_data && r.spotify_data.artists + ? (Array.isArray(r.spotify_data.artists) + ? (r.spotify_data.artists + .map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : a) + .filter(Boolean) + .join(', ') || '-') + : r.spotify_data.artists) + : (r.spotify_artist || '-'), + spotify_album: r.spotify_data ? (typeof r.spotify_data.album === 'object' ? r.spotify_data.album.name : r.spotify_data.album) : (r.spotify_album || '-'), + spotify_data: r.spotify_data, spotify_id: r.spotify_id, manual_match: r.manual_match, + wing_it_fallback: isWingIt + }; + }) + }; + const st = youtubePlaylistStates[fakeUrlHash]; + if (st) { + st.discovery_progress = data.progress; st.discoveryProgress = data.progress; + st.spotify_matches = data.spotify_matches; st.spotifyMatches = data.spotify_matches; + st.discovery_results = data.results; st.discoveryResults = transformed.results; + st.phase = data.phase; + updateYouTubeDiscoveryModal(fakeUrlHash, transformed); + } + if (itunesLinkPlaylistStates[urlHash]) { + itunesLinkPlaylistStates[urlHash].phase = data.phase; + itunesLinkPlaylistStates[urlHash].discovery_results = data.results; + itunesLinkPlaylistStates[urlHash].spotify_matches = data.spotify_matches; + itunesLinkPlaylistStates[urlHash].discovery_progress = data.progress; + updateITunesLinkCardPhase(urlHash, data.phase); + } + updateITunesLinkCardProgress(urlHash, data); + if (data.complete) { + if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; } + socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash]; + } + }; + } + + const pollInterval = setInterval(async () => { + if (socketConnected) return; + try { + const response = await fetch(`/api/itunes-link/discovery/status/${urlHash}`); + const status = await response.json(); + + if (status.error) { + console.error('Error polling iTunes Link discovery status:', status.error); + clearInterval(pollInterval); + delete activeYouTubePollers[fakeUrlHash]; + return; + } + + const transformedStatus = { + progress: status.progress, + spotify_matches: status.spotify_matches, + spotify_total: status.spotify_total, + complete: status.complete, + results: status.results.map((result, index) => { + const isFound = result.status === 'found' || + result.status === '✅ Found' || + result.status_class === 'found' || + result.spotify_data || + result.spotify_track; + + return { + index: index, + yt_track: result.itunes_link_track ? result.itunes_link_track.name : 'Unknown', + yt_artist: result.itunes_link_track ? (result.itunes_link_track.artists ? result.itunes_link_track.artists.join(', ') : 'Unknown') : 'Unknown', + status: isFound ? '✅ Found' : '❌ Not Found', + status_class: isFound ? 'found' : 'not-found', + spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'), + spotify_artist: result.spotify_data && result.spotify_data.artists + ? (Array.isArray(result.spotify_data.artists) + ? (result.spotify_data.artists + .map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : a) + .filter(Boolean) + .join(', ') || '-') + : result.spotify_data.artists) + : (result.spotify_artist || '-'), + spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'), + spotify_data: result.spotify_data, + spotify_id: result.spotify_id, + manual_match: result.manual_match + }; + }) + }; + + const state = youtubePlaylistStates[fakeUrlHash]; + if (state) { + state.discovery_progress = status.progress; + state.discoveryProgress = status.progress; + state.spotify_matches = status.spotify_matches; + state.spotifyMatches = status.spotify_matches; + state.discovery_results = status.results; + state.discoveryResults = transformedStatus.results; + state.phase = status.phase; + + updateYouTubeDiscoveryModal(fakeUrlHash, transformedStatus); + + if (itunesLinkPlaylistStates[urlHash]) { + itunesLinkPlaylistStates[urlHash].phase = status.phase; + itunesLinkPlaylistStates[urlHash].discovery_results = status.results; + itunesLinkPlaylistStates[urlHash].spotify_matches = status.spotify_matches; + itunesLinkPlaylistStates[urlHash].discovery_progress = status.progress; + updateITunesLinkCardPhase(urlHash, status.phase); + } + + updateITunesLinkCardProgress(urlHash, status); + + console.log(`🔄 iTunes Link discovery progress: ${status.progress}% (${status.spotify_matches}/${status.spotify_total} found)`); + } + + if (status.complete) { + console.log(`iTunes Link discovery complete: ${status.spotify_matches}/${status.spotify_total} tracks found`); + clearInterval(pollInterval); + delete activeYouTubePollers[fakeUrlHash]; + } + + } catch (error) { + console.error('Error polling iTunes Link discovery:', error); + clearInterval(pollInterval); + delete activeYouTubePollers[fakeUrlHash]; + } + }, 1000); + + activeYouTubePollers[fakeUrlHash] = pollInterval; +} + +async function loadITunesLinkPlaylistStatesFromBackend() { + try { + console.log('🎵 Loading iTunes Link playlist states from backend...'); + + const response = await fetch('/api/itunes-link/playlists/states'); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to fetch iTunes Link playlist states'); + } + + const data = await response.json(); + const states = data.states || []; + + console.log(`🎵 Found ${states.length} stored iTunes Link playlist states in backend`); + + if (states.length === 0) return; + + for (const stateInfo of states) { + await applyITunesLinkPlaylistState(stateInfo); + } + + // Rehydrate download modals for playlists in downloading/download_complete phases + for (const stateInfo of states) { + if ((stateInfo.phase === 'downloading' || stateInfo.phase === 'download_complete') && + stateInfo.converted_spotify_playlist_id && stateInfo.download_process_id) { + + const convertedPlaylistId = stateInfo.converted_spotify_playlist_id; + + if (!activeDownloadProcesses[convertedPlaylistId]) { + console.log(`Rehydrating download modal for iTunes Link playlist: ${stateInfo.playlist_id}`); + try { + const playlistData = itunesLinkPlaylists.find(p => String(p.url_hash) === String(stateInfo.playlist_id)); + if (!playlistData) continue; + + const spotifyTracks = itunesLinkPlaylistStates[stateInfo.playlist_id]?.discovery_results + ?.filter(result => result.spotify_data) + ?.map(result => result.spotify_data) || []; + + if (spotifyTracks.length > 0) { + await openDownloadMissingModalForTidal( + convertedPlaylistId, + playlistData.name, + spotifyTracks + ); + + const process = activeDownloadProcesses[convertedPlaylistId]; + if (process) { + process.status = 'running'; + process.batchId = stateInfo.download_process_id; + const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + startModalDownloadPolling(convertedPlaylistId); + } + } + } catch (error) { + console.error(`Error rehydrating iTunes Link download modal for ${stateInfo.playlist_id}:`, error); + } + } + } + } + + console.log('iTunes Link playlist states loaded and applied'); + + } catch (error) { + console.error('Error loading iTunes Link playlist states:', error); + } +} + +async function applyITunesLinkPlaylistState(stateInfo) { + const { playlist_id, phase, discovery_progress, spotify_matches, discovery_results, converted_spotify_playlist_id, download_process_id } = stateInfo; + + try { + console.log(`🎵 Applying saved state for iTunes Link playlist: ${playlist_id}, Phase: ${phase}`); + + const playlistData = itunesLinkPlaylists.find(p => String(p.url_hash) === String(playlist_id)); + if (!playlistData) { + console.warn(`Playlist data not found for state ${playlist_id} - skipping`); + return; + } + + if (!itunesLinkPlaylistStates[playlist_id]) { + itunesLinkPlaylistStates[playlist_id] = { + playlist: playlistData, + phase: 'fresh' + }; + } + + itunesLinkPlaylistStates[playlist_id].phase = phase; + itunesLinkPlaylistStates[playlist_id].discovery_progress = discovery_progress; + itunesLinkPlaylistStates[playlist_id].spotify_matches = spotify_matches; + itunesLinkPlaylistStates[playlist_id].discovery_results = discovery_results; + itunesLinkPlaylistStates[playlist_id].convertedSpotifyPlaylistId = converted_spotify_playlist_id; + itunesLinkPlaylistStates[playlist_id].download_process_id = download_process_id; + itunesLinkPlaylistStates[playlist_id].playlist = playlistData; + + if (phase !== 'fresh' && phase !== 'discovering') { + try { + const stateResponse = await fetch(`/api/itunes-link/state/${playlist_id}`); + if (stateResponse.ok) { + const fullState = await stateResponse.json(); + if (fullState.discovery_results && itunesLinkPlaylistStates[playlist_id]) { + itunesLinkPlaylistStates[playlist_id].discovery_results = fullState.discovery_results; + itunesLinkPlaylistStates[playlist_id].discovery_progress = fullState.discovery_progress; + itunesLinkPlaylistStates[playlist_id].spotify_matches = fullState.spotify_matches; + itunesLinkPlaylistStates[playlist_id].convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id; + itunesLinkPlaylistStates[playlist_id].download_process_id = fullState.download_process_id; + } + } + } catch (error) { + console.warn(`Error fetching full discovery results for iTunes Link playlist ${playlistData.name}:`, error.message); + } + } + + updateITunesLinkCardPhase(playlist_id, phase); + + if (phase === 'discovered' && itunesLinkPlaylistStates[playlist_id]) { + const progressInfo = { + spotify_total: playlistData.track_count || playlistData.tracks?.length || 0, + spotify_matches: itunesLinkPlaylistStates[playlist_id].spotify_matches || 0 + }; + updateITunesLinkCardProgress(playlist_id, progressInfo); + } + + if (phase === 'discovering') { + const fakeUrlHash = `ituneslink_${playlist_id}`; + startITunesLinkDiscoveryPolling(fakeUrlHash, playlist_id); + } else if (phase === 'syncing') { + const fakeUrlHash = `ituneslink_${playlist_id}`; + startITunesLinkSyncPolling(fakeUrlHash); + } + + } catch (error) { + console.error(`Error applying iTunes Link playlist state for ${playlist_id}:`, error); + } +} + +function updateITunesLinkCardPhase(urlHash, phase) { + const state = itunesLinkPlaylistStates[urlHash]; + if (!state) return; + + state.phase = phase; + + const card = document.getElementById(`itunes-link-card-${urlHash}`); + if (card) { + const newCardHtml = createITunesLinkCard(state.playlist); + card.outerHTML = newCardHtml; + + const newCard = document.getElementById(`itunes-link-card-${urlHash}`); + if (newCard) { + newCard.addEventListener('click', () => handleITunesLinkCardClick(urlHash)); + } + + if ((phase === 'syncing' || phase === 'sync_complete') && state.lastSyncProgress) { + setTimeout(() => { + updateITunesLinkCardSyncProgress(urlHash, state.lastSyncProgress); + }, 0); + } + } +} + +function updateITunesLinkCardProgress(urlHash, progress) { + const state = itunesLinkPlaylistStates[urlHash]; + if (!state) return; + + const card = document.getElementById(`itunes-link-card-${urlHash}`); + if (!card) return; + + const progressElement = card.querySelector('.playlist-card-progress'); + if (!progressElement) return; + + progressElement.classList.remove('hidden'); + + const total = progress.spotify_total || 0; + const matches = progress.spotify_matches || 0; + + if (total > 0) { + progressElement.innerHTML = ` +
+ ✓ ${matches} + / + ♪ ${total} +
+ `; + } +} + +// =============================== +// SPOTIFY PUBLIC SYNC FUNCTIONALITY +// =============================== + +async function startITunesLinkPlaylistSync(urlHash) { + try { + console.log('🎵 Starting iTunes Link playlist sync:', urlHash); + + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.is_itunes_link_playlist) { + console.error('Invalid iTunes Link playlist state for sync'); + return; + } + + const playlistId = state.itunes_link_playlist_id; + const response = await fetch(`/api/itunes-link/sync/start/${playlistId}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.error) { + showToast(`Error starting sync: ${result.error}`, 'error'); + return; + } + + const syncPlaylistId = result.sync_playlist_id; + if (state) state.syncPlaylistId = syncPlaylistId; + + updateITunesLinkCardPhase(playlistId, 'syncing'); + updateITunesLinkModalButtons(urlHash, 'syncing'); + + startITunesLinkSyncPolling(urlHash, syncPlaylistId); + + showToast('iTunes Link playlist sync started!', 'success'); + + } catch (error) { + console.error('Error starting iTunes Link sync:', error); + showToast(`Error starting sync: ${error.message}`, 'error'); + } +} + +function startITunesLinkSyncPolling(urlHash, syncPlaylistId) { + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + } + + const state = youtubePlaylistStates[urlHash]; + const playlistId = state.itunes_link_playlist_id; + + syncPlaylistId = syncPlaylistId || (state && state.syncPlaylistId); + + // WebSocket subscription + if (socketConnected && syncPlaylistId) { + socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] }); + _syncProgressCallbacks[syncPlaylistId] = (data) => { + const progress = data.progress || {}; + updateITunesLinkCardSyncProgress(playlistId, progress); + updateITunesLinkModalSyncProgress(urlHash, progress); + + if (data.status === 'finished') { + if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } + socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); + delete _syncProgressCallbacks[syncPlaylistId]; + if (itunesLinkPlaylistStates[playlistId]) itunesLinkPlaylistStates[playlistId].phase = 'sync_complete'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete'; + updateITunesLinkCardPhase(playlistId, 'sync_complete'); + updateITunesLinkModalButtons(urlHash, 'sync_complete'); + showToast('iTunes Link playlist sync complete!', 'success'); + } else if (data.status === 'error' || data.status === 'cancelled') { + if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } + socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); + delete _syncProgressCallbacks[syncPlaylistId]; + if (itunesLinkPlaylistStates[playlistId]) itunesLinkPlaylistStates[playlistId].phase = 'discovered'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered'; + updateITunesLinkCardPhase(playlistId, 'discovered'); + updateITunesLinkModalButtons(urlHash, 'discovered'); + showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error'); + } + }; + } + + const pollFunction = async () => { + if (socketConnected) return; + try { + const response = await fetch(`/api/itunes-link/sync/status/${playlistId}`); + const status = await response.json(); + + if (status.error) { + console.error('Error polling iTunes Link sync status:', status.error); + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + return; + } + + updateITunesLinkCardSyncProgress(playlistId, status.progress); + updateITunesLinkModalSyncProgress(urlHash, status.progress); + + if (status.complete) { + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + if (itunesLinkPlaylistStates[playlistId]) itunesLinkPlaylistStates[playlistId].phase = 'sync_complete'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete'; + updateITunesLinkCardPhase(playlistId, 'sync_complete'); + updateITunesLinkModalButtons(urlHash, 'sync_complete'); + showToast('iTunes Link playlist sync complete!', 'success'); + } else if (status.sync_status === 'error') { + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + if (itunesLinkPlaylistStates[playlistId]) itunesLinkPlaylistStates[playlistId].phase = 'discovered'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered'; + updateITunesLinkCardPhase(playlistId, 'discovered'); + updateITunesLinkModalButtons(urlHash, 'discovered'); + showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error'); + } + } catch (error) { + console.error('Error polling iTunes Link sync:', error); + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + delete activeYouTubePollers[urlHash]; + } + } + }; + + if (!socketConnected) pollFunction(); + + const pollInterval = setInterval(pollFunction, 1000); + activeYouTubePollers[urlHash] = pollInterval; +} + +async function cancelITunesLinkSync(urlHash) { + try { + console.log('Cancelling iTunes Link sync:', urlHash); + + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.is_itunes_link_playlist) { + console.error('Invalid iTunes Link playlist state'); + return; + } + + const playlistId = state.itunes_link_playlist_id; + const response = await fetch(`/api/itunes-link/sync/cancel/${playlistId}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.error) { + showToast(`Error cancelling sync: ${result.error}`, 'error'); + return; + } + + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + delete activeYouTubePollers[urlHash]; + } + + const syncId = state && state.syncPlaylistId; + if (syncId && _syncProgressCallbacks[syncId]) { + if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [syncId] }); + delete _syncProgressCallbacks[syncId]; + } + + updateITunesLinkCardPhase(playlistId, 'discovered'); + updateITunesLinkModalButtons(urlHash, 'discovered'); + + showToast('iTunes Link sync cancelled', 'info'); + + } catch (error) { + console.error('Error cancelling iTunes Link sync:', error); + showToast(`Error cancelling sync: ${error.message}`, 'error'); + } +} + +function updateITunesLinkCardSyncProgress(urlHash, progress) { + const state = itunesLinkPlaylistStates[urlHash]; + if (!state || !state.playlist || !progress) return; + + state.lastSyncProgress = progress; + + const card = document.getElementById(`itunes-link-card-${urlHash}`); + if (!card) return; + + const progressElement = card.querySelector('.playlist-card-progress'); + + let statusCounterHTML = ''; + if (progress && progress.total_tracks > 0) { + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + const total = progress.total_tracks || 0; + const processed = matched + failed; + const percentage = total > 0 ? Math.round((processed / total) * 100) : 0; + + statusCounterHTML = ` +
+ ♪ ${total} + / + ✓ ${matched} + / + ✗ ${failed} + (${percentage}%) +
+ `; + } + + if (statusCounterHTML) { + progressElement.innerHTML = statusCounterHTML; + } +} + +function updateITunesLinkModalSyncProgress(urlHash, progress) { + const statusDisplay = document.getElementById(`itunes-link-sync-status-${urlHash}`); + if (!statusDisplay || !progress) return; + + const totalEl = document.getElementById(`itunes-link-total-${urlHash}`); + const matchedEl = document.getElementById(`itunes-link-matched-${urlHash}`); + const failedEl = document.getElementById(`itunes-link-failed-${urlHash}`); + const percentageEl = document.getElementById(`itunes-link-percentage-${urlHash}`); + + const total = progress.total_tracks || 0; + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + + if (totalEl) totalEl.textContent = total; + if (matchedEl) matchedEl.textContent = matched; + if (failedEl) failedEl.textContent = failed; + + if (total > 0) { + const processed = matched + failed; + const percentage = Math.round((processed / total) * 100); + if (percentageEl) percentageEl.textContent = percentage; + } +} + +function updateITunesLinkModalButtons(urlHash, phase) { + const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + if (!modal) return; + + const footerLeft = modal.querySelector('.modal-footer-left'); + if (footerLeft) { + footerLeft.innerHTML = getModalActionButtons(urlHash, phase); + } +} + +async function startITunesLinkDownloadMissing(urlHash) { + try { + console.log('🔍 Starting download missing tracks for iTunes Link playlist:', urlHash); + + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.is_itunes_link_playlist) { + console.error('Invalid iTunes Link playlist state for download'); + return; + } + + const discoveryResults = state.discoveryResults || state.discovery_results; + + if (!discoveryResults) { + showToast('No discovery results available for download', 'error'); + return; + } + + const spotifyTracks = []; + for (const result of discoveryResults) { + if (result.spotify_data) { + spotifyTracks.push(result.spotify_data); + } else if (result.spotify_track && result.status_class === 'found') { + const albumData = result.spotify_album || 'Unknown Album'; + const albumObject = typeof albumData === 'object' && albumData !== null + ? albumData + : { + name: typeof albumData === 'string' ? albumData : 'Unknown Album', + album_type: 'album', + images: [] + }; + + spotifyTracks.push({ + id: result.spotify_id || 'unknown', + name: result.spotify_track || 'Unknown Track', + artists: result.spotify_artist ? [result.spotify_artist] : ['Unknown Artist'], + album: albumObject, + duration_ms: 0 + }); + } + } + + if (spotifyTracks.length === 0) { + showToast('No Spotify matches found for download', 'error'); + return; + } + + const realUrlHash = state.itunes_link_playlist_id; + const virtualPlaylistId = `itunes_link_${realUrlHash}`; + const playlistName = state.playlist.name; + + state.convertedSpotifyPlaylistId = virtualPlaylistId; + + // Sync convertedSpotifyPlaylistId to itunesLinkPlaylistStates for card click routing + if (realUrlHash && itunesLinkPlaylistStates[realUrlHash]) { + itunesLinkPlaylistStates[realUrlHash].convertedSpotifyPlaylistId = virtualPlaylistId; + } + + const discoveryModal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + if (discoveryModal) { + discoveryModal.classList.add('hidden'); + } + + await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks); + + } catch (error) { + console.error('Error starting iTunes Link download missing:', error); + showToast(`Error: ${error.message}`, 'error'); + } +} + + // =============================== // URL HISTORY (Saved playlist URLs) // =============================== @@ -7595,7 +8681,8 @@ const URL_HISTORY_MAX = 10; const URL_HISTORY_SOURCES = { youtube: { key: 'soulsync-url-history-youtube', icon: '▶', inputId: 'youtube-url-input', containerId: 'youtube-url-history', loadFn: () => parseYouTubePlaylist() }, deezer: { key: 'soulsync-url-history-deezer', icon: '🎵', inputId: 'deezer-url-input', containerId: 'deezer-url-history', loadFn: () => loadDeezerPlaylist() }, - 'spotify-public': { key: 'soulsync-url-history-spotify-public', icon: '🎧', inputId: 'spotify-public-url-input', containerId: 'spotify-public-url-history', loadFn: () => parseSpotifyPublicUrl() } + 'spotify-public': { key: 'soulsync-url-history-spotify-public', icon: '🎧', inputId: 'spotify-public-url-input', containerId: 'spotify-public-url-history', loadFn: () => parseSpotifyPublicUrl() }, + 'itunes-link': { key: 'soulsync-url-history-itunes-link', icon: '♪', inputId: 'itunes-link-url-input', containerId: 'itunes-link-url-history', loadFn: () => parseITunesLinkUrl() } }; function getUrlHistory(source) { @@ -7704,10 +8791,34 @@ function _isUrlAlreadyLoaded(source, url) { if (spId && spotifyPublicPlaylists.some(p => p.id === spId)) return true; // Fallback: direct URL comparison return spotifyPublicPlaylists.some(p => p.url === url); + } else if (source === 'itunes-link') { + const parsed = extractITunesLinkId(url); + if (parsed && itunesLinkPlaylists.some(p => p.id === parsed.id && p.type === parsed.type)) return true; + return itunesLinkPlaylists.some(p => p.url === url); } return false; } +function extractITunesLinkId(url) { + try { + const raw = (url || '').trim(); + const uriMatch = raw.match(/^(?:itunes|applemusic):(album|track|playlist):([A-Za-z0-9._-]+)$/i); + if (uriMatch) return { type: uriMatch[1].toLowerCase(), id: uriMatch[2] }; + const parsed = new URL(raw); + const trackId = parsed.searchParams.get('i'); + if (trackId && /^\d+$/.test(trackId)) return { type: 'track', id: trackId }; + const songMatch = parsed.pathname.match(/\/song(?:\/[^/]+)?\/(\d+)/); + if (songMatch) return { type: 'track', id: songMatch[1] }; + const albumMatch = parsed.pathname.match(/\/album(?:\/[^/]+)?\/(\d+)/); + if (albumMatch) return { type: 'album', id: albumMatch[1] }; + const playlistMatch = parsed.pathname.match(/\/playlist(?:\/[^/]+)?\/(pl\.[A-Za-z0-9._-]+)/); + if (playlistMatch) return { type: 'playlist', id: playlistMatch[1] }; + } catch (e) { + return null; + } + return null; +} + function initUrlHistories() { for (const source of Object.keys(URL_HISTORY_SOURCES)) { renderUrlHistory(source); @@ -8229,6 +9340,8 @@ function openYouTubeDiscoveryModal(urlHash) { startDeezerSyncPolling(urlHash); } else if (state.is_spotify_public_playlist) { startSpotifyPublicSyncPolling(urlHash); + } else if (state.is_itunes_link_playlist) { + startITunesLinkSyncPolling(urlHash); } else if (state.is_beatport_playlist) { startBeatportSyncPolling(urlHash); } else if (state.is_listenbrainz_playlist) { @@ -8243,13 +9356,15 @@ function openYouTubeDiscoveryModal(urlHash) { const isQobuz = state.is_qobuz_playlist; const isDeezer = state.is_deezer_playlist; const isSpotifyPublic = state.is_spotify_public_playlist; + const isITunesLink = state.is_itunes_link_playlist; const isBeatport = state.is_beatport_playlist; const isListenBrainz = state.is_listenbrainz_playlist; const isMirrored = state.is_mirrored_playlist; const isLastfmRadio = typeof urlHash === 'string' && urlHash.startsWith('lastfm_radio_'); const modalTitle = isMirrored ? '🎵 Mirrored Playlist Discovery' : isSpotifyPublic ? '🎵 Spotify Playlist Discovery' : - isDeezer ? '🎵 Deezer Playlist Discovery' : + isITunesLink ? '🎵 iTunes Link Discovery' : + isDeezer ? '🎵 Deezer Playlist Discovery' : isTidal ? '🎵 Tidal Playlist Discovery' : isQobuz ? '🎵 Qobuz Playlist Discovery' : isBeatport ? '🎵 Beatport Chart Discovery' : @@ -8258,7 +9373,8 @@ function openYouTubeDiscoveryModal(urlHash) { '🎵 YouTube Playlist Discovery'; const sourceLabel = isMirrored ? (state.mirrored_source ? state.mirrored_source.charAt(0).toUpperCase() + state.mirrored_source.slice(1) : 'Source') : isSpotifyPublic ? 'Spotify' : - isDeezer ? 'Deezer' : + isITunesLink ? 'iTunes' : + isDeezer ? 'Deezer' : isTidal ? 'Tidal' : isQobuz ? 'Qobuz' : isBeatport ? 'Beatport' : @@ -8272,7 +9388,7 @@ function openYouTubeDiscoveryModal(urlHash) { @@ -8445,6 +9561,7 @@ function getModalActionButtons(urlHash, phase, state = null) { const isQobuz = state && state.is_qobuz_playlist; const isDeezer = state && state.is_deezer_playlist; const isSpotifyPublic = state && state.is_spotify_public_playlist; + const isITunesLink = state && state.is_itunes_link_playlist; const isBeatport = state && state.is_beatport_playlist; const isListenBrainz = state && state.is_listenbrainz_playlist; @@ -8492,6 +9609,8 @@ function getModalActionButtons(urlHash, phase, state = null) { buttons += ``; } else if (isSpotifyPublic) { buttons += ``; + } else if (isITunesLink) { + buttons += ``; } else if (isBeatport) { buttons += ``; } else { @@ -8511,6 +9630,8 @@ function getModalActionButtons(urlHash, phase, state = null) { buttons += ``; } else if (isSpotifyPublic) { buttons += ``; + } else if (isITunesLink) { + buttons += ``; } else if (isBeatport) { buttons += ``; } else { @@ -8530,7 +9651,7 @@ function getModalActionButtons(urlHash, phase, state = null) { // Rediscover button — reset and re-run discovery (only for sources with reset endpoints) if (isBeatport) { buttons += ``; - } else if (!isListenBrainz && !isTidal && !isQobuz && !isDeezer && !isSpotifyPublic) { + } else if (!isListenBrainz && !isTidal && !isQobuz && !isDeezer && !isSpotifyPublic && !isITunesLink) { buttons += ``; } @@ -8608,6 +9729,18 @@ function getModalActionButtons(urlHash, phase, state = null) { (0%)
`; + } else if (isITunesLink) { + return ` + + + `; } else if (isBeatport) { return ` @@ -8647,6 +9780,8 @@ function getModalActionButtons(urlHash, phase, state = null) { syncCompleteButtons += ``; } else if (isSpotifyPublic) { syncCompleteButtons += ``; + } else if (isITunesLink) { + syncCompleteButtons += ``; } else if (isBeatport) { syncCompleteButtons += ``; } else { @@ -8664,6 +9799,8 @@ function getModalActionButtons(urlHash, phase, state = null) { syncCompleteButtons += ``; } else if (isSpotifyPublic) { syncCompleteButtons += ``; + } else if (isITunesLink) { + syncCompleteButtons += ``; } else if (isBeatport) { syncCompleteButtons += ``; } else { @@ -8674,7 +9811,7 @@ function getModalActionButtons(urlHash, phase, state = null) { // Rediscover button (only for sources with reset endpoints) if (isBeatport) { syncCompleteButtons += ``; - } else if (!isListenBrainz && !isTidal && !isQobuz && !isDeezer && !isSpotifyPublic) { + } else if (!isListenBrainz && !isTidal && !isQobuz && !isDeezer && !isSpotifyPublic && !isITunesLink) { syncCompleteButtons += ``; } @@ -8698,6 +9835,8 @@ function getModalActionButtons(urlHash, phase, state = null) { dlCompleteButtons += ``; } else if (isSpotifyPublic) { dlCompleteButtons += ``; + } else if (isITunesLink) { + dlCompleteButtons += ``; } else if (isBeatport) { dlCompleteButtons += ``; } else { @@ -8716,6 +9855,8 @@ function getModalActionButtons(urlHash, phase, state = null) { dlCompleteButtons += ``; } else if (isSpotifyPublic) { dlCompleteButtons += ``; + } else if (isITunesLink) { + dlCompleteButtons += ``; } else if (isBeatport) { dlCompleteButtons += ``; } else { @@ -8726,7 +9867,7 @@ function getModalActionButtons(urlHash, phase, state = null) { // Rediscover button (only for sources with reset endpoints) if (isBeatport) { dlCompleteButtons += ``; - } else if (!isListenBrainz && !isTidal && !isQobuz && !isDeezer && !isSpotifyPublic) { + } else if (!isListenBrainz && !isTidal && !isQobuz && !isDeezer && !isSpotifyPublic && !isITunesLink) { dlCompleteButtons += ``; } @@ -8737,8 +9878,8 @@ function getModalActionButtons(urlHash, phase, state = null) { } } -function getModalDescription(phase, isTidal = false, isBeatport = false, isListenBrainz = false, isMirrored = false, isDeezer = false, isSpotifyPublic = false, isLastfmRadio = false, isQobuz = false) { - const source = isMirrored ? 'mirrored' : (isSpotifyPublic ? 'Spotify' : (isDeezer ? 'Deezer' : (isLastfmRadio ? 'Last.fm Radio' : (isListenBrainz ? 'ListenBrainz' : (isBeatport ? 'Beatport' : (isQobuz ? 'Qobuz' : (isTidal ? 'Tidal' : 'YouTube'))))))); +function getModalDescription(phase, isTidal = false, isBeatport = false, isListenBrainz = false, isMirrored = false, isDeezer = false, isSpotifyPublic = false, isLastfmRadio = false, isQobuz = false, isITunesLink = false) { + const source = isMirrored ? 'mirrored' : (isSpotifyPublic ? 'Spotify' : (isITunesLink ? 'iTunes' : (isDeezer ? 'Deezer' : (isLastfmRadio ? 'Last.fm Radio' : (isListenBrainz ? 'ListenBrainz' : (isBeatport ? 'Beatport' : (isQobuz ? 'Qobuz' : (isTidal ? 'Tidal' : 'YouTube')))))))); switch (phase) { case 'fresh': return `Ready to discover clean ${currentMusicSourceName} metadata for ${source} tracks...`; @@ -8773,10 +9914,11 @@ function generateTableRowsFromState(state, urlHash) { const isQobuz = state.is_qobuz_playlist; const isDeezer = state.is_deezer_playlist; const isSpotifyPublic = state.is_spotify_public_playlist; + const isITunesLink = state.is_itunes_link_playlist; const isBeatport = state.is_beatport_playlist; const isListenBrainz = state.is_listenbrainz_playlist; const isMirrored = state.is_mirrored_playlist; - const platform = isMirrored ? 'mirrored' : (isSpotifyPublic ? 'spotify_public' : (isDeezer ? 'deezer' : (isListenBrainz ? 'listenbrainz' : (isTidal ? 'tidal' : (isQobuz ? 'qobuz' : (isBeatport ? 'beatport' : 'youtube')))))); + const platform = isMirrored ? 'mirrored' : (isSpotifyPublic ? 'spotify_public' : (isITunesLink ? 'itunes_link' : (isDeezer ? 'deezer' : (isListenBrainz ? 'listenbrainz' : (isTidal ? 'tidal' : (isQobuz ? 'qobuz' : (isBeatport ? 'beatport' : 'youtube'))))))); // Support both camelCase and snake_case const discoveryResults = state.discoveryResults || state.discovery_results; @@ -8938,11 +10080,12 @@ function updateYouTubeDiscoveryModal(urlHash, status) { const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash]; const platform = state?.is_mirrored_playlist ? 'mirrored' : (state?.is_spotify_public_playlist ? 'spotify_public' : + (state?.is_itunes_link_playlist ? 'itunes_link' : (state?.is_deezer_playlist ? 'deezer' : (state?.is_listenbrainz_playlist ? 'listenbrainz' : (state?.is_tidal_playlist ? 'tidal' : (state?.is_qobuz_playlist ? 'qobuz' : - (state?.is_beatport_playlist ? 'beatport' : 'youtube')))))); + (state?.is_beatport_playlist ? 'beatport' : 'youtube'))))))); actionsCell.innerHTML = generateDiscoveryActionButton(result, urlHash, platform); } }); @@ -9016,11 +10159,12 @@ function closeYouTubeDiscoveryModal(urlHash) { const isQobuz = state.is_qobuz_playlist; const isDeezer = state.is_deezer_playlist; const isSpotifyPublic = state.is_spotify_public_playlist; + const isITunesLink = state.is_itunes_link_playlist; const isBeatport = state.is_beatport_playlist; // Reset to 'discovered' phase if modal is closed after completion (like Tidal does) if (state.phase === 'sync_complete' || state.phase === 'download_complete') { - console.log(`🧹 [Modal Close] Resetting ${isSpotifyPublic ? 'Spotify Public' : (isDeezer ? 'Deezer' : (isQobuz ? 'Qobuz' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube'))))} state after completion`); + console.log(`🧹 [Modal Close] Resetting ${isSpotifyPublic ? 'Spotify Public' : (isITunesLink ? 'iTunes Link' : (isDeezer ? 'Deezer' : (isQobuz ? 'Qobuz' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube')))))} state after completion`); if (isSpotifyPublic) { // Spotify Public: Extract url_hash and reset state @@ -9052,6 +10196,35 @@ function closeYouTubeDiscoveryModal(urlHash) { console.warn('Error updating backend Spotify Public phase:', error); } } + } else if (isITunesLink) { + const itunesUrlHash = state.itunes_link_playlist_id || null; + if (itunesUrlHash && itunesLinkPlaylistStates[itunesUrlHash]) { + const preservedData = { + playlist: itunesLinkPlaylistStates[itunesUrlHash].playlist, + discovery_results: itunesLinkPlaylistStates[itunesUrlHash].discovery_results, + spotify_matches: itunesLinkPlaylistStates[itunesUrlHash].spotify_matches, + discovery_progress: itunesLinkPlaylistStates[itunesUrlHash].discovery_progress, + convertedSpotifyPlaylistId: itunesLinkPlaylistStates[itunesUrlHash].convertedSpotifyPlaylistId + }; + + delete itunesLinkPlaylistStates[itunesUrlHash].download_process_id; + delete itunesLinkPlaylistStates[itunesUrlHash].phase; + + Object.assign(itunesLinkPlaylistStates[itunesUrlHash], preservedData); + itunesLinkPlaylistStates[itunesUrlHash].phase = 'discovered'; + + updateITunesLinkCardPhase(itunesUrlHash, 'discovered'); + + try { + fetch(`/api/itunes-link/update_phase/${itunesUrlHash}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'discovered' }) + }); + } catch (error) { + console.warn('Error updating backend iTunes Link phase:', error); + } + } } else if (isDeezer) { // Deezer: Extract playlist ID and reset Deezer state const deezerPlaylistId = state.deezer_playlist_id || null; @@ -9394,7 +10567,7 @@ function updateYouTubeCardSyncProgress(urlHash, progress) { function updateYouTubeModalSyncProgress(urlHash, progress) { // Try all source-specific element ID prefixes - const prefixes = ['youtube', 'listenbrainz', 'tidal', 'deezer', 'spotify-public', 'beatport']; + const prefixes = ['youtube', 'listenbrainz', 'tidal', 'deezer', 'spotify-public', 'itunes-link', 'beatport']; let statusDisplay = null; let prefix = 'youtube'; for (const p of prefixes) { @@ -9644,6 +10817,118 @@ async function resetBeatportChart(urlHash) { // LISTENBRAINZ PLAYLIST DISCOVERY & SYNC // ============================================================================ +/** + * Auto-mirror a ListenBrainz playlist into the mirrored_playlists + * table after discovery completes. Pattern parity with Tidal — + * Tidal mirrors on tab load with raw tracks, then discovery enriches; + * LB tracks only have provider IDs after discovery, so we mirror at + * the end. Idempotent (UPSERT on source + source_playlist_id + + * profile_id), so calling it twice is a no-op. + * + * Rotating-series collapse: if the playlist title belongs to a + * known LB series (Weekly Jams, Weekly Exploration, Top Discoveries, + * Top Missed Recordings), the mirror is created under a synthetic + * ``source_playlist_id`` like ``lb_weekly_jams_`` with a + * canonical name. The next week / year UPSERTs into the same row, + * so users get one rolling mirror per series instead of accumulating + * one per period. Non-series LB playlists (user-created, + * collaborative, Last.fm radios for a specific seed) continue to + * mirror under their per-playlist MBID. + */ +async function _mirrorListenBrainzAfterDiscovery(playlistMbid) { + try { + const state = listenbrainzPlaylistStates[playlistMbid]; + if (!state || !state.playlist) return; + if (typeof mirrorPlaylist !== 'function') return; + + const results = state.discovery_results || []; + if (!results.length) return; + + const tracks = results + .filter(r => r && r.spotify_data && r.spotify_data.id) + .map(r => { + const sp = r.spotify_data; + const artistName = Array.isArray(sp.artists) + ? (typeof sp.artists[0] === 'object' ? sp.artists[0].name : sp.artists[0]) + : (sp.artists || ''); + const albumName = (sp.album && typeof sp.album === 'object') + ? sp.album.name : (sp.album || ''); + const albumImage = (sp.album && sp.album.images && sp.album.images[0]) + ? sp.album.images[0].url : (sp.image_url || null); + return { + track_name: sp.name || '', + artist_name: artistName || '', + album_name: albumName || '', + duration_ms: sp.duration_ms || 0, + image_url: albumImage, + source_track_id: sp.id || '', + extra_data: JSON.stringify({ + discovered: true, + provider: sp.source || 'spotify', + confidence: r.confidence || 1.0, + matched_data: sp, + }), + }; + }); + + if (!tracks.length) { + console.warn(`🪞 [LB Mirror] No matched tracks in '${state.playlist.name}', skipping mirror`); + return; + } + + // Last.fm Radio playlists live in the same listenbrainz_playlists + // table but are persisted by ``save_lastfm_radio_playlist`` with + // a "Last.fm Radio: " title prefix and ``playlist_type='lastfm_radio'``. + // Route them to ``source='lastfm'`` so the Auto-Sync manager + // groups them under the Last.fm Radio section + the cascade- + // delete hook targets the right mirror source. + const rawTitle = state.playlist.name || 'ListenBrainz Playlist'; + let mirrorSource = rawTitle.startsWith('Last.fm Radio:') ? 'lastfm' : 'listenbrainz'; + let mirrorSourcePlaylistId = playlistMbid; + let mirrorName = rawTitle; + + // Rolling-series detection — backend tells us whether the title + // belongs to a known rotating LB series. If so, collapse this + // mirror onto a synthetic id + canonical name so per-week / + // per-year duplicates roll up into one row. + try { + const seriesResp = await fetch( + `/api/listenbrainz/series-detect?title=${encodeURIComponent(rawTitle)}` + ); + if (seriesResp.ok) { + const seriesData = await seriesResp.json(); + if (seriesData && seriesData.matched) { + mirrorSource = seriesData.source || mirrorSource; + mirrorSourcePlaylistId = seriesData.series_id || mirrorSourcePlaylistId; + mirrorName = seriesData.canonical_name || mirrorName; + console.log( + `🔁 [LB Series] '${rawTitle}' rolled into '${mirrorName}' ` + + `(series id: ${mirrorSourcePlaylistId})` + ); + } + } + } catch (_) { + // Non-fatal — fall through to per-playlist mirror id. + } + + const ownerFallback = mirrorSource === 'lastfm' ? 'Last.fm' : 'ListenBrainz'; + mirrorPlaylist( + mirrorSource, + mirrorSourcePlaylistId, + mirrorName, + tracks, + { + owner: state.playlist.creator || ownerFallback, + description: state.playlist.description || '', + image_url: state.playlist.image_url || '', + } + ); + console.log(`🪞 [${mirrorSource} Mirror] Mirrored '${mirrorName}' with ${tracks.length} matched tracks`); + } catch (err) { + console.warn('LB mirror-after-discovery failed:', err); + } +} + function startListenBrainzDiscoveryPolling(playlistMbid) { console.log(`🔄 Starting ListenBrainz discovery polling for: ${playlistMbid}`); @@ -9694,6 +10979,10 @@ function startListenBrainzDiscoveryPolling(playlistMbid) { const playlistIdEl = `discover-lb-playlist-${playlistMbid}`; const syncBtn = document.getElementById(`${playlistIdEl}-sync-btn`); if (syncBtn) syncBtn.style.display = 'inline-block'; + // Mirror matched tracks → mirrored_playlists table so the + // playlist participates in Auto-Sync schedules just like + // Tidal / Qobuz / Spotify mirrors do. + _mirrorListenBrainzAfterDiscovery(playlistMbid); showToast('ListenBrainz discovery complete!', 'success'); } }; @@ -9786,6 +11075,9 @@ function startListenBrainzDiscoveryPolling(playlistMbid) { } console.log('✅ ListenBrainz discovery complete:', playlistMbid); + // Mirror matched tracks → mirrored_playlists table so + // the playlist participates in Auto-Sync schedules. + _mirrorListenBrainzAfterDiscovery(playlistMbid); showToast('ListenBrainz discovery complete!', 'success'); } diff --git a/webui/static/sync-soulsync-discovery.js b/webui/static/sync-soulsync-discovery.js new file mode 100644 index 00000000..6a707daa --- /dev/null +++ b/webui/static/sync-soulsync-discovery.js @@ -0,0 +1,266 @@ +// =================================================================== +// SOULSYNC DISCOVERY SYNC TAB (Phase 1c.3) +// =================================================================== +// Surfaces the user's persisted SoulSync Discovery / personalized +// playlists (decade mixes, hidden gems, popular picks, daily mixes, +// discovery shuffle, etc.) as a Sync-page tab so they participate +// in the mirrored-playlist + Auto-Sync pipeline like every other +// source. +// +// Different shape from the LB / Last.fm tabs: personalized tracks +// already carry Spotify / iTunes / Deezer IDs (matched at generation +// time from the discovery pool), so there's no MB-style "needs +// discovery" hop. Click → refresh kind → grab tracks → mirror as +// ``source='soulsync_discovery'`` with the matched_data shape +// downstream consumers already expect from auto-discovered Spotify +// mirrors. + +let _soulsyncDiscoverySyncRecords = []; + +async function loadSoulsyncDiscoverySyncPlaylists() { + const container = document.getElementById('soulsync-discovery-sync-playlist-container'); + const refreshBtn = document.getElementById('soulsync-discovery-sync-refresh-btn'); + if (!container) return; + + container.innerHTML = `
🔄 Loading SoulSync Discovery playlists...
`; + if (refreshBtn) { + refreshBtn.disabled = true; + refreshBtn.textContent = '🔄 Loading...'; + } + + try { + const resp = await fetch('/api/personalized/playlists'); + const data = await resp.json(); + if (!data.success) { + container.innerHTML = `
❌ ${escapeHtml(data.error || 'Failed to load')}
`; + return; + } + _soulsyncDiscoverySyncRecords = data.playlists || []; + renderSoulsyncDiscoverySyncPlaylists(); + console.log(`✨ SoulSync Discovery Sync tab loaded: ${_soulsyncDiscoverySyncRecords.length} playlists`); + } catch (err) { + container.innerHTML = `
❌ Error: ${err.message}
`; + if (typeof showToast === 'function') { + showToast(`Error loading SoulSync Discovery: ${err.message}`, 'error'); + } + } finally { + if (refreshBtn) { + refreshBtn.disabled = false; + refreshBtn.textContent = '🔄 Refresh'; + } + } +} + +function renderSoulsyncDiscoverySyncPlaylists() { + const container = document.getElementById('soulsync-discovery-sync-playlist-container'); + if (!container) return; + + if (_soulsyncDiscoverySyncRecords.length === 0) { + container.innerHTML = `
No SoulSync Discovery playlists yet. Open the Discover page and generate a few personalized playlists first.
`; + return; + } + + container.innerHTML = _soulsyncDiscoverySyncRecords.map(p => { + const syntheticId = _soulsyncSyntheticId(p.kind, p.variant); + const title = p.name || `${p.kind} ${p.variant || ''}`.trim(); + const subtitle = p.variant ? `${p.kind} · ${p.variant}` : p.kind; + const count = p.track_count || 0; + const stale = !!p.is_stale; + const stalenessText = stale ? 'Stale — refresh to regenerate' : 'Ready'; + const stalenessColor = stale ? '#facc15' : '#14b8a6'; + + return ` +
+
+
+
${escapeHtml(title)}
+
+ ${count} tracks + ${escapeHtml(subtitle)} + ${stalenessText} +
+
+ + +
+ `; + }).join(''); + + container.querySelectorAll('.soulsync-discovery-playlist-card').forEach(card => { + card.addEventListener('click', () => { + const kind = card.dataset.ssdKind; + const variant = card.dataset.ssdVariant; + const name = card.dataset.ssdName; + handleSoulsyncDiscoverySyncCardClick(kind, variant, name, card); + }); + }); +} + +function _soulsyncSyntheticId(kind, variant) { + // Synthetic stable id keyed on (kind, variant) so re-refreshes UPSERT + // the same mirror row instead of duplicating. Empty variant collapses + // cleanly (e.g. hidden_gems with no variant -> "ssd_hidden_gems"). + return `ssd_${kind}${variant ? `_${variant}` : ''}`; +} + +async function handleSoulsyncDiscoverySyncCardClick(kind, variant, name, cardEl) { + if (!kind) { + if (typeof showToast === 'function') showToast('Missing kind', 'error'); + return; + } + const btn = cardEl ? cardEl.querySelector('.playlist-card-action-btn') : null; + const progEl = cardEl ? cardEl.querySelector('.playlist-card-progress') : null; + if (btn) { + btn.disabled = true; + btn.textContent = 'Refreshing…'; + } + if (progEl) progEl.classList.remove('hidden'); + + try { + // Trigger the kind's generator and grab fresh tracks. + const url = variant + ? `/api/personalized/playlist/${encodeURIComponent(kind)}/${encodeURIComponent(variant)}/refresh` + : `/api/personalized/playlist/${encodeURIComponent(kind)}/refresh`; + const resp = await fetch(url, { method: 'POST' }); + const data = await resp.json(); + if (!data.success) { + throw new Error(data.error || 'Generator failed'); + } + + const rec = data.playlist || {}; + const tracks = data.tracks || []; + const finalName = rec.name || name || `${kind} ${variant || ''}`.trim(); + const syntheticId = _soulsyncSyntheticId(kind, variant); + + if (tracks.length === 0) { + if (typeof showToast === 'function') { + showToast(`'${finalName}' generated 0 tracks. Try widening the playlist's config in Discover.`, 'warning'); + } + } + + // Project each track into the mirrorPlaylist contract. Tracks + // already carry provider IDs from the discovery pool, so the + // matched_data block is filled inline — no separate discovery + // worker pass needed. + const mirrorTracks = tracks.map(t => { + const trackId = t.spotify_track_id || t.itunes_track_id || t.deezer_track_id || ''; + const provider = t.spotify_track_id ? 'spotify' + : (t.itunes_track_id ? 'itunes' + : (t.deezer_track_id ? 'deezer' : (t.source || 'unknown'))); + const albumObj = { name: t.album_name || '' }; + if (t.album_cover_url) { + albumObj.images = [{ url: t.album_cover_url, height: 600, width: 600 }]; + } + const extra = trackId ? JSON.stringify({ + discovered: true, + provider, + confidence: 1.0, + matched_data: { + id: trackId, + name: t.track_name || '', + artists: [{ name: t.artist_name || '' }], + album: albumObj, + duration_ms: t.duration_ms || 0, + image_url: t.album_cover_url || null, + source: provider, + }, + }) : null; + return { + track_name: t.track_name || '', + artist_name: t.artist_name || '', + album_name: t.album_name || '', + duration_ms: t.duration_ms || 0, + image_url: t.album_cover_url || null, + source_track_id: trackId, + extra_data: extra, + }; + }); + + // POST inline so we can capture the returned mirrored_playlists + // row id and open the detail modal afterward. ``mirrorPlaylist`` + // (in stats-automations.js) is fire-and-forget and doesn't + // surface the id, which the next step needs. + const normalizedTracks = mirrorTracks.map(t => ({ + track_name: t.track_name || '', + artist_name: t.artist_name || '', + album_name: t.album_name || '', + duration_ms: t.duration_ms || 0, + image_url: t.image_url || null, + source_track_id: t.source_track_id || '', + extra_data: t.extra_data || null, + })); + const mirrorResp = await fetch('/api/mirror-playlist', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + source: 'soulsync_discovery', + source_playlist_id: syntheticId, + name: finalName, + tracks: normalizedTracks, + description: `Personalized ${kind}${variant ? ' · ' + variant : ''} — regenerates on Auto-Sync refresh.`, + owner: 'SoulSync', + image_url: '', + }), + }); + const mirrorData = await mirrorResp.json(); + if (!mirrorData.success) { + throw new Error(mirrorData.error || 'Mirror creation failed'); + } + const mirroredId = mirrorData.playlist_id; + + if (progEl) { + progEl.textContent = `♪ ${tracks.length} / ✓ ${mirrorTracks.length} / mirrored`; + } + if (btn) { + btn.disabled = false; + btn.textContent = 'Refresh & Mirror'; + } + + // Update the in-memory record so the card displays the new count. + const idx = _soulsyncDiscoverySyncRecords.findIndex( + r => r.kind === kind && (r.variant || '') === (variant || '') + ); + if (idx >= 0) { + _soulsyncDiscoverySyncRecords[idx] = { + ..._soulsyncDiscoverySyncRecords[idx], + ...rec, + track_count: tracks.length, + is_stale: false, + }; + } + + if (typeof showToast === 'function') { + showToast(`Mirrored '${finalName}' with ${mirrorTracks.length} tracks`, 'success'); + } + + // Open the mirrored-playlist detail modal so the user lands on + // the tracks view + can trigger sync / download from there. + // Same flow the Mirrored tab uses when clicking a row. + if (mirroredId && typeof openMirroredPlaylistModal === 'function') { + try { + await openMirroredPlaylistModal(mirroredId); + } catch (e) { + console.warn('Could not open mirrored playlist detail:', e); + } + } + } catch (err) { + if (btn) { + btn.disabled = false; + btn.textContent = 'Refresh & Mirror'; + } + if (typeof showToast === 'function') { + showToast(`Refresh failed: ${err.message}`, 'error'); + } + console.error('SoulSync Discovery refresh failed:', err); + } +} + +document.addEventListener('DOMContentLoaded', () => { + const btn = document.getElementById('soulsync-discovery-sync-refresh-btn'); + if (btn) btn.addEventListener('click', loadSoulsyncDiscoverySyncPlaylists); +}); diff --git a/webui/static/wishlist-tools.js b/webui/static/wishlist-tools.js index a1fbc4b3..0b4084ad 100644 --- a/webui/static/wishlist-tools.js +++ b/webui/static/wishlist-tools.js @@ -396,13 +396,16 @@ async function selectDiscoveryFixTrack(track) { // For Spotify Public, backend expects the url_hash const state = youtubePlaylistStates[identifier]; backendIdentifier = state?.spotify_public_playlist_id || identifier; + } else if (platform === 'itunes_link') { + const state = youtubePlaylistStates[identifier]; + backendIdentifier = state?.itunes_link_playlist_id || identifier; } else if (platform === 'beatport') { // For Beatport, backend expects url_hash (same as identifier) backendIdentifier = identifier; } // Mirrored playlists route through the YouTube endpoint (which already handles mirrored_ prefixes) - const apiPlatform = platform === 'mirrored' ? 'youtube' : (platform === 'spotify_public' ? 'spotify-public' : platform); + const apiPlatform = platform === 'mirrored' ? 'youtube' : (platform === 'spotify_public' ? 'spotify-public' : (platform === 'itunes_link' ? 'itunes-link' : platform)); const requestBody = { identifier: backendIdentifier, @@ -459,6 +462,8 @@ async function selectDiscoveryFixTrack(track) { state = youtubePlaylistStates[identifier]; } else if (platform === 'spotify_public') { state = youtubePlaylistStates[identifier]; + } else if (platform === 'itunes_link') { + state = youtubePlaylistStates[identifier]; } // Support both camelCase and snake_case @@ -561,6 +566,17 @@ async function selectDiscoveryFixTrack(track) { }); } } + + if (platform === 'itunes_link' && state.itunes_link_playlist_id) { + const itunesState = itunesLinkPlaylistStates?.[state.itunes_link_playlist_id]; + if (itunesState) { + itunesState.spotifyMatches = state.spotifyMatches; + updateITunesLinkCardProgress(state.itunes_link_playlist_id, { + spotify_matches: state.spotifyMatches, + spotify_total: spotify_total + }); + } + } } // Update UI - refresh the table row @@ -623,10 +639,19 @@ function updateDiscoveryModalSingleRow(platform, identifier, trackIndex) { } async function unmatchDiscoveryTrack(platform, identifier, trackIndex) { + const uiState = (typeof youtubePlaylistStates !== 'undefined' ? youtubePlaylistStates[identifier] : null) + || (typeof listenbrainzPlaylistStates !== 'undefined' ? listenbrainzPlaylistStates[identifier] : null); + const backendIdentifier = platform === 'spotify_public' + ? (uiState?.spotify_public_playlist_id || identifier) + : platform === 'itunes_link' + ? (uiState?.itunes_link_playlist_id || identifier) + : identifier; + // Determine the correct API base for this platform const apiBase = platform === 'tidal' ? '/api/tidal' : platform === 'deezer' ? '/api/deezer' - : platform === 'spotify-public' ? '/api/spotify-public' + : (platform === 'spotify-public' || platform === 'spotify_public') ? '/api/spotify-public' + : (platform === 'itunes-link' || platform === 'itunes_link') ? '/api/itunes-link' : platform === 'beatport' ? '/api/beatport' : platform === 'listenbrainz' ? '/api/listenbrainz' : '/api/youtube'; @@ -635,7 +660,7 @@ async function unmatchDiscoveryTrack(platform, identifier, trackIndex) { const response = await fetch(`${apiBase}/discovery/unmatch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ identifier, track_index: trackIndex }) + body: JSON.stringify({ identifier: backendIdentifier, track_index: trackIndex }) }); const data = await response.json(); if (data.success) { @@ -6530,6 +6555,7 @@ const TOOL_HELP_CONTENT = {
  • Bot Token: Your Telegram bot token (from @BotFather)
  • Chat ID: The chat/group ID to send messages to
  • +
  • Thread ID: Optional — only set if your group uses Telegram topics. Leave blank for the main chat.
  • Message Template: Custom message with variable placeholders