From 0f428dc45c4df7bc5c39bb2d5a3aa08330ef1b75 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 3 Mar 2026 18:26:29 -0800 Subject: [PATCH] Add WebSocket real-time updates with automatic HTTP polling fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates 38 HTTP polling loops to WebSocket push events across 6 phases: service status, dashboard stats, enrichment workers, tool progress, sync/discovery progress, and scan status. All original HTTP polling is preserved as automatic fallback — if WebSocket is unavailable or disconnects, the app seamlessly reverts to its previous behavior. Includes 162 tests verifying event delivery, data shape, and HTTP parity. Also fixes a copy-paste bug in Beatport sync error cleanup. --- requirements-webui.txt | 5 +- tests/conftest.py | 1061 ++++++++++++ tests/test_phase1_websocket.py | 397 +++++ tests/test_phase2_dashboard.py | 388 +++++ tests/test_phase3_enrichment.py | 229 +++ tests/test_phase4_tools.py | 329 ++++ tests/test_phase5_sync.py | 920 +++++++++++ web_server.py | 556 ++++++- webui/index.html | 1 + webui/static/script.js | 2242 ++++++++++++++++++-------- webui/static/style.css | 2 +- webui/static/vendor/socket.io.min.js | 7 + 12 files changed, 5340 insertions(+), 797 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_phase1_websocket.py create mode 100644 tests/test_phase2_dashboard.py create mode 100644 tests/test_phase3_enrichment.py create mode 100644 tests/test_phase4_tools.py create mode 100644 tests/test_phase5_sync.py create mode 100644 webui/static/vendor/socket.io.min.js diff --git a/requirements-webui.txt b/requirements-webui.txt index 8617a599..902fdbd6 100644 --- a/requirements-webui.txt +++ b/requirements-webui.txt @@ -46,4 +46,7 @@ pyacoustid>=1.3.0 websocket-client>=1.7.0 # Tidal download support -tidalapi>=0.7.6 \ No newline at end of file +tidalapi>=0.7.6 + +# WebSocket server for real-time UI updates +flask-socketio>=5.3.0 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..b52f91a1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,1061 @@ +"""Shared pytest fixtures for SoulSync WebSocket tests. + +Creates a minimal Flask+SocketIO app that replicates the relevant +endpoints and event handlers without importing the full web_server.py +(which would try to initialize Spotify, Soulseek, Plex, etc.).""" + +import copy +import pytest +import threading +import time +from flask import Flask, jsonify +from flask_socketio import SocketIO, join_room, leave_room + + +# --------------------------------------------------------------------------- +# Fake state that mirrors the real web_server.py module-level globals +# --------------------------------------------------------------------------- + +_DEFAULT_STATUS_CACHE = { + 'spotify': {'connected': True, 'response_time': 12.5, 'source': 'spotify'}, + 'media_server': {'connected': True, 'response_time': 8.1, 'type': 'plex'}, + 'soulseek': {'connected': True, 'response_time': 5.3, 'source': 'soulseek'}, +} + +_DEFAULT_WATCHLIST_STATE = { + 'count': 7, + 'next_run_in_seconds': 3600, +} + +# Phase 2: Dashboard state defaults +_DEFAULT_SYSTEM_STATS = { + 'active_downloads': 2, + 'finished_downloads': 15, + 'download_speed': '1.2 MB/s', + 'active_syncs': 1, + 'uptime': '2:30:00', + 'memory_usage': '45.2%', +} + +_DEFAULT_DB_STATS = { + 'artists': 350, + 'albums': 1200, + 'tracks': 14500, + 'database_size_mb': 48.75, + 'server_source': 'plex', + 'last_full_refresh': '2026-03-01T12:00:00', +} + +_DEFAULT_WISHLIST_COUNT = { + 'count': 5, +} + +# Phase 3: Enrichment worker state defaults +_ENRICHMENT_COMMON = { + 'enabled': True, 'running': True, 'paused': False, 'idle': False, + 'current_item': {'name': 'Pink Floyd', 'type': 'artist'}, + 'stats': {'matched': 10, 'not_found': 2, 'pending': 50, 'errors': 0}, + 'progress': { + 'artists': {'matched': 10, 'total': 50, 'percent': 20}, + 'albums': {'matched': 0, 'total': 100, 'percent': 0}, + 'tracks': {'matched': 0, 'total': 500, 'percent': 0}, + } +} + +_DEFAULT_ENRICHMENT_STATUS = { + 'musicbrainz': copy.deepcopy(_ENRICHMENT_COMMON), + 'audiodb': copy.deepcopy(_ENRICHMENT_COMMON), + 'deezer': copy.deepcopy(_ENRICHMENT_COMMON), + 'spotify-enrichment': {**copy.deepcopy(_ENRICHMENT_COMMON), 'authenticated': True}, + 'itunes-enrichment': copy.deepcopy(_ENRICHMENT_COMMON), + 'hydrabase': { + 'enabled': True, 'running': True, 'paused': False, + 'queue_size': 12, 'stats': {'sent': 100, 'dropped': 2, 'errors': 0}, + }, + 'repair': { + 'enabled': True, 'running': True, 'paused': False, 'idle': False, + 'current_item': {'name': 'song.mp3', 'type': 'track'}, + 'stats': {'scanned': 50, 'repaired': 3, 'skipped': 10, 'errors': 0, 'pending': 150}, + 'progress': { + 'tracks': {'checked': 50, 'total': 200, 'percent': 25, 'repaired': 3}, + } + }, +} + +# Phase 4: Tool progress state defaults +_DEFAULT_STREAM_STATE = { + "status": "loading", "progress": 45, + "track_info": {"artist": "Pink Floyd", "title": "Comfortably Numb"}, + "error_message": None, +} + +_DEFAULT_QUALITY_SCANNER_STATE = { + "status": "running", "phase": "Scanning...", "progress": 35, + "processed": 35, "total": 100, "quality_met": 30, + "low_quality": 5, "matched": 2, "error_message": "", "results": [], +} + +_DEFAULT_DUPLICATE_CLEANER_STATE = { + "status": "running", "phase": "Scanning...", "progress": 50, + "files_scanned": 500, "total_files": 1000, "duplicates_found": 10, + "deleted": 5, "space_freed": 52428800, "error_message": "", +} + +_DEFAULT_RETAG_STATE = { + "status": "running", "phase": "Retagging...", "progress": 25, + "current_track": "song.mp3", "total_tracks": 200, "processed": 50, + "error_message": "", +} + +_DEFAULT_DB_UPDATE_STATE = { + "status": "running", "phase": "Updating...", "progress": 40, + "current_item": "Pink Floyd", "processed": 40, "total": 100, + "error_message": "", "removed_artists": 0, "removed_albums": 0, "removed_tracks": 0, +} + +_DEFAULT_METADATA_STATE = { + "status": "running", "current_artist": "Pink Floyd", + "processed": 10, "total": 50, "percentage": 20.0, + "successful": 9, "failed": 1, "started_at": None, "completed_at": None, + "error": None, "refresh_interval_days": 30, +} + +_DEFAULT_LOGS_ACTIVITIES = [ + {"icon": "\U0001f3b5", "title": "Download Complete", "subtitle": "Artist - Song", "time": "Now"}, +] + +# Phase 5: Sync/Discovery/Scan state defaults +_DEFAULT_SYNC_STATES = { + 'test-playlist-1': { + 'status': 'syncing', + 'progress': { + 'total_tracks': 11, 'matched_tracks': 5, 'failed_tracks': 1, + 'progress': 45, 'current_step': 'Matching...', 'current_track': 'Test Song', + }, + 'playlist_id': 'test-playlist-1', 'playlist_name': 'Test Playlist', + }, + # Phase 6: Platform-specific sync IDs + 'tidal_test-tidal-1': { + 'status': 'syncing', + 'progress': { + 'total_tracks': 8, 'matched_tracks': 3, 'failed_tracks': 0, + 'progress': 37, 'current_step': 'Matching...', 'current_track': 'Tidal Song', + }, + 'playlist_id': 'tidal_test-tidal-1', 'playlist_name': 'Tidal Test Playlist', + }, + 'youtube_test-yt-hash': { + 'status': 'syncing', + 'progress': { + 'total_tracks': 10, 'matched_tracks': 4, 'failed_tracks': 1, + 'progress': 50, 'current_step': 'Matching...', 'current_track': 'YT Song', + }, + 'playlist_id': 'youtube_test-yt-hash', 'playlist_name': 'YouTube Test Playlist', + }, + 'beatport_sync_test-bp-hash_1234': { + 'status': 'syncing', + 'progress': { + 'total_tracks': 15, 'matched_tracks': 7, 'failed_tracks': 2, + 'progress': 60, 'current_step': 'Matching...', 'current_track': 'BP Song', + }, + 'playlist_id': 'beatport_sync_test-bp-hash_1234', 'playlist_name': 'Beatport Test Chart', + }, + 'listenbrainz_test-lb-mbid': { + 'status': 'syncing', + 'progress': { + 'total_tracks': 12, 'matched_tracks': 6, 'failed_tracks': 0, + 'progress': 50, 'current_step': 'Matching...', 'current_track': 'LB Song', + }, + 'playlist_id': 'listenbrainz_test-lb-mbid', 'playlist_name': 'ListenBrainz Test Playlist', + }, +} + +_DEFAULT_DISCOVERY_STATES = { + 'tidal': { + 'test-tidal-1': { + 'phase': 'discovering', 'status': 'running', + 'discovery_progress': 50, 'spotify_matches': 5, 'spotify_total': 10, + 'discovery_results': [ + {'tidal_track': {'name': 'Song A', 'artists': ['Artist A']}, + 'status': 'found', 'status_class': 'found', + 'spotify_data': {'name': 'Song A', 'artists': ['Artist A'], 'album': 'Album A'}, + 'spotify_id': 'sp1', 'manual_match': False}, + ], + } + }, + 'youtube': { + 'test-yt-hash': { + 'phase': 'discovering', 'status': 'running', + 'discovery_progress': 30, 'spotify_matches': 3, 'spotify_total': 10, + 'discovery_results': [ + {'index': 0, 'yt_track': 'Song B', 'yt_artist': 'Artist B', + 'status': '✅ Found', 'status_class': 'found', + 'spotify_track': 'Song B', 'spotify_artist': 'Artist B', + 'spotify_album': 'Album B'}, + ], + } + }, + 'beatport': {}, + 'listenbrainz': {}, +} + +_DEFAULT_WATCHLIST_SCAN_STATE = { + 'status': 'scanning', + 'current_artist_name': 'Pink Floyd', 'current_album': 'Dark Side', + 'current_track_name': 'Money', + 'current_artist_image_url': '', 'current_album_image_url': '', + 'current_phase': 'scanning', 'recent_wishlist_additions': [], +} + +_DEFAULT_MEDIA_SCAN_STATE = { + 'is_scanning': True, 'status': 'scanning', + 'progress_message': 'Scanning library...', +} + +_DEFAULT_WISHLIST_STATS = { + 'is_auto_processing': False, + 'next_run_in_seconds': 120, +} + +_status_cache = copy.deepcopy(_DEFAULT_STATUS_CACHE) +watchlist_state = copy.deepcopy(_DEFAULT_WATCHLIST_STATE) +download_batches = {} # batch_id -> {phase, tasks, ...} +tasks_lock = threading.Lock() + +# Phase 2: Dashboard state +system_stats = copy.deepcopy(_DEFAULT_SYSTEM_STATS) +activity_feed = [] +activity_feed_lock = threading.Lock() +db_stats = copy.deepcopy(_DEFAULT_DB_STATS) +wishlist_count = copy.deepcopy(_DEFAULT_WISHLIST_COUNT) + +# Phase 3: Enrichment worker state +enrichment_status = copy.deepcopy(_DEFAULT_ENRICHMENT_STATUS) + +# Phase 4: Tool progress state +stream_state = copy.deepcopy(_DEFAULT_STREAM_STATE) +quality_scanner_state = copy.deepcopy(_DEFAULT_QUALITY_SCANNER_STATE) +duplicate_cleaner_state = copy.deepcopy(_DEFAULT_DUPLICATE_CLEANER_STATE) +retag_state = copy.deepcopy(_DEFAULT_RETAG_STATE) +db_update_state = copy.deepcopy(_DEFAULT_DB_UPDATE_STATE) +metadata_update_state = copy.deepcopy(_DEFAULT_METADATA_STATE) +logs_activities = copy.deepcopy(_DEFAULT_LOGS_ACTIVITIES) + +# Phase 5: Sync/Discovery/Scan state +sync_states = copy.deepcopy(_DEFAULT_SYNC_STATES) +sync_lock = threading.Lock() +discovery_states = copy.deepcopy(_DEFAULT_DISCOVERY_STATES) +watchlist_scan_state = copy.deepcopy(_DEFAULT_WATCHLIST_SCAN_STATE) +media_scan_state = copy.deepcopy(_DEFAULT_MEDIA_SCAN_STATE) +wishlist_stats_state = copy.deepcopy(_DEFAULT_WISHLIST_STATS) + + +# --------------------------------------------------------------------------- +# Helpers (same signatures as real web_server.py) +# --------------------------------------------------------------------------- + +def _build_status_payload(): + return { + 'spotify': dict(_status_cache['spotify']), + 'media_server': dict(_status_cache['media_server']), + 'soulseek': dict(_status_cache['soulseek']), + 'active_media_server': _status_cache['media_server'].get('type', 'plex'), + } + + +def _build_watchlist_count_payload(): + return { + 'success': True, + 'count': watchlist_state['count'], + 'next_run_in_seconds': watchlist_state['next_run_in_seconds'], + } + + +def _build_batch_status_data(batch_id, batch): + """Simplified version — real one is ~200 lines.""" + return { + 'phase': batch.get('phase', 'downloading'), + 'tasks': batch.get('tasks', []), + 'active_count': batch.get('active_count', 0), + 'max_concurrent': batch.get('max_concurrent', 3), + 'playlist_id': batch.get('playlist_id', ''), + 'playlist_name': batch.get('playlist_name', ''), + } + + +# Phase 2 helpers + +def _build_system_stats(): + return dict(system_stats) + + +def _build_activity_feed_payload(): + with activity_feed_lock: + return {'activities': list(activity_feed[-10:][::-1])} + + +def _build_db_stats(): + return dict(db_stats) + + +def _build_wishlist_count_payload(): + return dict(wishlist_count) + + +# Phase 3 helpers + +def _build_enrichment_status(worker_name): + return copy.deepcopy(enrichment_status.get(worker_name, {})) + +ENRICHMENT_WORKERS = [ + 'musicbrainz', 'audiodb', 'deezer', + 'spotify-enrichment', 'itunes-enrichment', + 'hydrabase', 'repair', +] + +ENRICHMENT_ENDPOINTS = { + 'musicbrainz': '/api/musicbrainz/status', + 'audiodb': '/api/audiodb/status', + 'deezer': '/api/deezer/status', + 'spotify-enrichment': '/api/spotify-enrichment/status', + 'itunes-enrichment': '/api/itunes-enrichment/status', + 'hydrabase': '/api/hydrabase-worker/status', + 'repair': '/api/repair/status', +} + +# Phase 4 helpers + +TOOL_NAMES = [ + 'stream', 'quality-scanner', 'duplicate-cleaner', + 'retag', 'db-update', 'metadata', 'logs', +] + +TOOL_ENDPOINTS = { + 'stream': '/api/stream/status', + 'quality-scanner': '/api/quality-scanner/status', + 'duplicate-cleaner': '/api/duplicate-cleaner/status', + 'retag': '/api/retag/status', + 'db-update': '/api/database/update/status', + 'metadata': '/api/metadata/status', + 'logs': '/api/logs', +} + + +def _build_stream_status(): + return { + "status": stream_state["status"], + "progress": stream_state["progress"], + "track_info": stream_state["track_info"], + "error_message": stream_state["error_message"], + } + + +def _build_quality_scanner_status(): + return dict(quality_scanner_state) + + +def _build_duplicate_cleaner_status(): + state_copy = duplicate_cleaner_state.copy() + state_copy["space_freed_mb"] = duplicate_cleaner_state["space_freed"] / (1024 * 1024) + return state_copy + + +def _build_retag_status(): + return dict(retag_state) + + +def _build_db_update_status(): + return dict(db_update_state) + + +def _build_metadata_status(): + state_copy = metadata_update_state.copy() + if state_copy.get('started_at'): + state_copy['started_at'] = state_copy['started_at'].isoformat() + if state_copy.get('completed_at'): + state_copy['completed_at'] = state_copy['completed_at'].isoformat() + return {"success": True, "status": state_copy} + + +def _build_logs(): + recent = logs_activities[-50:][::-1] + formatted = [] + for a in recent: + ts = a.get('time', 'Unknown') + icon = a.get('icon', '\u2022') + title = a.get('title', 'Activity') + sub = a.get('subtitle', '') + formatted.append(f"[{ts}] {icon} {title} - {sub}" if sub else f"[{ts}] {icon} {title}") + if not formatted: + formatted = ["No recent activity.", "Sync and download operations..."] + return {'logs': formatted} + + +def _build_tool_status(tool_name): + """Dispatcher that returns the correct status payload for any tool.""" + builders = { + 'stream': _build_stream_status, + 'quality-scanner': _build_quality_scanner_status, + 'duplicate-cleaner': _build_duplicate_cleaner_status, + 'retag': _build_retag_status, + 'db-update': _build_db_update_status, + 'metadata': _build_metadata_status, + 'logs': _build_logs, + } + return builders[tool_name]() + + +# Phase 5 helpers + +SYNC_ENDPOINTS = { + 'sync': '/api/sync/status/test-playlist-1', + # Phase 6: Platform-specific sync endpoints (use generic sync status) + 'tidal_sync': '/api/sync/status/tidal_test-tidal-1', + 'youtube_sync': '/api/sync/status/youtube_test-yt-hash', + 'beatport_sync': '/api/sync/status/beatport_sync_test-bp-hash_1234', + 'listenbrainz_sync': '/api/sync/status/listenbrainz_test-lb-mbid', +} + +DISCOVERY_ENDPOINTS = { + 'tidal': '/api/tidal/discovery/status/test-tidal-1', + 'youtube': '/api/youtube/discovery/status/test-yt-hash', +} + +SCAN_ENDPOINTS = { + 'watchlist': '/api/watchlist/scan/status', + 'media': '/api/scan/status', + 'wishlist_stats': '/api/wishlist/stats', +} + + +def _build_sync_status(playlist_id): + with sync_lock: + state = sync_states.get(playlist_id, {}) + return dict(state) if state else {'status': 'not_found'} + + +def _build_discovery_status(platform, pid): + states = discovery_states.get(platform, {}) + state = states.get(pid, {}) + if not state: + return {'error': 'Not found'} + return { + 'phase': state.get('phase'), + 'status': state.get('status', 'unknown'), + 'progress': state.get('discovery_progress', 0), + 'spotify_matches': state.get('spotify_matches', 0), + 'spotify_total': state.get('spotify_total', 0), + 'results': state.get('discovery_results', []), + 'complete': state.get('phase') == 'discovered', + } + + +def _build_watchlist_scan_status(): + return {"success": True, **watchlist_scan_state} + + +def _build_media_scan_status(): + return {"success": True, "status": dict(media_scan_state)} + + +def _build_wishlist_stats(): + return dict(wishlist_stats_state) + + +# Shared reference for socketio — set during test_app fixture +_test_socketio = None + + +def add_activity_item(icon, title, subtitle, time_ago="Now", show_toast=True): + """Mirrors web_server.py's add_activity_item with instant toast push.""" + activity_item = { + 'icon': icon, + 'title': title, + 'subtitle': subtitle, + 'time': time_ago, + 'timestamp': time.time(), + 'show_toast': show_toast, + } + with activity_feed_lock: + activity_feed.append(activity_item) + if len(activity_feed) > 20: + activity_feed.pop(0) + + # Instant toast push via WebSocket + if show_toast and _test_socketio is not None: + try: + _test_socketio.emit('dashboard:toast', activity_item) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def test_app(): + """Create a minimal Flask + SocketIO app that mirrors Phase 1+2 endpoints.""" + global _test_socketio + + app = Flask(__name__) + app.config['TESTING'] = True + app.start_time = time.time() + socketio = SocketIO(app, async_mode='threading', cors_allowed_origins='*') + _test_socketio = socketio + + # --- Phase 1 HTTP endpoints --- + + @app.route('/status') + def get_status(): + return jsonify(_build_status_payload()) + + @app.route('/api/watchlist/count') + def get_watchlist_count_endpoint(): + return jsonify(_build_watchlist_count_payload()) + + @app.route('/api/download_status/batch') + def get_batched_download_statuses(): + from flask import request + requested_ids = request.args.getlist('batch_ids') + response = {'batches': {}} + with tasks_lock: + target = {bid: b for bid, b in download_batches.items() + if not requested_ids or bid in requested_ids} + for bid, batch in target.items(): + response['batches'][bid] = _build_batch_status_data(bid, batch) + response['metadata'] = { + 'total_batches': len(response['batches']), + 'requested_batch_ids': requested_ids, + 'timestamp': time.time(), + } + return jsonify(response) + + # --- Phase 2 HTTP endpoints --- + + @app.route('/api/system/stats') + def get_system_stats(): + try: + return jsonify(_build_system_stats()) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @app.route('/api/activity/feed') + def get_activity_feed(): + try: + return jsonify(_build_activity_feed_payload()) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @app.route('/api/activity/toasts') + def get_recent_toasts(): + try: + current_time = time.time() + with activity_feed_lock: + recent_toasts = [ + a for a in activity_feed + if a.get('show_toast', True) and + (current_time - a.get('timestamp', 0)) <= 10 + ] + return jsonify({'toasts': recent_toasts}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @app.route('/api/database/stats') + def get_database_stats(): + try: + return jsonify(_build_db_stats()) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @app.route('/api/wishlist/count') + def get_wishlist_count_api(): + try: + return jsonify(_build_wishlist_count_payload()) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + # --- Phase 3 HTTP endpoints (enrichment workers) --- + + @app.route('/api/musicbrainz/status') + def musicbrainz_status(): + return jsonify(_build_enrichment_status('musicbrainz')) + + @app.route('/api/audiodb/status') + def audiodb_status(): + return jsonify(_build_enrichment_status('audiodb')) + + @app.route('/api/deezer/status') + def deezer_status(): + return jsonify(_build_enrichment_status('deezer')) + + @app.route('/api/spotify-enrichment/status') + def spotify_enrichment_status(): + return jsonify(_build_enrichment_status('spotify-enrichment')) + + @app.route('/api/itunes-enrichment/status') + def itunes_enrichment_status(): + return jsonify(_build_enrichment_status('itunes-enrichment')) + + @app.route('/api/hydrabase-worker/status') + def hydrabase_worker_status(): + return jsonify(_build_enrichment_status('hydrabase')) + + @app.route('/api/repair/status') + def repair_status(): + return jsonify(_build_enrichment_status('repair')) + + # --- Phase 4 HTTP endpoints (tool progress) --- + + @app.route('/api/stream/status') + def stream_status_endpoint(): + return jsonify(_build_stream_status()) + + @app.route('/api/quality-scanner/status') + def quality_scanner_status_endpoint(): + return jsonify(_build_quality_scanner_status()) + + @app.route('/api/duplicate-cleaner/status') + def duplicate_cleaner_status_endpoint(): + return jsonify(_build_duplicate_cleaner_status()) + + @app.route('/api/retag/status') + def retag_status_endpoint(): + return jsonify(_build_retag_status()) + + @app.route('/api/database/update/status') + def db_update_status_endpoint(): + return jsonify(_build_db_update_status()) + + @app.route('/api/metadata/status') + def metadata_status_endpoint(): + return jsonify(_build_metadata_status()) + + @app.route('/api/logs') + def logs_endpoint(): + return jsonify(_build_logs()) + + # --- Phase 5 HTTP endpoints (sync/discovery/scan) --- + + @app.route('/api/sync/status/') + def sync_status_endpoint(playlist_id): + status = _build_sync_status(playlist_id) + if status.get('status') == 'not_found': + return jsonify({'error': 'Sync not found'}), 404 + return jsonify(status) + + @app.route('/api/tidal/discovery/status/') + def tidal_discovery_status_endpoint(playlist_id): + return jsonify(_build_discovery_status('tidal', playlist_id)) + + @app.route('/api/youtube/discovery/status/') + def youtube_discovery_status_endpoint(url_hash): + return jsonify(_build_discovery_status('youtube', url_hash)) + + @app.route('/api/beatport/discovery/status/') + def beatport_discovery_status_endpoint(url_hash): + return jsonify(_build_discovery_status('beatport', url_hash)) + + @app.route('/api/listenbrainz/discovery/status/') + def listenbrainz_discovery_status_endpoint(playlist_mbid): + return jsonify(_build_discovery_status('listenbrainz', playlist_mbid)) + + @app.route('/api/watchlist/scan/status') + def watchlist_scan_status_endpoint(): + return jsonify(_build_watchlist_scan_status()) + + @app.route('/api/scan/status') + def media_scan_status_endpoint(): + return jsonify(_build_media_scan_status()) + + @app.route('/api/wishlist/stats') + def wishlist_stats_endpoint(): + return jsonify(_build_wishlist_stats()) + + # --- Phase 1 WebSocket background emitters --- + + def _emit_service_status_loop(): + while True: + socketio.sleep(10) + try: + socketio.emit('status:update', _build_status_payload()) + except Exception: + pass + + def _emit_watchlist_count_loop(): + while True: + socketio.sleep(30) + try: + socketio.emit('watchlist:count', _build_watchlist_count_payload()) + except Exception: + pass + + def _emit_download_status_loop(): + while True: + socketio.sleep(2) + try: + with tasks_lock: + for bid, batch in download_batches.items(): + try: + socketio.emit('downloads:batch_update', { + 'batch_id': bid, + 'data': _build_batch_status_data(bid, batch), + }, room=f'batch:{bid}') + except Exception: + pass + except Exception: + pass + + # --- Phase 2 WebSocket background emitters --- + + def _emit_system_stats_loop(): + while True: + socketio.sleep(10) + try: + socketio.emit('dashboard:stats', _build_system_stats()) + except Exception: + pass + + def _emit_activity_feed_loop(): + while True: + socketio.sleep(5) + try: + socketio.emit('dashboard:activity', _build_activity_feed_payload()) + except Exception: + pass + + def _emit_db_stats_loop(): + while True: + socketio.sleep(30) + try: + socketio.emit('dashboard:db_stats', _build_db_stats()) + except Exception: + pass + + def _emit_wishlist_count_ws_loop(): + while True: + socketio.sleep(30) + try: + socketio.emit('dashboard:wishlist_count', _build_wishlist_count_payload()) + except Exception: + pass + + # Note: Toasts emit instantly from add_activity_item() — no timer needed + + # --- Phase 3 WebSocket background emitter --- + + def _emit_enrichment_status_loop(): + while True: + socketio.sleep(2) + for name in ENRICHMENT_WORKERS: + try: + status = _build_enrichment_status(name) + if status: + socketio.emit(f'enrichment:{name}', status) + except Exception: + pass + + # --- Phase 4 WebSocket background emitter --- + + def _emit_tool_progress_loop(): + while True: + socketio.sleep(1) + for name in TOOL_NAMES: + try: + status = _build_tool_status(name) + if status: + socketio.emit(f'tool:{name}', status) + except Exception: + pass + + # --- Phase 5 WebSocket background emitters --- + + def _emit_sync_progress_loop(): + while True: + socketio.sleep(1) + try: + with sync_lock: + for pid, state in list(sync_states.items()): + try: + socketio.emit('sync:progress', { + 'playlist_id': pid, **state + }, room=f'sync:{pid}') + except Exception: + pass + except Exception: + pass + + def _emit_discovery_progress_loop(): + while True: + socketio.sleep(1) + for platform in ['tidal', 'youtube', 'beatport', 'listenbrainz']: + try: + states_dict = discovery_states.get(platform, {}) + for pid, state in list(states_dict.items()): + try: + phase = state.get('phase', '') + if phase in ('', 'idle'): + continue + payload = { + 'platform': platform, + 'id': pid, + 'phase': state.get('phase'), + 'status': state.get('status', 'unknown'), + 'progress': state.get('discovery_progress', 0), + 'discovery_progress': state.get('discovery_progress', {}), + 'spotify_matches': state.get('spotify_matches', 0), + 'spotify_total': state.get('spotify_total', 0), + 'results': state.get('discovery_results', state.get('results', [])), + 'complete': state.get('phase') == 'discovered', + } + socketio.emit('discovery:progress', payload, room=f'discovery:{pid}') + except Exception: + pass + except Exception: + pass + + def _emit_scan_status_loop(): + while True: + socketio.sleep(2) + try: + socketio.emit('scan:watchlist', {"success": True, **watchlist_scan_state}) + except Exception: + pass + try: + socketio.emit('scan:media', {"success": True, "status": dict(media_scan_state)}) + except Exception: + pass + try: + socketio.emit('wishlist:stats', dict(wishlist_stats_state)) + except Exception: + pass + + # --- Socket.IO event handlers --- + + @socketio.on('connect') + def handle_connect(): + pass + + @socketio.on('disconnect') + def handle_disconnect(): + pass + + @socketio.on('downloads:subscribe') + def handle_download_subscribe(data): + batch_ids = data.get('batch_ids', []) + for bid in batch_ids: + join_room(f'batch:{bid}') + + @socketio.on('downloads:unsubscribe') + def handle_download_unsubscribe(data): + batch_ids = data.get('batch_ids', []) + for bid in batch_ids: + leave_room(f'batch:{bid}') + + # Phase 5 subscribe/unsubscribe handlers + @socketio.on('sync:subscribe') + def handle_sync_subscribe(data): + for pid in data.get('playlist_ids', []): + join_room(f'sync:{pid}') + + @socketio.on('sync:unsubscribe') + def handle_sync_unsubscribe(data): + for pid in data.get('playlist_ids', []): + leave_room(f'sync:{pid}') + + @socketio.on('discovery:subscribe') + def handle_discovery_subscribe(data): + for pid in data.get('ids', []): + join_room(f'discovery:{pid}') + + @socketio.on('discovery:unsubscribe') + def handle_discovery_unsubscribe(data): + for pid in data.get('ids', []): + leave_room(f'discovery:{pid}') + + # Start emitters (Phase 1 + Phase 2 + Phase 3 + Phase 4 + Phase 5) + socketio.start_background_task(_emit_service_status_loop) + socketio.start_background_task(_emit_watchlist_count_loop) + socketio.start_background_task(_emit_download_status_loop) + socketio.start_background_task(_emit_system_stats_loop) + socketio.start_background_task(_emit_activity_feed_loop) + socketio.start_background_task(_emit_db_stats_loop) + socketio.start_background_task(_emit_wishlist_count_ws_loop) + socketio.start_background_task(_emit_enrichment_status_loop) + socketio.start_background_task(_emit_tool_progress_loop) + socketio.start_background_task(_emit_sync_progress_loop) + socketio.start_background_task(_emit_discovery_progress_loop) + socketio.start_background_task(_emit_scan_status_loop) + + return app, socketio + + +@pytest.fixture +def flask_client(test_app): + """Plain Flask test client (HTTP only).""" + app, _socketio = test_app + return app.test_client() + + +@pytest.fixture +def socketio_client(test_app): + """Socket.IO test client (connects via WebSocket).""" + app, socketio = test_app + return socketio.test_client(app) + + +@pytest.fixture +def shared_state(): + """Provide direct references to the mutable state dicts AND helper functions. + + Using this fixture avoids import-path mismatches between pytest's + auto-discovered conftest module and explicit ``from tests.conftest import …``.""" + return { + # Phase 1 state + 'status_cache': _status_cache, + 'watchlist_state': watchlist_state, + 'download_batches': download_batches, + 'tasks_lock': tasks_lock, + 'build_status_payload': _build_status_payload, + 'build_watchlist_count_payload': _build_watchlist_count_payload, + 'build_batch_status_data': _build_batch_status_data, + # Phase 2 state + 'system_stats': system_stats, + 'activity_feed': activity_feed, + 'activity_feed_lock': activity_feed_lock, + 'db_stats': db_stats, + 'wishlist_count': wishlist_count, + 'build_system_stats': _build_system_stats, + 'build_activity_feed_payload': _build_activity_feed_payload, + 'build_db_stats': _build_db_stats, + 'build_wishlist_count_payload_ws': _build_wishlist_count_payload, + 'add_activity_item': add_activity_item, + # Phase 3 state + 'enrichment_status': enrichment_status, + 'build_enrichment_status': _build_enrichment_status, + 'enrichment_workers': ENRICHMENT_WORKERS, + 'enrichment_endpoints': ENRICHMENT_ENDPOINTS, + # Phase 4 state + 'stream_state': stream_state, + 'quality_scanner_state': quality_scanner_state, + 'duplicate_cleaner_state': duplicate_cleaner_state, + 'retag_state': retag_state, + 'db_update_state': db_update_state, + 'metadata_update_state': metadata_update_state, + 'logs_activities': logs_activities, + 'build_tool_status': _build_tool_status, + 'build_stream_status': _build_stream_status, + 'build_quality_scanner_status': _build_quality_scanner_status, + 'build_duplicate_cleaner_status': _build_duplicate_cleaner_status, + 'build_retag_status': _build_retag_status, + 'build_db_update_status': _build_db_update_status, + 'build_metadata_status': _build_metadata_status, + 'build_logs': _build_logs, + 'tool_names': TOOL_NAMES, + 'tool_endpoints': TOOL_ENDPOINTS, + # Phase 5 state + 'sync_states': sync_states, + 'sync_lock': sync_lock, + 'discovery_states': discovery_states, + 'watchlist_scan_state': watchlist_scan_state, + 'media_scan_state': media_scan_state, + 'build_sync_status': _build_sync_status, + 'build_discovery_status': _build_discovery_status, + 'build_watchlist_scan_status': _build_watchlist_scan_status, + 'build_media_scan_status': _build_media_scan_status, + 'wishlist_stats_state': wishlist_stats_state, + 'build_wishlist_stats': _build_wishlist_stats, + 'sync_endpoints': SYNC_ENDPOINTS, + 'discovery_endpoints': DISCOVERY_ENDPOINTS, + 'scan_endpoints': SCAN_ENDPOINTS, + } + + +@pytest.fixture(autouse=True) +def reset_state(): + """Reset all mutable state between tests.""" + # Reset to defaults + _status_cache.clear() + _status_cache.update(copy.deepcopy(_DEFAULT_STATUS_CACHE)) + watchlist_state.clear() + watchlist_state.update(copy.deepcopy(_DEFAULT_WATCHLIST_STATE)) + download_batches.clear() + # Phase 2 resets + system_stats.clear() + system_stats.update(copy.deepcopy(_DEFAULT_SYSTEM_STATS)) + with activity_feed_lock: + activity_feed.clear() + db_stats.clear() + db_stats.update(copy.deepcopy(_DEFAULT_DB_STATS)) + wishlist_count.clear() + wishlist_count.update(copy.deepcopy(_DEFAULT_WISHLIST_COUNT)) + # Phase 3 resets + enrichment_status.clear() + enrichment_status.update(copy.deepcopy(_DEFAULT_ENRICHMENT_STATUS)) + # Phase 4 resets + stream_state.clear() + stream_state.update(copy.deepcopy(_DEFAULT_STREAM_STATE)) + quality_scanner_state.clear() + quality_scanner_state.update(copy.deepcopy(_DEFAULT_QUALITY_SCANNER_STATE)) + duplicate_cleaner_state.clear() + duplicate_cleaner_state.update(copy.deepcopy(_DEFAULT_DUPLICATE_CLEANER_STATE)) + retag_state.clear() + retag_state.update(copy.deepcopy(_DEFAULT_RETAG_STATE)) + db_update_state.clear() + db_update_state.update(copy.deepcopy(_DEFAULT_DB_UPDATE_STATE)) + metadata_update_state.clear() + metadata_update_state.update(copy.deepcopy(_DEFAULT_METADATA_STATE)) + logs_activities.clear() + logs_activities.extend(copy.deepcopy(_DEFAULT_LOGS_ACTIVITIES)) + # Phase 5 resets + sync_states.clear() + sync_states.update(copy.deepcopy(_DEFAULT_SYNC_STATES)) + discovery_states.clear() + discovery_states.update(copy.deepcopy(_DEFAULT_DISCOVERY_STATES)) + watchlist_scan_state.clear() + watchlist_scan_state.update(copy.deepcopy(_DEFAULT_WATCHLIST_SCAN_STATE)) + media_scan_state.clear() + media_scan_state.update(copy.deepcopy(_DEFAULT_MEDIA_SCAN_STATE)) + wishlist_stats_state.clear() + wishlist_stats_state.update(copy.deepcopy(_DEFAULT_WISHLIST_STATS)) + yield + # Cleanup after test + _status_cache.clear() + _status_cache.update(copy.deepcopy(_DEFAULT_STATUS_CACHE)) + watchlist_state.clear() + watchlist_state.update(copy.deepcopy(_DEFAULT_WATCHLIST_STATE)) + download_batches.clear() + system_stats.clear() + system_stats.update(copy.deepcopy(_DEFAULT_SYSTEM_STATS)) + with activity_feed_lock: + activity_feed.clear() + db_stats.clear() + db_stats.update(copy.deepcopy(_DEFAULT_DB_STATS)) + wishlist_count.clear() + wishlist_count.update(copy.deepcopy(_DEFAULT_WISHLIST_COUNT)) + enrichment_status.clear() + enrichment_status.update(copy.deepcopy(_DEFAULT_ENRICHMENT_STATUS)) + stream_state.clear() + stream_state.update(copy.deepcopy(_DEFAULT_STREAM_STATE)) + quality_scanner_state.clear() + quality_scanner_state.update(copy.deepcopy(_DEFAULT_QUALITY_SCANNER_STATE)) + duplicate_cleaner_state.clear() + duplicate_cleaner_state.update(copy.deepcopy(_DEFAULT_DUPLICATE_CLEANER_STATE)) + retag_state.clear() + retag_state.update(copy.deepcopy(_DEFAULT_RETAG_STATE)) + db_update_state.clear() + db_update_state.update(copy.deepcopy(_DEFAULT_DB_UPDATE_STATE)) + metadata_update_state.clear() + metadata_update_state.update(copy.deepcopy(_DEFAULT_METADATA_STATE)) + logs_activities.clear() + logs_activities.extend(copy.deepcopy(_DEFAULT_LOGS_ACTIVITIES)) + # Phase 5 resets + sync_states.clear() + sync_states.update(copy.deepcopy(_DEFAULT_SYNC_STATES)) + discovery_states.clear() + discovery_states.update(copy.deepcopy(_DEFAULT_DISCOVERY_STATES)) + watchlist_scan_state.clear() + watchlist_scan_state.update(copy.deepcopy(_DEFAULT_WATCHLIST_SCAN_STATE)) + media_scan_state.clear() + media_scan_state.update(copy.deepcopy(_DEFAULT_MEDIA_SCAN_STATE)) + wishlist_stats_state.clear() + wishlist_stats_state.update(copy.deepcopy(_DEFAULT_WISHLIST_STATS)) diff --git a/tests/test_phase1_websocket.py b/tests/test_phase1_websocket.py new file mode 100644 index 00000000..e25eed5d --- /dev/null +++ b/tests/test_phase1_websocket.py @@ -0,0 +1,397 @@ +"""Phase 1 WebSocket migration tests. + +Verifies that: + - WebSocket infrastructure connects and communicates + - HTTP endpoints still work (backward compat / fallback) + - Socket events deliver identical data to HTTP responses + - Download batch room subscriptions work correctly + +IMPORTANT: Do NOT use ``from tests.conftest import …`` — pytest's auto-discovered +conftest is a different module instance. Use the ``shared_state`` fixture instead. +""" + +import pytest + + +# ========================================================================= +# Group A — Infrastructure +# ========================================================================= + +class TestInfrastructure: + """Socket.IO connects, and HTTP endpoints remain functional.""" + + def test_socketio_connects(self, socketio_client): + """Client can establish a WebSocket connection.""" + assert socketio_client.is_connected() + + def test_socketio_disconnect_and_reconnect(self, test_app): + """Client can disconnect and reconnect cleanly.""" + app, socketio = test_app + client = socketio.test_client(app) + assert client.is_connected() + client.disconnect() + assert not client.is_connected() + client.connect() + assert client.is_connected() + client.disconnect() + + def test_http_status_still_works(self, flask_client): + """GET /status returns 200 with expected keys.""" + resp = flask_client.get('/status') + assert resp.status_code == 200 + data = resp.get_json() + assert 'spotify' in data + assert 'media_server' in data + assert 'soulseek' in data + assert 'active_media_server' in data + + def test_http_watchlist_count_still_works(self, flask_client): + """GET /api/watchlist/count returns 200 with expected keys.""" + resp = flask_client.get('/api/watchlist/count') + assert resp.status_code == 200 + data = resp.get_json() + assert data['success'] is True + assert 'count' in data + assert 'next_run_in_seconds' in data + + def test_http_download_batch_still_works(self, flask_client): + """GET /api/download_status/batch returns 200 with expected structure.""" + resp = flask_client.get('/api/download_status/batch') + assert resp.status_code == 200 + data = resp.get_json() + assert 'batches' in data + assert 'metadata' in data + + +# ========================================================================= +# Group B — Service Status Parity +# ========================================================================= + +class TestServiceStatus: + """status:update socket events match GET /status HTTP responses.""" + + def test_status_update_received(self, test_app, shared_state): + """Client receives a status:update event.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_status_payload'] + socketio.emit('status:update', build()) + received = client.get_received() + status_events = [e for e in received if e['name'] == 'status:update'] + assert len(status_events) >= 1 + + def test_status_update_shape(self, test_app, shared_state): + """status:update event data has the expected keys.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_status_payload'] + socketio.emit('status:update', build()) + received = client.get_received() + status_events = [e for e in received if e['name'] == 'status:update'] + assert len(status_events) >= 1 + data = status_events[0]['args'][0] + assert 'spotify' in data + assert 'media_server' in data + assert 'soulseek' in data + assert 'active_media_server' in data + + def test_status_matches_http(self, test_app, shared_state): + """Socket event data matches HTTP endpoint response exactly.""" + app, socketio = test_app + flask_client = app.test_client() + ws_client = socketio.test_client(app) + build = shared_state['build_status_payload'] + + http_data = flask_client.get('/status').get_json() + + socketio.emit('status:update', build()) + received = ws_client.get_received() + status_events = [e for e in received if e['name'] == 'status:update'] + assert len(status_events) >= 1 + ws_data = status_events[0]['args'][0] + + assert ws_data['spotify'] == http_data['spotify'] + assert ws_data['media_server'] == http_data['media_server'] + assert ws_data['soulseek'] == http_data['soulseek'] + assert ws_data['active_media_server'] == http_data['active_media_server'] + + def test_status_reflects_cache_changes(self, test_app, shared_state): + """When _status_cache changes, the next emit reflects it.""" + app, socketio = test_app + client = socketio.test_client(app) + status_cache = shared_state['status_cache'] + build = shared_state['build_status_payload'] + + # Mutate cache + status_cache['spotify']['source'] = 'itunes' + + socketio.emit('status:update', build()) + received = client.get_received() + status_events = [e for e in received if e['name'] == 'status:update'] + data = status_events[-1]['args'][0] + assert data['spotify']['source'] == 'itunes' + + +# ========================================================================= +# Group C — Watchlist Count Parity +# ========================================================================= + +class TestWatchlistCount: + """watchlist:count socket events match GET /api/watchlist/count.""" + + def test_watchlist_count_received(self, test_app, shared_state): + """Client receives a watchlist:count event.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_watchlist_count_payload'] + socketio.emit('watchlist:count', build()) + received = client.get_received() + wl_events = [e for e in received if e['name'] == 'watchlist:count'] + assert len(wl_events) >= 1 + + def test_watchlist_count_shape(self, test_app, shared_state): + """watchlist:count event data has expected keys.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_watchlist_count_payload'] + socketio.emit('watchlist:count', build()) + received = client.get_received() + wl_events = [e for e in received if e['name'] == 'watchlist:count'] + data = wl_events[0]['args'][0] + assert data['success'] is True + assert isinstance(data['count'], int) + assert isinstance(data['next_run_in_seconds'], int) + + def test_watchlist_matches_http(self, test_app, shared_state): + """Socket event data matches HTTP endpoint response.""" + app, socketio = test_app + flask_client = app.test_client() + ws_client = socketio.test_client(app) + build = shared_state['build_watchlist_count_payload'] + + http_data = flask_client.get('/api/watchlist/count').get_json() + + socketio.emit('watchlist:count', build()) + received = ws_client.get_received() + wl_events = [e for e in received if e['name'] == 'watchlist:count'] + ws_data = wl_events[0]['args'][0] + + assert ws_data['success'] == http_data['success'] + assert ws_data['count'] == http_data['count'] + assert ws_data['next_run_in_seconds'] == http_data['next_run_in_seconds'] + + def test_watchlist_reflects_count_change(self, test_app, shared_state): + """When watchlist count changes, the emit reflects it.""" + app, socketio = test_app + client = socketio.test_client(app) + wl_state = shared_state['watchlist_state'] + build = shared_state['build_watchlist_count_payload'] + + wl_state['count'] = 42 + + socketio.emit('watchlist:count', build()) + received = client.get_received() + wl_events = [e for e in received if e['name'] == 'watchlist:count'] + data = wl_events[-1]['args'][0] + assert data['count'] == 42 + + +# ========================================================================= +# Group D — Download Batch Rooms +# ========================================================================= + +class TestDownloadBatch: + """Download batch updates are delivered via room subscriptions.""" + + def _add_batch(self, shared_state, batch_id, **kwargs): + """Helper to add a fake download batch.""" + defaults = { + 'phase': 'downloading', + 'tasks': [ + {'task_id': 't1', 'status': 'downloading', 'progress': 50}, + {'task_id': 't2', 'status': 'searching', 'progress': 0}, + ], + 'active_count': 2, + 'max_concurrent': 3, + 'playlist_id': 'spotify_test', + 'playlist_name': 'Test Playlist', + } + defaults.update(kwargs) + batches = shared_state['download_batches'] + lock = shared_state['tasks_lock'] + with lock: + batches[batch_id] = defaults + + def test_download_subscribe(self, test_app): + """Client can subscribe to a batch room.""" + app, socketio = test_app + client = socketio.test_client(app) + client.emit('downloads:subscribe', {'batch_ids': ['batch_abc']}) + + def test_download_receives_updates(self, test_app, shared_state): + """After subscribing, client receives batch_update for that batch.""" + app, socketio = test_app + client = socketio.test_client(app) + build_batch = shared_state['build_batch_status_data'] + + self._add_batch(shared_state, 'batch_123') + client.emit('downloads:subscribe', {'batch_ids': ['batch_123']}) + client.get_received() # clear + + batches = shared_state['download_batches'] + lock = shared_state['tasks_lock'] + with lock: + batch = batches['batch_123'] + socketio.emit('downloads:batch_update', { + 'batch_id': 'batch_123', + 'data': build_batch('batch_123', batch), + }, room='batch:batch_123') + + received = client.get_received() + dl_events = [e for e in received if e['name'] == 'downloads:batch_update'] + assert len(dl_events) >= 1 + payload = dl_events[0]['args'][0] + assert payload['batch_id'] == 'batch_123' + assert payload['data']['phase'] == 'downloading' + assert len(payload['data']['tasks']) == 2 + + def test_download_only_subscribed_batches(self, test_app, shared_state): + """Client only receives updates for subscribed batches, not others.""" + app, socketio = test_app + client = socketio.test_client(app) + build_batch = shared_state['build_batch_status_data'] + + self._add_batch(shared_state, 'batch_A') + self._add_batch(shared_state, 'batch_B') + + client.emit('downloads:subscribe', {'batch_ids': ['batch_A']}) + client.get_received() # clear + + batches = shared_state['download_batches'] + lock = shared_state['tasks_lock'] + with lock: + for bid in ['batch_A', 'batch_B']: + socketio.emit('downloads:batch_update', { + 'batch_id': bid, + 'data': build_batch(bid, batches[bid]), + }, room=f'batch:{bid}') + + received = client.get_received() + dl_events = [e for e in received if e['name'] == 'downloads:batch_update'] + batch_ids_received = {e['args'][0]['batch_id'] for e in dl_events} + + assert 'batch_A' in batch_ids_received + assert 'batch_B' not in batch_ids_received + + def test_download_unsubscribe_stops_updates(self, test_app, shared_state): + """After unsubscribing, client stops receiving updates for that batch.""" + app, socketio = test_app + client = socketio.test_client(app) + build_batch = shared_state['build_batch_status_data'] + + self._add_batch(shared_state, 'batch_X') + client.emit('downloads:subscribe', {'batch_ids': ['batch_X']}) + client.get_received() # clear + + client.emit('downloads:unsubscribe', {'batch_ids': ['batch_X']}) + client.get_received() # clear + + batches = shared_state['download_batches'] + lock = shared_state['tasks_lock'] + with lock: + socketio.emit('downloads:batch_update', { + 'batch_id': 'batch_X', + 'data': build_batch('batch_X', batches['batch_X']), + }, room='batch:batch_X') + + received = client.get_received() + dl_events = [e for e in received if e['name'] == 'downloads:batch_update'] + assert len(dl_events) == 0 + + def test_download_batch_shape(self, test_app, shared_state): + """Batch update data has the expected structure.""" + app, socketio = test_app + client = socketio.test_client(app) + build_batch = shared_state['build_batch_status_data'] + + self._add_batch(shared_state, 'batch_shape') + client.emit('downloads:subscribe', {'batch_ids': ['batch_shape']}) + client.get_received() + + batches = shared_state['download_batches'] + lock = shared_state['tasks_lock'] + with lock: + socketio.emit('downloads:batch_update', { + 'batch_id': 'batch_shape', + 'data': build_batch('batch_shape', batches['batch_shape']), + }, room='batch:batch_shape') + + received = client.get_received() + dl_events = [e for e in received if e['name'] == 'downloads:batch_update'] + payload = dl_events[0]['args'][0] + + assert 'batch_id' in payload + assert 'data' in payload + data = payload['data'] + assert 'phase' in data + assert 'tasks' in data + assert 'active_count' in data + assert 'max_concurrent' in data + + def test_download_http_batch_still_works(self, test_app, shared_state): + """HTTP batch endpoint works alongside WebSocket rooms.""" + app, socketio = test_app + flask_client = app.test_client() + + self._add_batch(shared_state, 'batch_http') + + resp = flask_client.get('/api/download_status/batch?batch_ids=batch_http') + assert resp.status_code == 200 + data = resp.get_json() + assert 'batch_http' in data['batches'] + assert data['batches']['batch_http']['phase'] == 'downloading' + + +# ========================================================================= +# Group E — Fallback Behavior +# ========================================================================= + +class TestFallback: + """HTTP endpoints work when no WebSocket is connected.""" + + def test_http_works_without_websocket(self, flask_client): + """All three HTTP endpoints work without any WebSocket connection.""" + # Status + resp = flask_client.get('/status') + assert resp.status_code == 200 + data = resp.get_json() + assert data['spotify']['connected'] is True + + # Watchlist + resp = flask_client.get('/api/watchlist/count') + assert resp.status_code == 200 + data = resp.get_json() + assert data['count'] == 7 + + # Download batch (empty — no active batches) + resp = flask_client.get('/api/download_status/batch') + assert resp.status_code == 200 + data = resp.get_json() + assert data['batches'] == {} + + def test_multiple_clients_get_updates(self, test_app, shared_state): + """Multiple WebSocket clients each receive broadcast events.""" + app, socketio = test_app + client1 = socketio.test_client(app) + client2 = socketio.test_client(app) + build = shared_state['build_status_payload'] + + socketio.emit('status:update', build()) + + for client in [client1, client2]: + received = client.get_received() + status_events = [e for e in received if e['name'] == 'status:update'] + assert len(status_events) >= 1 + + client1.disconnect() + client2.disconnect() diff --git a/tests/test_phase2_dashboard.py b/tests/test_phase2_dashboard.py new file mode 100644 index 00000000..81751246 --- /dev/null +++ b/tests/test_phase2_dashboard.py @@ -0,0 +1,388 @@ +"""Phase 2 WebSocket migration tests — Dashboard pollers. + +Verifies that: + - System stats, activity feed, toasts, DB stats, and wishlist count + are delivered identically via WebSocket events and HTTP endpoints + - Instant toast push from add_activity_item() works correctly + - HTTP endpoints still work as fallback + +IMPORTANT: Do NOT use ``from tests.conftest import …`` — pytest's auto-discovered +conftest is a different module instance. Use the ``shared_state`` fixture instead. +""" + +import pytest +import time + + +# ========================================================================= +# Group A — System Stats +# ========================================================================= + +class TestSystemStats: + """dashboard:stats socket events match GET /api/system/stats.""" + + def test_stats_event_received(self, test_app, shared_state): + """Client receives a dashboard:stats event.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_system_stats'] + socketio.emit('dashboard:stats', build()) + received = client.get_received() + stats_events = [e for e in received if e['name'] == 'dashboard:stats'] + assert len(stats_events) >= 1 + + def test_stats_shape(self, test_app, shared_state): + """dashboard:stats event data has expected keys.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_system_stats'] + socketio.emit('dashboard:stats', build()) + received = client.get_received() + stats_events = [e for e in received if e['name'] == 'dashboard:stats'] + assert len(stats_events) >= 1 + data = stats_events[0]['args'][0] + assert 'active_downloads' in data + assert 'finished_downloads' in data + assert 'download_speed' in data + assert 'active_syncs' in data + assert 'uptime' in data + assert 'memory_usage' in data + + def test_stats_matches_http(self, test_app, shared_state): + """Socket event data matches HTTP endpoint response.""" + app, socketio = test_app + flask_client = app.test_client() + ws_client = socketio.test_client(app) + build = shared_state['build_system_stats'] + + http_data = flask_client.get('/api/system/stats').get_json() + + socketio.emit('dashboard:stats', build()) + received = ws_client.get_received() + stats_events = [e for e in received if e['name'] == 'dashboard:stats'] + assert len(stats_events) >= 1 + ws_data = stats_events[0]['args'][0] + + assert ws_data['active_downloads'] == http_data['active_downloads'] + assert ws_data['finished_downloads'] == http_data['finished_downloads'] + assert ws_data['download_speed'] == http_data['download_speed'] + assert ws_data['active_syncs'] == http_data['active_syncs'] + assert ws_data['uptime'] == http_data['uptime'] + assert ws_data['memory_usage'] == http_data['memory_usage'] + + def test_http_stats_still_works(self, flask_client): + """GET /api/system/stats returns 200 with expected keys.""" + resp = flask_client.get('/api/system/stats') + assert resp.status_code == 200 + data = resp.get_json() + assert 'active_downloads' in data + assert 'finished_downloads' in data + assert 'download_speed' in data + + +# ========================================================================= +# Group B — Activity Feed +# ========================================================================= + +class TestActivityFeed: + """dashboard:activity socket events match GET /api/activity/feed.""" + + def test_activity_event_received(self, test_app, shared_state): + """Client receives a dashboard:activity event.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_activity_feed_payload'] + socketio.emit('dashboard:activity', build()) + received = client.get_received() + events = [e for e in received if e['name'] == 'dashboard:activity'] + assert len(events) >= 1 + + def test_activity_shape(self, test_app, shared_state): + """dashboard:activity event data has activities array.""" + app, socketio = test_app + client = socketio.test_client(app) + # Add some activities first + add_item = shared_state['add_activity_item'] + add_item('🎵', 'Download Complete', 'Artist - Song', show_toast=False) + + build = shared_state['build_activity_feed_payload'] + socketio.emit('dashboard:activity', build()) + received = client.get_received() + # Filter out any toast events, get only activity events + events = [e for e in received if e['name'] == 'dashboard:activity'] + assert len(events) >= 1 + data = events[0]['args'][0] + assert 'activities' in data + assert isinstance(data['activities'], list) + + def test_activity_matches_http(self, test_app, shared_state): + """Socket event data matches HTTP endpoint response.""" + app, socketio = test_app + flask_client = app.test_client() + ws_client = socketio.test_client(app) + + # Add an activity + add_item = shared_state['add_activity_item'] + add_item('🎵', 'Test Activity', 'Test subtitle', show_toast=False) + + http_data = flask_client.get('/api/activity/feed').get_json() + build = shared_state['build_activity_feed_payload'] + socketio.emit('dashboard:activity', build()) + received = ws_client.get_received() + events = [e for e in received if e['name'] == 'dashboard:activity'] + ws_data = events[0]['args'][0] + + assert len(ws_data['activities']) == len(http_data['activities']) + if ws_data['activities']: + assert ws_data['activities'][0]['title'] == http_data['activities'][0]['title'] + + def test_http_activity_still_works(self, flask_client): + """GET /api/activity/feed returns 200 with expected structure.""" + resp = flask_client.get('/api/activity/feed') + assert resp.status_code == 200 + data = resp.get_json() + assert 'activities' in data + + +# ========================================================================= +# Group C — Toasts (instant push) +# ========================================================================= + +class TestToasts: + """dashboard:toast events are pushed instantly from add_activity_item().""" + + def test_toast_emitted_on_add(self, test_app, shared_state): + """Calling add_activity_item() with show_toast=True emits dashboard:toast.""" + app, socketio = test_app + client = socketio.test_client(app) + client.get_received() # clear + + add_item = shared_state['add_activity_item'] + add_item('✅', 'Download Complete', 'Artist - Song', show_toast=True) + + received = client.get_received() + toast_events = [e for e in received if e['name'] == 'dashboard:toast'] + assert len(toast_events) >= 1 + data = toast_events[0]['args'][0] + assert data['title'] == 'Download Complete' + assert data['subtitle'] == 'Artist - Song' + + def test_toast_not_emitted_when_disabled(self, test_app, shared_state): + """add_activity_item() with show_toast=False does NOT emit dashboard:toast.""" + app, socketio = test_app + client = socketio.test_client(app) + client.get_received() # clear + + add_item = shared_state['add_activity_item'] + add_item('📊', 'Background Task', 'Silent update', show_toast=False) + + received = client.get_received() + toast_events = [e for e in received if e['name'] == 'dashboard:toast'] + assert len(toast_events) == 0 + + def test_toast_shape(self, test_app, shared_state): + """Toast data has expected keys.""" + app, socketio = test_app + client = socketio.test_client(app) + client.get_received() # clear + + add_item = shared_state['add_activity_item'] + add_item('✅', 'Test Title', 'Test Subtitle', 'Now', show_toast=True) + + received = client.get_received() + toast_events = [e for e in received if e['name'] == 'dashboard:toast'] + assert len(toast_events) >= 1 + data = toast_events[0]['args'][0] + assert 'icon' in data + assert 'title' in data + assert 'subtitle' in data + assert 'time' in data + assert 'timestamp' in data + assert 'show_toast' in data + assert data['show_toast'] is True + + def test_http_toasts_still_works(self, flask_client, shared_state): + """GET /api/activity/toasts returns 200 with expected structure.""" + # Add a toast-worthy activity first + add_item = shared_state['add_activity_item'] + add_item('✅', 'Test', 'Sub', show_toast=True) + + resp = flask_client.get('/api/activity/toasts') + assert resp.status_code == 200 + data = resp.get_json() + assert 'toasts' in data + + +# ========================================================================= +# Group D — DB Stats +# ========================================================================= + +class TestDbStats: + """dashboard:db_stats socket events match GET /api/database/stats.""" + + def test_db_stats_event_received(self, test_app, shared_state): + """Client receives a dashboard:db_stats event.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_db_stats'] + socketio.emit('dashboard:db_stats', build()) + received = client.get_received() + events = [e for e in received if e['name'] == 'dashboard:db_stats'] + assert len(events) >= 1 + + def test_db_stats_shape(self, test_app, shared_state): + """dashboard:db_stats event data has expected keys.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_db_stats'] + socketio.emit('dashboard:db_stats', build()) + received = client.get_received() + events = [e for e in received if e['name'] == 'dashboard:db_stats'] + assert len(events) >= 1 + data = events[0]['args'][0] + assert 'artists' in data + assert 'albums' in data + assert 'tracks' in data + assert 'database_size_mb' in data + assert 'server_source' in data + + def test_db_stats_matches_http(self, test_app, shared_state): + """Socket event data matches HTTP endpoint response.""" + app, socketio = test_app + flask_client = app.test_client() + ws_client = socketio.test_client(app) + build = shared_state['build_db_stats'] + + http_data = flask_client.get('/api/database/stats').get_json() + + socketio.emit('dashboard:db_stats', build()) + received = ws_client.get_received() + events = [e for e in received if e['name'] == 'dashboard:db_stats'] + ws_data = events[0]['args'][0] + + assert ws_data['artists'] == http_data['artists'] + assert ws_data['albums'] == http_data['albums'] + assert ws_data['tracks'] == http_data['tracks'] + assert ws_data['database_size_mb'] == http_data['database_size_mb'] + assert ws_data['server_source'] == http_data['server_source'] + + def test_http_db_stats_still_works(self, flask_client): + """GET /api/database/stats returns 200 with expected keys.""" + resp = flask_client.get('/api/database/stats') + assert resp.status_code == 200 + data = resp.get_json() + assert 'artists' in data + assert 'albums' in data + assert 'tracks' in data + + +# ========================================================================= +# Group E — Wishlist Count +# ========================================================================= + +class TestWishlistCount: + """dashboard:wishlist_count socket events match GET /api/wishlist/count.""" + + def test_wishlist_count_received(self, test_app, shared_state): + """Client receives a dashboard:wishlist_count event.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_wishlist_count_payload_ws'] + socketio.emit('dashboard:wishlist_count', build()) + received = client.get_received() + events = [e for e in received if e['name'] == 'dashboard:wishlist_count'] + assert len(events) >= 1 + + def test_wishlist_count_shape(self, test_app, shared_state): + """dashboard:wishlist_count event data has count key.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_wishlist_count_payload_ws'] + socketio.emit('dashboard:wishlist_count', build()) + received = client.get_received() + events = [e for e in received if e['name'] == 'dashboard:wishlist_count'] + assert len(events) >= 1 + data = events[0]['args'][0] + assert 'count' in data + assert isinstance(data['count'], int) + + def test_wishlist_count_matches_http(self, test_app, shared_state): + """Socket event data matches HTTP endpoint response.""" + app, socketio = test_app + flask_client = app.test_client() + ws_client = socketio.test_client(app) + build = shared_state['build_wishlist_count_payload_ws'] + + http_data = flask_client.get('/api/wishlist/count').get_json() + + socketio.emit('dashboard:wishlist_count', build()) + received = ws_client.get_received() + events = [e for e in received if e['name'] == 'dashboard:wishlist_count'] + ws_data = events[0]['args'][0] + + assert ws_data['count'] == http_data['count'] + + def test_http_wishlist_count_still_works(self, flask_client): + """GET /api/wishlist/count returns 200 with count.""" + resp = flask_client.get('/api/wishlist/count') + assert resp.status_code == 200 + data = resp.get_json() + assert 'count' in data + assert isinstance(data['count'], int) + + +# ========================================================================= +# Group F — Backward Compatibility +# ========================================================================= + +class TestBackwardCompat: + """HTTP endpoints work when no WebSocket is connected.""" + + def test_all_http_endpoints_work_without_socket(self, flask_client): + """All 5 Phase 2 HTTP endpoints work without any WebSocket connection.""" + # System stats + resp = flask_client.get('/api/system/stats') + assert resp.status_code == 200 + data = resp.get_json() + assert data['active_downloads'] == 2 + + # Activity feed + resp = flask_client.get('/api/activity/feed') + assert resp.status_code == 200 + data = resp.get_json() + assert 'activities' in data + + # Toasts + resp = flask_client.get('/api/activity/toasts') + assert resp.status_code == 200 + data = resp.get_json() + assert 'toasts' in data + + # DB stats + resp = flask_client.get('/api/database/stats') + assert resp.status_code == 200 + data = resp.get_json() + assert data['artists'] == 350 + + # Wishlist count + resp = flask_client.get('/api/wishlist/count') + assert resp.status_code == 200 + data = resp.get_json() + assert data['count'] == 5 + + def test_multiple_clients_get_dashboard_updates(self, test_app, shared_state): + """Multiple WebSocket clients each receive dashboard broadcast events.""" + app, socketio = test_app + client1 = socketio.test_client(app) + client2 = socketio.test_client(app) + build = shared_state['build_system_stats'] + + socketio.emit('dashboard:stats', build()) + + for client in [client1, client2]: + received = client.get_received() + events = [e for e in received if e['name'] == 'dashboard:stats'] + assert len(events) >= 1 + + client1.disconnect() + client2.disconnect() diff --git a/tests/test_phase3_enrichment.py b/tests/test_phase3_enrichment.py new file mode 100644 index 00000000..321f47a7 --- /dev/null +++ b/tests/test_phase3_enrichment.py @@ -0,0 +1,229 @@ +"""Phase 3 WebSocket migration tests — Enrichment sidebar workers. + +Verifies that: + - All 7 enrichment worker statuses are delivered identically via + WebSocket events and HTTP endpoints + - Each worker's data shape is correct + - HTTP endpoints still work as fallback + +IMPORTANT: Do NOT use ``from tests.conftest import …`` — pytest's auto-discovered +conftest is a different module instance. Use the ``shared_state`` fixture instead. +""" + +import pytest + + +# All 7 enrichment workers +WORKERS = [ + 'musicbrainz', 'audiodb', 'deezer', + 'spotify-enrichment', 'itunes-enrichment', + 'hydrabase', 'repair', +] + +# Endpoint URLs keyed by worker name +ENDPOINTS = { + 'musicbrainz': '/api/musicbrainz/status', + 'audiodb': '/api/audiodb/status', + 'deezer': '/api/deezer/status', + 'spotify-enrichment': '/api/spotify-enrichment/status', + 'itunes-enrichment': '/api/itunes-enrichment/status', + 'hydrabase': '/api/hydrabase-worker/status', + 'repair': '/api/repair/status', +} + + +# ========================================================================= +# Group A — Event Delivery (parameterized) +# ========================================================================= + +class TestEnrichmentEventDelivery: + """enrichment: socket events are received by the client.""" + + @pytest.mark.parametrize('worker', WORKERS) + def test_enrichment_event_received(self, test_app, shared_state, worker): + """Client receives an enrichment: event.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_enrichment_status'] + + socketio.emit(f'enrichment:{worker}', build(worker)) + received = client.get_received() + events = [e for e in received if e['name'] == f'enrichment:{worker}'] + assert len(events) >= 1 + + +# ========================================================================= +# Group B — Data Shape (parameterized) +# ========================================================================= + +class TestEnrichmentDataShape: + """enrichment: event data has the expected keys.""" + + @pytest.mark.parametrize('worker', [ + 'musicbrainz', 'audiodb', 'deezer', + 'spotify-enrichment', 'itunes-enrichment', + ]) + def test_standard_enrichment_shape(self, test_app, shared_state, worker): + """Standard enrichment worker data has running, paused, idle, progress.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_enrichment_status'] + + socketio.emit(f'enrichment:{worker}', build(worker)) + received = client.get_received() + events = [e for e in received if e['name'] == f'enrichment:{worker}'] + assert len(events) >= 1 + data = events[0]['args'][0] + + assert 'running' in data + assert 'paused' in data + assert 'idle' in data + assert 'current_item' in data + assert 'progress' in data + assert isinstance(data['running'], bool) + assert isinstance(data['paused'], bool) + + def test_spotify_enrichment_has_authenticated(self, test_app, shared_state): + """Spotify enrichment includes the 'authenticated' field.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_enrichment_status'] + + socketio.emit('enrichment:spotify-enrichment', build('spotify-enrichment')) + received = client.get_received() + events = [e for e in received if e['name'] == 'enrichment:spotify-enrichment'] + data = events[0]['args'][0] + assert 'authenticated' in data + + def test_hydrabase_shape(self, test_app, shared_state): + """Hydrabase worker has running, paused, queue_size (no idle/progress).""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_enrichment_status'] + + socketio.emit('enrichment:hydrabase', build('hydrabase')) + received = client.get_received() + events = [e for e in received if e['name'] == 'enrichment:hydrabase'] + data = events[0]['args'][0] + + assert 'running' in data + assert 'paused' in data + assert 'queue_size' in data + assert 'idle' not in data # Hydrabase doesn't have idle + + def test_repair_shape(self, test_app, shared_state): + """Repair worker has progress.tracks with checked/repaired counters.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_enrichment_status'] + + socketio.emit('enrichment:repair', build('repair')) + received = client.get_received() + events = [e for e in received if e['name'] == 'enrichment:repair'] + data = events[0]['args'][0] + + assert 'running' in data + assert 'progress' in data + tracks = data['progress']['tracks'] + assert 'checked' in tracks + assert 'total' in tracks + assert 'repaired' in tracks + + +# ========================================================================= +# Group C — HTTP Parity (parameterized) +# ========================================================================= + +class TestEnrichmentHttpParity: + """Socket event data matches HTTP endpoint response.""" + + @pytest.mark.parametrize('worker', WORKERS) + def test_enrichment_matches_http(self, test_app, shared_state, worker): + """Socket event data matches GET /api//status.""" + app, socketio = test_app + flask_client = app.test_client() + ws_client = socketio.test_client(app) + build = shared_state['build_enrichment_status'] + + endpoint = ENDPOINTS[worker] + http_data = flask_client.get(endpoint).get_json() + + socketio.emit(f'enrichment:{worker}', build(worker)) + received = ws_client.get_received() + events = [e for e in received if e['name'] == f'enrichment:{worker}'] + assert len(events) >= 1 + ws_data = events[0]['args'][0] + + # Both should have the same running/paused state + assert ws_data['running'] == http_data['running'] + assert ws_data['paused'] == http_data['paused'] + + +# ========================================================================= +# Group D — HTTP Still Works (parameterized) +# ========================================================================= + +class TestEnrichmentHttpStillWorks: + """HTTP endpoints return 200 with expected structure.""" + + @pytest.mark.parametrize('worker', WORKERS) + def test_http_enrichment_still_works(self, flask_client, worker): + """GET /api//status returns 200.""" + endpoint = ENDPOINTS[worker] + resp = flask_client.get(endpoint) + assert resp.status_code == 200 + data = resp.get_json() + assert 'running' in data + assert 'paused' in data + + +# ========================================================================= +# Group E — Backward Compatibility +# ========================================================================= + +class TestEnrichmentBackwardCompat: + """HTTP endpoints work when no WebSocket is connected.""" + + def test_all_http_endpoints_work_without_socket(self, flask_client): + """All 7 enrichment HTTP endpoints work without any WebSocket connection.""" + for worker in WORKERS: + endpoint = ENDPOINTS[worker] + resp = flask_client.get(endpoint) + assert resp.status_code == 200 + data = resp.get_json() + assert data['running'] is True + + def test_multiple_clients_get_enrichment_updates(self, test_app, shared_state): + """Multiple WebSocket clients each receive enrichment events.""" + app, socketio = test_app + client1 = socketio.test_client(app) + client2 = socketio.test_client(app) + build = shared_state['build_enrichment_status'] + + socketio.emit('enrichment:musicbrainz', build('musicbrainz')) + + for client in [client1, client2]: + received = client.get_received() + events = [e for e in received if e['name'] == 'enrichment:musicbrainz'] + assert len(events) >= 1 + + client1.disconnect() + client2.disconnect() + + def test_enrichment_reflects_state_change(self, test_app, shared_state): + """When enrichment state changes, the next emit reflects it.""" + app, socketio = test_app + client = socketio.test_client(app) + enrich = shared_state['enrichment_status'] + build = shared_state['build_enrichment_status'] + + # Mutate state + enrich['musicbrainz']['paused'] = True + enrich['musicbrainz']['running'] = False + + socketio.emit('enrichment:musicbrainz', build('musicbrainz')) + received = client.get_received() + events = [e for e in received if e['name'] == 'enrichment:musicbrainz'] + data = events[-1]['args'][0] + assert data['paused'] is True + assert data['running'] is False diff --git a/tests/test_phase4_tools.py b/tests/test_phase4_tools.py new file mode 100644 index 00000000..7d0608fd --- /dev/null +++ b/tests/test_phase4_tools.py @@ -0,0 +1,329 @@ +"""Phase 4 WebSocket migration tests — Tool progress pollers. + +Verifies that: + - All 7 tool progress statuses are delivered identically via + WebSocket events and HTTP endpoints + - Each tool's data shape is correct + - HTTP endpoints still work as fallback + +IMPORTANT: Do NOT use ``from tests.conftest import …`` — pytest's auto-discovered +conftest is a different module instance. Use the ``shared_state`` fixture instead. +""" + +import pytest + + +# All 7 tool progress pollers +TOOLS = [ + 'stream', 'quality-scanner', 'duplicate-cleaner', + 'retag', 'db-update', 'metadata', 'logs', +] + +# Endpoint URLs keyed by tool name +ENDPOINTS = { + 'stream': '/api/stream/status', + 'quality-scanner': '/api/quality-scanner/status', + 'duplicate-cleaner': '/api/duplicate-cleaner/status', + 'retag': '/api/retag/status', + 'db-update': '/api/database/update/status', + 'metadata': '/api/metadata/status', + 'logs': '/api/logs', +} + + +# ========================================================================= +# Group A — Event Delivery (parameterized) +# ========================================================================= + +class TestToolEventDelivery: + """tool: socket events are received by the client.""" + + @pytest.mark.parametrize('tool', TOOLS) + def test_tool_event_received(self, test_app, shared_state, tool): + """Client receives a tool: event.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_tool_status'] + + socketio.emit(f'tool:{tool}', build(tool)) + received = client.get_received() + events = [e for e in received if e['name'] == f'tool:{tool}'] + assert len(events) >= 1 + + +# ========================================================================= +# Group B — Data Shape (individual per tool) +# ========================================================================= + +class TestToolDataShape: + """tool: event data has the expected keys.""" + + def test_stream_shape(self, test_app, shared_state): + """Stream status has status, progress, track_info, error_message.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_stream_status'] + + socketio.emit('tool:stream', build()) + received = client.get_received() + events = [e for e in received if e['name'] == 'tool:stream'] + assert len(events) >= 1 + data = events[0]['args'][0] + + assert 'status' in data + assert 'progress' in data + assert 'track_info' in data + assert 'error_message' in data + assert isinstance(data['progress'], (int, float)) + + def test_quality_scanner_shape(self, test_app, shared_state): + """Quality scanner has status, phase, progress, processed, total, quality_met.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_quality_scanner_status'] + + socketio.emit('tool:quality-scanner', build()) + received = client.get_received() + events = [e for e in received if e['name'] == 'tool:quality-scanner'] + assert len(events) >= 1 + data = events[0]['args'][0] + + assert 'status' in data + assert 'phase' in data + assert 'progress' in data + assert 'processed' in data + assert 'total' in data + assert 'quality_met' in data + assert 'low_quality' in data + assert 'matched' in data + + def test_duplicate_cleaner_shape(self, test_app, shared_state): + """Duplicate cleaner has status, phase, progress, space_freed_mb.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_duplicate_cleaner_status'] + + socketio.emit('tool:duplicate-cleaner', build()) + received = client.get_received() + events = [e for e in received if e['name'] == 'tool:duplicate-cleaner'] + assert len(events) >= 1 + data = events[0]['args'][0] + + assert 'status' in data + assert 'phase' in data + assert 'progress' in data + assert 'files_scanned' in data + assert 'total_files' in data + assert 'duplicates_found' in data + assert 'deleted' in data + assert 'space_freed_mb' in data + assert isinstance(data['space_freed_mb'], (int, float)) + + def test_retag_shape(self, test_app, shared_state): + """Retag has status, phase, progress, current_track, total_tracks.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_retag_status'] + + socketio.emit('tool:retag', build()) + received = client.get_received() + events = [e for e in received if e['name'] == 'tool:retag'] + assert len(events) >= 1 + data = events[0]['args'][0] + + assert 'status' in data + assert 'phase' in data + assert 'progress' in data + assert 'current_track' in data + assert 'total_tracks' in data + assert 'processed' in data + + def test_db_update_shape(self, test_app, shared_state): + """DB update has status, phase, progress, removed_artists/albums/tracks.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_db_update_status'] + + socketio.emit('tool:db-update', build()) + received = client.get_received() + events = [e for e in received if e['name'] == 'tool:db-update'] + assert len(events) >= 1 + data = events[0]['args'][0] + + assert 'status' in data + assert 'phase' in data + assert 'progress' in data + assert 'current_item' in data + assert 'processed' in data + assert 'total' in data + assert 'removed_artists' in data + assert 'removed_albums' in data + assert 'removed_tracks' in data + + def test_metadata_shape(self, test_app, shared_state): + """Metadata has {success, status} wrapper with inner percentage, successful, failed.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_metadata_status'] + + socketio.emit('tool:metadata', build()) + received = client.get_received() + events = [e for e in received if e['name'] == 'tool:metadata'] + assert len(events) >= 1 + data = events[0]['args'][0] + + assert 'success' in data + assert data['success'] is True + assert 'status' in data + status = data['status'] + assert 'status' in status + assert 'current_artist' in status + assert 'processed' in status + assert 'total' in status + assert 'percentage' in status + assert 'successful' in status + assert 'failed' in status + + def test_logs_shape(self, test_app, shared_state): + """Logs has logs array of strings.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_logs'] + + socketio.emit('tool:logs', build()) + received = client.get_received() + events = [e for e in received if e['name'] == 'tool:logs'] + assert len(events) >= 1 + data = events[0]['args'][0] + + assert 'logs' in data + assert isinstance(data['logs'], list) + assert len(data['logs']) >= 1 + assert isinstance(data['logs'][0], str) + + +# ========================================================================= +# Group C — HTTP Parity (parameterized) +# ========================================================================= + +class TestToolHttpParity: + """Socket event data matches HTTP endpoint response.""" + + @pytest.mark.parametrize('tool', [t for t in TOOLS if t != 'logs']) + def test_tool_matches_http(self, test_app, shared_state, tool): + """Socket event data matches GET endpoint for non-logs tools.""" + app, socketio = test_app + flask_client = app.test_client() + ws_client = socketio.test_client(app) + build = shared_state['build_tool_status'] + + endpoint = ENDPOINTS[tool] + http_data = flask_client.get(endpoint).get_json() + + socketio.emit(f'tool:{tool}', build(tool)) + received = ws_client.get_received() + events = [e for e in received if e['name'] == f'tool:{tool}'] + assert len(events) >= 1 + ws_data = events[0]['args'][0] + + if tool == 'metadata': + # Metadata wraps in {success, status} + assert ws_data['success'] == http_data['success'] + assert ws_data['status']['status'] == http_data['status']['status'] + assert ws_data['status']['processed'] == http_data['status']['processed'] + else: + assert ws_data['status'] == http_data['status'] + + def test_logs_matches_http(self, test_app, shared_state): + """Logs event data matches GET /api/logs.""" + app, socketio = test_app + flask_client = app.test_client() + ws_client = socketio.test_client(app) + build = shared_state['build_logs'] + + http_data = flask_client.get('/api/logs').get_json() + + socketio.emit('tool:logs', build()) + received = ws_client.get_received() + events = [e for e in received if e['name'] == 'tool:logs'] + assert len(events) >= 1 + ws_data = events[0]['args'][0] + + assert len(ws_data['logs']) == len(http_data['logs']) + if ws_data['logs']: + assert ws_data['logs'][0] == http_data['logs'][0] + + +# ========================================================================= +# Group D — HTTP Still Works (parameterized) +# ========================================================================= + +class TestToolHttpStillWorks: + """HTTP endpoints return 200 with expected structure.""" + + @pytest.mark.parametrize('tool', TOOLS) + def test_http_tool_still_works(self, flask_client, tool): + """GET /api//status returns 200.""" + endpoint = ENDPOINTS[tool] + resp = flask_client.get(endpoint) + assert resp.status_code == 200 + data = resp.get_json() + if tool == 'logs': + assert 'logs' in data + elif tool == 'metadata': + assert 'success' in data + assert 'status' in data + else: + assert 'status' in data + + +# ========================================================================= +# Group E — Backward Compatibility +# ========================================================================= + +class TestToolBackwardCompat: + """HTTP endpoints work when no WebSocket is connected.""" + + def test_all_http_endpoints_work_without_socket(self, flask_client): + """All 7 tool HTTP endpoints work without any WebSocket connection.""" + for tool in TOOLS: + endpoint = ENDPOINTS[tool] + resp = flask_client.get(endpoint) + assert resp.status_code == 200 + + def test_multiple_clients_get_tool_updates(self, test_app, shared_state): + """Multiple WebSocket clients each receive tool events.""" + app, socketio = test_app + client1 = socketio.test_client(app) + client2 = socketio.test_client(app) + build = shared_state['build_tool_status'] + + socketio.emit('tool:quality-scanner', build('quality-scanner')) + + for client in [client1, client2]: + received = client.get_received() + events = [e for e in received if e['name'] == 'tool:quality-scanner'] + assert len(events) >= 1 + + client1.disconnect() + client2.disconnect() + + def test_tool_reflects_state_change(self, test_app, shared_state): + """When tool state changes, the next emit reflects it.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_tool_status'] + qs = shared_state['quality_scanner_state'] + + # Mutate state + qs['status'] = 'finished' + qs['progress'] = 100 + qs['processed'] = 100 + + socketio.emit('tool:quality-scanner', build('quality-scanner')) + received = client.get_received() + events = [e for e in received if e['name'] == 'tool:quality-scanner'] + data = events[-1]['args'][0] + assert data['status'] == 'finished' + assert data['progress'] == 100 + assert data['processed'] == 100 diff --git a/tests/test_phase5_sync.py b/tests/test_phase5_sync.py new file mode 100644 index 00000000..7911fee1 --- /dev/null +++ b/tests/test_phase5_sync.py @@ -0,0 +1,920 @@ +"""Phase 5 WebSocket migration tests — Sync/Discovery Progress + Scans. + +Verifies that: + - Room-based sync:progress events are delivered only to subscribed clients + - Room-based discovery:progress events are delivered only to subscribed clients + - Broadcast scan:watchlist and scan:media events reach all clients + - Data shapes are correct for each event type + - HTTP endpoints still work as fallback + +IMPORTANT: Do NOT use ``from tests.conftest import …`` — pytest's auto-discovered +conftest is a different module instance. Use the ``shared_state`` fixture instead. +""" + +import pytest + + +# ========================================================================= +# Constants +# ========================================================================= + +DISCOVERY_PLATFORMS = ['tidal', 'youtube'] + +SYNC_PLAYLIST_IDS = ['test-playlist-1'] + + +# ========================================================================= +# Group A — Sync Event Delivery (room-based) +# ========================================================================= + +class TestSyncEventDelivery: + """sync:progress socket events are received by subscribed clients.""" + + def test_sync_event_received(self, test_app, shared_state): + """Client subscribes and receives sync:progress event.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_sync_status'] + + # Subscribe to sync room + client.emit('sync:subscribe', {'playlist_ids': ['test-playlist-1']}) + + # Server emits to the room + socketio.emit('sync:progress', { + 'playlist_id': 'test-playlist-1', + **build('test-playlist-1') + }, room='sync:test-playlist-1') + + received = client.get_received() + events = [e for e in received if e['name'] == 'sync:progress'] + assert len(events) >= 1 + + client.disconnect() + + def test_sync_only_subscribed(self, test_app, shared_state): + """Unsubscribed client does NOT receive sync:progress events.""" + app, socketio = test_app + subscribed = socketio.test_client(app) + unsubscribed = socketio.test_client(app) + build = shared_state['build_sync_status'] + + # Only one client subscribes + subscribed.emit('sync:subscribe', {'playlist_ids': ['test-playlist-1']}) + + socketio.emit('sync:progress', { + 'playlist_id': 'test-playlist-1', + **build('test-playlist-1') + }, room='sync:test-playlist-1') + + sub_events = [e for e in subscribed.get_received() + if e['name'] == 'sync:progress'] + unsub_events = [e for e in unsubscribed.get_received() + if e['name'] == 'sync:progress'] + + assert len(sub_events) >= 1 + assert len(unsub_events) == 0 + + subscribed.disconnect() + unsubscribed.disconnect() + + def test_sync_subscribe_unsubscribe(self, test_app, shared_state): + """Client stops receiving events after unsubscribing.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_sync_status'] + + # Subscribe + client.emit('sync:subscribe', {'playlist_ids': ['test-playlist-1']}) + + # First emit — should receive + socketio.emit('sync:progress', { + 'playlist_id': 'test-playlist-1', + **build('test-playlist-1') + }, room='sync:test-playlist-1') + + received = client.get_received() + events = [e for e in received if e['name'] == 'sync:progress'] + assert len(events) >= 1 + + # Unsubscribe + client.emit('sync:unsubscribe', {'playlist_ids': ['test-playlist-1']}) + + # Second emit — should NOT receive + socketio.emit('sync:progress', { + 'playlist_id': 'test-playlist-1', + **build('test-playlist-1') + }, room='sync:test-playlist-1') + + received2 = client.get_received() + events2 = [e for e in received2 if e['name'] == 'sync:progress'] + assert len(events2) == 0 + + client.disconnect() + + +# ========================================================================= +# Group B — Sync Data Shape +# ========================================================================= + +class TestSyncDataShape: + """sync:progress event data has the expected keys.""" + + def test_sync_progress_shape(self, test_app, shared_state): + """Sync progress has playlist_id, status, progress dict.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_sync_status'] + + client.emit('sync:subscribe', {'playlist_ids': ['test-playlist-1']}) + + payload = {'playlist_id': 'test-playlist-1', **build('test-playlist-1')} + socketio.emit('sync:progress', payload, room='sync:test-playlist-1') + + received = client.get_received() + events = [e for e in received if e['name'] == 'sync:progress'] + assert len(events) >= 1 + data = events[0]['args'][0] + + assert 'playlist_id' in data + assert data['playlist_id'] == 'test-playlist-1' + assert 'status' in data + assert data['status'] == 'syncing' + assert 'progress' in data + progress = data['progress'] + assert 'total_tracks' in progress + assert 'matched_tracks' in progress + assert 'progress' in progress + assert isinstance(progress['progress'], (int, float)) + + client.disconnect() + + +# ========================================================================= +# Group C — Discovery Event Delivery (room-based) +# ========================================================================= + +class TestDiscoveryEventDelivery: + """discovery:progress socket events are received by subscribed clients.""" + + @pytest.mark.parametrize('platform,pid', [ + ('tidal', 'test-tidal-1'), + ('youtube', 'test-yt-hash'), + ]) + def test_discovery_event_received(self, test_app, shared_state, platform, pid): + """Client subscribes and receives discovery:progress event.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_discovery_status'] + + client.emit('discovery:subscribe', {'ids': [pid]}) + + payload = build(platform, pid) + payload['platform'] = platform + payload['id'] = pid + socketio.emit('discovery:progress', payload, room=f'discovery:{pid}') + + received = client.get_received() + events = [e for e in received if e['name'] == 'discovery:progress'] + assert len(events) >= 1 + + client.disconnect() + + def test_discovery_only_subscribed(self, test_app, shared_state): + """Unsubscribed client does NOT receive discovery:progress events.""" + app, socketio = test_app + subscribed = socketio.test_client(app) + unsubscribed = socketio.test_client(app) + build = shared_state['build_discovery_status'] + + subscribed.emit('discovery:subscribe', {'ids': ['test-tidal-1']}) + + payload = build('tidal', 'test-tidal-1') + payload['platform'] = 'tidal' + payload['id'] = 'test-tidal-1' + socketio.emit('discovery:progress', payload, room='discovery:test-tidal-1') + + sub_events = [e for e in subscribed.get_received() + if e['name'] == 'discovery:progress'] + unsub_events = [e for e in unsubscribed.get_received() + if e['name'] == 'discovery:progress'] + + assert len(sub_events) >= 1 + assert len(unsub_events) == 0 + + subscribed.disconnect() + unsubscribed.disconnect() + + def test_discovery_subscribe_unsubscribe(self, test_app, shared_state): + """Client stops receiving discovery events after unsubscribing.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_discovery_status'] + + client.emit('discovery:subscribe', {'ids': ['test-yt-hash']}) + + payload = build('youtube', 'test-yt-hash') + payload['platform'] = 'youtube' + payload['id'] = 'test-yt-hash' + socketio.emit('discovery:progress', payload, room='discovery:test-yt-hash') + + received = client.get_received() + events = [e for e in received if e['name'] == 'discovery:progress'] + assert len(events) >= 1 + + client.emit('discovery:unsubscribe', {'ids': ['test-yt-hash']}) + + socketio.emit('discovery:progress', payload, room='discovery:test-yt-hash') + received2 = client.get_received() + events2 = [e for e in received2 if e['name'] == 'discovery:progress'] + assert len(events2) == 0 + + client.disconnect() + + +# ========================================================================= +# Group D — Discovery Data Shape +# ========================================================================= + +class TestDiscoveryDataShape: + """discovery:progress event data has the expected keys.""" + + @pytest.mark.parametrize('platform,pid', [ + ('tidal', 'test-tidal-1'), + ('youtube', 'test-yt-hash'), + ]) + def test_discovery_progress_shape(self, test_app, shared_state, platform, pid): + """Discovery progress has platform, id, phase, status, complete, results.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_discovery_status'] + + client.emit('discovery:subscribe', {'ids': [pid]}) + + payload = build(platform, pid) + payload['platform'] = platform + payload['id'] = pid + socketio.emit('discovery:progress', payload, room=f'discovery:{pid}') + + received = client.get_received() + events = [e for e in received if e['name'] == 'discovery:progress'] + assert len(events) >= 1 + data = events[0]['args'][0] + + assert data['platform'] == platform + assert data['id'] == pid + assert 'phase' in data + assert 'status' in data + assert 'progress' in data + assert 'spotify_matches' in data + assert 'spotify_total' in data + assert 'results' in data + assert 'complete' in data + assert isinstance(data['results'], list) + assert isinstance(data['complete'], bool) + + client.disconnect() + + +# ========================================================================= +# Group E — Scan Events (broadcast) +# ========================================================================= + +class TestScanEventDelivery: + """Broadcast scan events are received by all connected clients.""" + + def test_watchlist_scan_received(self, test_app, shared_state): + """All clients receive scan:watchlist event.""" + app, socketio = test_app + client1 = socketio.test_client(app) + client2 = socketio.test_client(app) + build = shared_state['build_watchlist_scan_status'] + + socketio.emit('scan:watchlist', build()) + + for client in [client1, client2]: + received = client.get_received() + events = [e for e in received if e['name'] == 'scan:watchlist'] + assert len(events) >= 1 + + client1.disconnect() + client2.disconnect() + + def test_watchlist_scan_shape(self, test_app, shared_state): + """scan:watchlist data has success, status, current_artist_name.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_watchlist_scan_status'] + + socketio.emit('scan:watchlist', build()) + + received = client.get_received() + events = [e for e in received if e['name'] == 'scan:watchlist'] + assert len(events) >= 1 + data = events[0]['args'][0] + + assert data['success'] is True + assert 'status' in data + assert data['status'] == 'scanning' + assert 'current_artist_name' in data + assert 'current_album' in data + assert 'current_track_name' in data + + client.disconnect() + + def test_media_scan_received(self, test_app, shared_state): + """All clients receive scan:media event.""" + app, socketio = test_app + client1 = socketio.test_client(app) + client2 = socketio.test_client(app) + build = shared_state['build_media_scan_status'] + + socketio.emit('scan:media', build()) + + for client in [client1, client2]: + received = client.get_received() + events = [e for e in received if e['name'] == 'scan:media'] + assert len(events) >= 1 + + client1.disconnect() + client2.disconnect() + + def test_media_scan_shape(self, test_app, shared_state): + """scan:media data has success and status with is_scanning.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_media_scan_status'] + + socketio.emit('scan:media', build()) + + received = client.get_received() + events = [e for e in received if e['name'] == 'scan:media'] + assert len(events) >= 1 + data = events[0]['args'][0] + + assert data['success'] is True + assert 'status' in data + status = data['status'] + assert 'is_scanning' in status + assert 'status' in status + assert 'progress_message' in status + + client.disconnect() + + +# ========================================================================= +# Group F — HTTP Parity +# ========================================================================= + +class TestSyncHttpParity: + """Socket event data matches HTTP endpoint response for sync.""" + + def test_sync_matches_http(self, test_app, shared_state): + """Sync socket data matches GET /api/sync/status/:id.""" + app, socketio = test_app + flask_client = app.test_client() + ws_client = socketio.test_client(app) + build = shared_state['build_sync_status'] + + endpoint = shared_state['sync_endpoints']['sync'] + http_data = flask_client.get(endpoint).get_json() + + ws_client.emit('sync:subscribe', {'playlist_ids': ['test-playlist-1']}) + + payload = {'playlist_id': 'test-playlist-1', **build('test-playlist-1')} + socketio.emit('sync:progress', payload, room='sync:test-playlist-1') + + received = ws_client.get_received() + events = [e for e in received if e['name'] == 'sync:progress'] + assert len(events) >= 1 + ws_data = events[0]['args'][0] + + assert ws_data['status'] == http_data['status'] + assert ws_data['playlist_id'] == http_data['playlist_id'] + assert ws_data['progress']['total_tracks'] == http_data['progress']['total_tracks'] + assert ws_data['progress']['matched_tracks'] == http_data['progress']['matched_tracks'] + + ws_client.disconnect() + + +class TestDiscoveryHttpParity: + """Socket event data matches HTTP endpoint response for discovery.""" + + @pytest.mark.parametrize('platform,pid,endpoint_key', [ + ('tidal', 'test-tidal-1', 'tidal'), + ('youtube', 'test-yt-hash', 'youtube'), + ]) + def test_discovery_matches_http(self, test_app, shared_state, + platform, pid, endpoint_key): + """Discovery socket data matches GET /api//discovery/status/:id.""" + app, socketio = test_app + flask_client = app.test_client() + ws_client = socketio.test_client(app) + build = shared_state['build_discovery_status'] + + endpoint = shared_state['discovery_endpoints'][endpoint_key] + http_data = flask_client.get(endpoint).get_json() + + ws_client.emit('discovery:subscribe', {'ids': [pid]}) + + payload = build(platform, pid) + payload['platform'] = platform + payload['id'] = pid + socketio.emit('discovery:progress', payload, room=f'discovery:{pid}') + + received = ws_client.get_received() + events = [e for e in received if e['name'] == 'discovery:progress'] + assert len(events) >= 1 + ws_data = events[0]['args'][0] + + assert ws_data['phase'] == http_data['phase'] + assert ws_data['status'] == http_data['status'] + assert ws_data['spotify_matches'] == http_data['spotify_matches'] + assert ws_data['spotify_total'] == http_data['spotify_total'] + assert ws_data['complete'] == http_data['complete'] + + ws_client.disconnect() + + +class TestScanHttpParity: + """Socket event data matches HTTP endpoint response for scans.""" + + def test_watchlist_scan_matches_http(self, test_app, shared_state): + """scan:watchlist socket data matches GET /api/watchlist/scan/status.""" + app, socketio = test_app + flask_client = app.test_client() + ws_client = socketio.test_client(app) + build = shared_state['build_watchlist_scan_status'] + + endpoint = shared_state['scan_endpoints']['watchlist'] + http_data = flask_client.get(endpoint).get_json() + + socketio.emit('scan:watchlist', build()) + + received = ws_client.get_received() + events = [e for e in received if e['name'] == 'scan:watchlist'] + assert len(events) >= 1 + ws_data = events[0]['args'][0] + + assert ws_data['success'] == http_data['success'] + assert ws_data['status'] == http_data['status'] + assert ws_data['current_artist_name'] == http_data['current_artist_name'] + + ws_client.disconnect() + + def test_media_scan_matches_http(self, test_app, shared_state): + """scan:media socket data matches GET /api/scan/status.""" + app, socketio = test_app + flask_client = app.test_client() + ws_client = socketio.test_client(app) + build = shared_state['build_media_scan_status'] + + endpoint = shared_state['scan_endpoints']['media'] + http_data = flask_client.get(endpoint).get_json() + + socketio.emit('scan:media', build()) + + received = ws_client.get_received() + events = [e for e in received if e['name'] == 'scan:media'] + assert len(events) >= 1 + ws_data = events[0]['args'][0] + + assert ws_data['success'] == http_data['success'] + assert ws_data['status']['is_scanning'] == http_data['status']['is_scanning'] + assert ws_data['status']['status'] == http_data['status']['status'] + + ws_client.disconnect() + + +# ========================================================================= +# Group G — HTTP Still Works +# ========================================================================= + +class TestHttpStillWorks: + """HTTP endpoints return 200 with expected structure.""" + + def test_sync_http_works(self, flask_client): + """GET /api/sync/status/:id returns 200.""" + resp = flask_client.get('/api/sync/status/test-playlist-1') + assert resp.status_code == 200 + data = resp.get_json() + assert 'status' in data + assert 'progress' in data + + def test_sync_http_404_unknown(self, flask_client): + """GET /api/sync/status/:id returns 404 for unknown playlist.""" + resp = flask_client.get('/api/sync/status/nonexistent') + assert resp.status_code == 404 + + @pytest.mark.parametrize('platform,endpoint', [ + ('tidal', '/api/tidal/discovery/status/test-tidal-1'), + ('youtube', '/api/youtube/discovery/status/test-yt-hash'), + ]) + def test_discovery_http_works(self, flask_client, platform, endpoint): + """GET /api//discovery/status/:id returns 200.""" + resp = flask_client.get(endpoint) + assert resp.status_code == 200 + data = resp.get_json() + assert 'phase' in data + assert 'status' in data + assert 'results' in data + + def test_discovery_http_not_found(self, flask_client): + """GET /api/tidal/discovery/status/:id returns error for unknown ID.""" + resp = flask_client.get('/api/tidal/discovery/status/nonexistent') + assert resp.status_code == 200 + data = resp.get_json() + assert 'error' in data + + def test_watchlist_scan_http_works(self, flask_client): + """GET /api/watchlist/scan/status returns 200.""" + resp = flask_client.get('/api/watchlist/scan/status') + assert resp.status_code == 200 + data = resp.get_json() + assert data['success'] is True + assert 'status' in data + + def test_media_scan_http_works(self, flask_client): + """GET /api/scan/status returns 200.""" + resp = flask_client.get('/api/scan/status') + assert resp.status_code == 200 + data = resp.get_json() + assert data['success'] is True + assert 'status' in data + + +# ========================================================================= +# Group H — Backward Compatibility +# ========================================================================= + +class TestBackwardCompat: + """HTTP endpoints work when no WebSocket is connected.""" + + def test_all_http_endpoints_work_without_socket(self, flask_client): + """All Phase 5 HTTP endpoints work without any WebSocket connection.""" + endpoints = [ + '/api/sync/status/test-playlist-1', + '/api/tidal/discovery/status/test-tidal-1', + '/api/youtube/discovery/status/test-yt-hash', + '/api/watchlist/scan/status', + '/api/scan/status', + ] + for endpoint in endpoints: + resp = flask_client.get(endpoint) + assert resp.status_code == 200 + + def test_multiple_clients_get_scan_updates(self, test_app, shared_state): + """Multiple WebSocket clients each receive scan events.""" + app, socketio = test_app + client1 = socketio.test_client(app) + client2 = socketio.test_client(app) + build = shared_state['build_watchlist_scan_status'] + + socketio.emit('scan:watchlist', build()) + + for client in [client1, client2]: + received = client.get_received() + events = [e for e in received if e['name'] == 'scan:watchlist'] + assert len(events) >= 1 + + client1.disconnect() + client2.disconnect() + + def test_sync_reflects_state_change(self, test_app, shared_state): + """When sync state changes, the next emit reflects it.""" + app, socketio = test_app + client = socketio.test_client(app) + ss = shared_state['sync_states'] + build = shared_state['build_sync_status'] + + client.emit('sync:subscribe', {'playlist_ids': ['test-playlist-1']}) + + # Mutate state + ss['test-playlist-1']['status'] = 'completed' + ss['test-playlist-1']['progress']['progress'] = 100 + ss['test-playlist-1']['progress']['matched_tracks'] = 11 + + payload = {'playlist_id': 'test-playlist-1', **build('test-playlist-1')} + socketio.emit('sync:progress', payload, room='sync:test-playlist-1') + + received = client.get_received() + events = [e for e in received if e['name'] == 'sync:progress'] + data = events[-1]['args'][0] + assert data['status'] == 'completed' + assert data['progress']['progress'] == 100 + assert data['progress']['matched_tracks'] == 11 + + client.disconnect() + + def test_discovery_reflects_state_change(self, test_app, shared_state): + """When discovery state changes, the next emit reflects it.""" + app, socketio = test_app + client = socketio.test_client(app) + ds = shared_state['discovery_states'] + build = shared_state['build_discovery_status'] + + client.emit('discovery:subscribe', {'ids': ['test-tidal-1']}) + + # Mutate state + ds['tidal']['test-tidal-1']['phase'] = 'discovered' + ds['tidal']['test-tidal-1']['spotify_matches'] = 10 + + payload = build('tidal', 'test-tidal-1') + payload['platform'] = 'tidal' + payload['id'] = 'test-tidal-1' + socketio.emit('discovery:progress', payload, room='discovery:test-tidal-1') + + received = client.get_received() + events = [e for e in received if e['name'] == 'discovery:progress'] + data = events[-1]['args'][0] + assert data['phase'] == 'discovered' + assert data['complete'] is True + assert data['spotify_matches'] == 10 + + client.disconnect() + + def test_scan_reflects_state_change(self, test_app, shared_state): + """When scan state changes, the next emit reflects it.""" + app, socketio = test_app + client = socketio.test_client(app) + wss = shared_state['watchlist_scan_state'] + build = shared_state['build_watchlist_scan_status'] + + # Mutate state + wss['status'] = 'completed' + wss['current_artist_name'] = 'Led Zeppelin' + + socketio.emit('scan:watchlist', build()) + + received = client.get_received() + events = [e for e in received if e['name'] == 'scan:watchlist'] + data = events[-1]['args'][0] + assert data['status'] == 'completed' + assert data['current_artist_name'] == 'Led Zeppelin' + + client.disconnect() + + +# ========================================================================= +# Group I — Phase 6: Platform Sync via WebSocket Rooms +# ========================================================================= + +PLATFORM_SYNC_IDS = [ + ('tidal', 'tidal_test-tidal-1'), + ('youtube', 'youtube_test-yt-hash'), + ('beatport', 'beatport_sync_test-bp-hash_1234'), + ('listenbrainz', 'listenbrainz_test-lb-mbid'), +] + + +class TestPlatformSyncEventDelivery: + """Platform sync pollers receive sync:progress via WS rooms.""" + + @pytest.mark.parametrize('platform,sync_id', PLATFORM_SYNC_IDS) + def test_platform_sync_event_received(self, test_app, shared_state, platform, sync_id): + """Client subscribes to platform sync_playlist_id and receives sync:progress.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_sync_status'] + + client.emit('sync:subscribe', {'playlist_ids': [sync_id]}) + + socketio.emit('sync:progress', { + 'playlist_id': sync_id, **build(sync_id) + }, room=f'sync:{sync_id}') + + received = client.get_received() + events = [e for e in received if e['name'] == 'sync:progress'] + assert len(events) >= 1 + data = events[0]['args'][0] + assert data['playlist_id'] == sync_id + assert data['status'] == 'syncing' + + client.disconnect() + + @pytest.mark.parametrize('platform,sync_id', PLATFORM_SYNC_IDS) + def test_platform_sync_not_received_when_unsubscribed( + self, test_app, shared_state, platform, sync_id): + """Unsubscribed client does NOT receive platform sync events.""" + app, socketio = test_app + subscribed = socketio.test_client(app) + unsubscribed = socketio.test_client(app) + build = shared_state['build_sync_status'] + + subscribed.emit('sync:subscribe', {'playlist_ids': [sync_id]}) + + socketio.emit('sync:progress', { + 'playlist_id': sync_id, **build(sync_id) + }, room=f'sync:{sync_id}') + + sub_events = [e for e in subscribed.get_received() + if e['name'] == 'sync:progress'] + unsub_events = [e for e in unsubscribed.get_received() + if e['name'] == 'sync:progress'] + + assert len(sub_events) >= 1 + assert len(unsub_events) == 0 + + subscribed.disconnect() + unsubscribed.disconnect() + + @pytest.mark.parametrize('platform,sync_id', PLATFORM_SYNC_IDS) + def test_platform_sync_progress_shape(self, test_app, shared_state, platform, sync_id): + """Platform sync progress data has expected keys.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_sync_status'] + + client.emit('sync:subscribe', {'playlist_ids': [sync_id]}) + + payload = {'playlist_id': sync_id, **build(sync_id)} + socketio.emit('sync:progress', payload, room=f'sync:{sync_id}') + + received = client.get_received() + events = [e for e in received if e['name'] == 'sync:progress'] + assert len(events) >= 1 + data = events[0]['args'][0] + + assert 'playlist_id' in data + assert 'status' in data + assert 'progress' in data + progress = data['progress'] + assert 'total_tracks' in progress + assert 'matched_tracks' in progress + assert 'failed_tracks' in progress + assert 'progress' in progress + + client.disconnect() + + def test_platform_sync_completion_detection(self, test_app, shared_state): + """When status changes to 'finished', client detects completion.""" + app, socketio = test_app + client = socketio.test_client(app) + ss = shared_state['sync_states'] + + sync_id = 'tidal_test-tidal-1' + client.emit('sync:subscribe', {'playlist_ids': [sync_id]}) + + # Mutate to finished + ss[sync_id]['status'] = 'finished' + ss[sync_id]['progress']['progress'] = 100 + ss[sync_id]['progress']['matched_tracks'] = 8 + + build = shared_state['build_sync_status'] + socketio.emit('sync:progress', { + 'playlist_id': sync_id, **build(sync_id) + }, room=f'sync:{sync_id}') + + received = client.get_received() + events = [e for e in received if e['name'] == 'sync:progress'] + data = events[-1]['args'][0] + assert data['status'] == 'finished' + assert data['progress']['progress'] == 100 + + client.disconnect() + + def test_platform_sync_error_detection(self, test_app, shared_state): + """When status changes to 'error', client detects the error.""" + app, socketio = test_app + client = socketio.test_client(app) + ss = shared_state['sync_states'] + + sync_id = 'youtube_test-yt-hash' + client.emit('sync:subscribe', {'playlist_ids': [sync_id]}) + + ss[sync_id]['status'] = 'error' + ss[sync_id]['error'] = 'Connection lost' + + build = shared_state['build_sync_status'] + socketio.emit('sync:progress', { + 'playlist_id': sync_id, **build(sync_id) + }, room=f'sync:{sync_id}') + + received = client.get_received() + events = [e for e in received if e['name'] == 'sync:progress'] + data = events[-1]['args'][0] + assert data['status'] == 'error' + + client.disconnect() + + @pytest.mark.parametrize('platform,sync_id', PLATFORM_SYNC_IDS) + def test_platform_sync_http_still_works(self, flask_client, platform, sync_id): + """GET /api/sync/status/ returns 200.""" + resp = flask_client.get(f'/api/sync/status/{sync_id}') + assert resp.status_code == 200 + data = resp.get_json() + assert 'status' in data + assert 'progress' in data + + +# ========================================================================= +# Group H — Wishlist Stats (broadcast) +# ========================================================================= + +class TestWishlistStatsEventDelivery: + """wishlist:stats broadcast events are received by the client.""" + + def test_wishlist_stats_event_received(self, test_app, shared_state): + """Client receives a wishlist:stats event.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_wishlist_stats'] + + socketio.emit('wishlist:stats', build()) + received = client.get_received() + events = [e for e in received if e['name'] == 'wishlist:stats'] + assert len(events) >= 1 + client.disconnect() + + def test_wishlist_stats_data_shape(self, test_app, shared_state): + """wishlist:stats has is_auto_processing and next_run_in_seconds.""" + app, socketio = test_app + client = socketio.test_client(app) + build = shared_state['build_wishlist_stats'] + + socketio.emit('wishlist:stats', build()) + received = client.get_received() + events = [e for e in received if e['name'] == 'wishlist:stats'] + assert len(events) >= 1 + data = events[0]['args'][0] + + assert 'is_auto_processing' in data + assert 'next_run_in_seconds' in data + assert isinstance(data['is_auto_processing'], bool) + assert isinstance(data['next_run_in_seconds'], (int, float)) + client.disconnect() + + def test_wishlist_stats_http_still_works(self, flask_client): + """GET /api/wishlist/stats returns 200 with expected keys.""" + resp = flask_client.get('/api/wishlist/stats') + assert resp.status_code == 200 + data = resp.get_json() + assert 'is_auto_processing' in data + assert 'next_run_in_seconds' in data + + def test_wishlist_stats_auto_processing_detection(self, test_app, shared_state): + """When is_auto_processing is True, client detects it.""" + app, socketio = test_app + client = socketio.test_client(app) + ws = shared_state['wishlist_stats_state'] + + ws['is_auto_processing'] = True + ws['next_run_in_seconds'] = 0 + build = shared_state['build_wishlist_stats'] + + socketio.emit('wishlist:stats', build()) + received = client.get_received() + events = [e for e in received if e['name'] == 'wishlist:stats'] + data = events[-1]['args'][0] + assert data['is_auto_processing'] is True + client.disconnect() + + def test_wishlist_stats_countdown_value(self, test_app, shared_state): + """next_run_in_seconds reflects the mutable state.""" + app, socketio = test_app + client = socketio.test_client(app) + ws = shared_state['wishlist_stats_state'] + + ws['next_run_in_seconds'] = 42 + build = shared_state['build_wishlist_stats'] + + socketio.emit('wishlist:stats', build()) + received = client.get_received() + events = [e for e in received if e['name'] == 'wishlist:stats'] + data = events[-1]['args'][0] + assert data['next_run_in_seconds'] == 42 + client.disconnect() + + def test_wishlist_stats_multiple_clients(self, test_app, shared_state): + """Multiple clients each receive wishlist:stats broadcast.""" + app, socketio = test_app + client1 = socketio.test_client(app) + client2 = socketio.test_client(app) + build = shared_state['build_wishlist_stats'] + + socketio.emit('wishlist:stats', build()) + + for client in [client1, client2]: + received = client.get_received() + events = [e for e in received if e['name'] == 'wishlist:stats'] + assert len(events) >= 1 + + client1.disconnect() + client2.disconnect() + + def test_wishlist_stats_matches_http(self, test_app, shared_state): + """Socket event data matches HTTP endpoint response.""" + app, socketio = test_app + flask_client = app.test_client() + ws_client = socketio.test_client(app) + build = shared_state['build_wishlist_stats'] + + http_data = flask_client.get('/api/wishlist/stats').get_json() + + socketio.emit('wishlist:stats', build()) + received = ws_client.get_received() + events = [e for e in received if e['name'] == 'wishlist:stats'] + ws_data = events[0]['args'][0] + + assert ws_data['is_auto_processing'] == http_data['is_auto_processing'] + assert ws_data['next_run_in_seconds'] == http_data['next_run_in_seconds'] + ws_client.disconnect() diff --git a/web_server.py b/web_server.py index a9b8ee5b..64568752 100644 --- a/web_server.py +++ b/web_server.py @@ -18,6 +18,7 @@ from urllib.parse import urljoin from concurrent.futures import ThreadPoolExecutor, as_completed from flask import Flask, render_template, request, jsonify, redirect, send_file, Response +from flask_socketio import SocketIO, emit, join_room, leave_room from utils.logging_config import get_logger from utils.async_helpers import run_async @@ -119,6 +120,9 @@ app = Flask( static_folder=os.path.join(base_dir, 'webui', 'static') ) +# --- WebSocket (Socket.IO) Setup --- +socketio = SocketIO(app, async_mode='threading', cors_allowed_origins='*') + # --- Docker Helper Functions --- def docker_resolve_path(path_str): """ @@ -2366,85 +2370,88 @@ def save_playlist_m3u(): logger.error(f"❌ Error saving M3U file: {e}") return jsonify({"status": "error", "message": str(e)}), 500 +def _build_system_stats(): + """Build system statistics dict — shared by HTTP handler and WebSocket emitter.""" + import psutil + import time + from datetime import timedelta + + # Calculate uptime + start_time = getattr(app, 'start_time', time.time()) + uptime_seconds = time.time() - start_time + uptime = str(timedelta(seconds=int(uptime_seconds))) + + # Get memory usage + memory = psutil.virtual_memory() + memory_usage = f"{memory.percent}%" + + # Count active downloads from download_batches (batches that are currently downloading) + active_downloads = len([batch_id for batch_id, batch_data in download_batches.items() + if batch_data.get('phase') == 'downloading']) + + # Count finished downloads (completed this session) - use session counter like dashboard.py + with session_stats_lock: + finished_downloads = session_completed_downloads + + # Calculate total download speed from active soulseek transfers + total_download_speed = 0.0 + try: + transfers_data = run_async(soulseek_client._make_request('GET', 'transfers/downloads')) + if transfers_data: + for user_data in transfers_data: + if 'directories' in user_data: + for directory in user_data['directories']: + if 'files' in directory: + for file_info in directory['files']: + state = file_info.get('state', '').lower() + # Only count actively downloading files + if 'inprogress' in state or 'downloading' in state or 'transferring' in state: + speed = file_info.get('averageSpeed', 0) + if isinstance(speed, (int, float)) and speed > 0: + total_download_speed += float(speed) + except Exception as e: + print(f"Warning: Could not fetch download speeds: {e}") + + # Convert bytes/sec to KB/s and format + if total_download_speed > 0: + speed_kb_s = total_download_speed / 1024 + if speed_kb_s >= 1024: + speed_mb_s = speed_kb_s / 1024 + download_speed_str = f"{speed_mb_s:.1f} MB/s" + else: + download_speed_str = f"{speed_kb_s:.1f} KB/s" + else: + download_speed_str = "0 KB/s" + + # Count active syncs (playlists currently syncing) + active_syncs = 0 + # Count Spotify playlist syncs + for playlist_id, sync_state in sync_states.items(): + if sync_state.get('status') == 'syncing': + active_syncs += 1 + # Count YouTube playlist syncs + for url_hash, state in youtube_playlist_states.items(): + if state.get('phase') == 'syncing': + active_syncs += 1 + # Count Tidal playlist syncs + for playlist_id, state in tidal_discovery_states.items(): + if state.get('phase') == 'syncing': + active_syncs += 1 + + return { + 'active_downloads': active_downloads, + 'finished_downloads': finished_downloads, + 'download_speed': download_speed_str, + 'active_syncs': active_syncs, + 'uptime': uptime, + 'memory_usage': memory_usage + } + @app.route('/api/system/stats') def get_system_stats(): """Get system statistics for dashboard""" try: - import psutil - import time - from datetime import timedelta - - # Calculate uptime - start_time = getattr(app, 'start_time', time.time()) - uptime_seconds = time.time() - start_time - uptime = str(timedelta(seconds=int(uptime_seconds))) - - # Get memory usage - memory = psutil.virtual_memory() - memory_usage = f"{memory.percent}%" - - # Count active downloads from download_batches (batches that are currently downloading) - active_downloads = len([batch_id for batch_id, batch_data in download_batches.items() - if batch_data.get('phase') == 'downloading']) - - # Count finished downloads (completed this session) - use session counter like dashboard.py - with session_stats_lock: - finished_downloads = session_completed_downloads - - # Calculate total download speed from active soulseek transfers - total_download_speed = 0.0 - try: - transfers_data = run_async(soulseek_client._make_request('GET', 'transfers/downloads')) - if transfers_data: - for user_data in transfers_data: - if 'directories' in user_data: - for directory in user_data['directories']: - if 'files' in directory: - for file_info in directory['files']: - state = file_info.get('state', '').lower() - # Only count actively downloading files - if 'inprogress' in state or 'downloading' in state or 'transferring' in state: - speed = file_info.get('averageSpeed', 0) - if isinstance(speed, (int, float)) and speed > 0: - total_download_speed += float(speed) - except Exception as e: - print(f"Warning: Could not fetch download speeds: {e}") - - # Convert bytes/sec to KB/s and format - if total_download_speed > 0: - speed_kb_s = total_download_speed / 1024 - if speed_kb_s >= 1024: - speed_mb_s = speed_kb_s / 1024 - download_speed_str = f"{speed_mb_s:.1f} MB/s" - else: - download_speed_str = f"{speed_kb_s:.1f} KB/s" - else: - download_speed_str = "0 KB/s" - - # Count active syncs (playlists currently syncing) - active_syncs = 0 - # Count Spotify playlist syncs - for playlist_id, sync_state in sync_states.items(): - if sync_state.get('status') == 'syncing': - active_syncs += 1 - # Count YouTube playlist syncs - for url_hash, state in youtube_playlist_states.items(): - if state.get('phase') == 'syncing': - active_syncs += 1 - # Count Tidal playlist syncs - for playlist_id, state in tidal_discovery_states.items(): - if state.get('phase') == 'syncing': - active_syncs += 1 - - stats_data = { - 'active_downloads': active_downloads, - 'finished_downloads': finished_downloads, - 'download_speed': download_speed_str, - 'active_syncs': active_syncs, - 'uptime': uptime, - 'memory_usage': memory_usage - } - return jsonify(stats_data) + return jsonify(_build_system_stats()) except Exception as e: return jsonify({'error': str(e)}), 500 @@ -2532,13 +2539,20 @@ def add_activity_item(icon: str, title: str, subtitle: str, time_ago: str = "Now 'timestamp': time.time(), 'show_toast': show_toast } - + with activity_feed_lock: activity_feed.append(activity_item) # Keep only last 20 items to prevent memory growth if len(activity_feed) > 20: activity_feed.pop(0) - + + # Instant toast push via WebSocket (replaces 3-second polling) + if show_toast: + try: + socketio.emit('dashboard:toast', activity_item) + except Exception: + pass + print(f"📝 Activity: {icon} {title} - {subtitle}") except Exception as e: print(f"Error adding activity item: {e}") @@ -21595,6 +21609,11 @@ def add_to_watchlist(): # Don't fail the add operation if image fetch fails print(f"⚠️ Could not fetch artist image for {artist_name}: {img_error}") + # Push updated count to all WebSocket clients immediately + try: + socketio.emit('watchlist:count', _build_watchlist_count_payload()) + except Exception: + pass return jsonify({"success": True, "message": f"Added {artist_name} to watchlist"}) else: return jsonify({"success": False, "error": "Failed to add artist to watchlist"}), 500 @@ -21609,14 +21628,19 @@ def remove_from_watchlist(): try: data = request.get_json() artist_id = data.get('artist_id') - + if not artist_id: return jsonify({"success": False, "error": "Missing artist_id"}), 400 - + database = get_database() success = database.remove_artist_from_watchlist(artist_id) - + if success: + # Push updated count to all WebSocket clients immediately + try: + socketio.emit('watchlist:count', _build_watchlist_count_payload()) + except Exception: + pass return jsonify({"success": True, "message": "Removed artist from watchlist"}) else: return jsonify({"success": False, "error": "Failed to remove artist from watchlist"}), 500 @@ -29643,6 +29667,355 @@ def import_staging_suggestions(): # ================================================================================================ +# ================================================================================================ +# WEBSOCKET (SOCKET.IO) EVENT HANDLERS AND BACKGROUND EMITTERS +# ================================================================================================ + +def _build_status_payload(): + """Build the same status payload used by GET /status, reading from the cache.""" + download_mode = config_manager.get('download_source.mode', 'soulseek') + soulseek_data = dict(_status_cache.get('soulseek', {})) + soulseek_data['source'] = download_mode + return { + 'spotify': _status_cache.get('spotify', {}), + 'media_server': _status_cache.get('media_server', {}), + 'soulseek': soulseek_data, + 'active_media_server': config_manager.get_active_media_server() + } + +def _build_watchlist_count_payload(): + """Build the same payload used by GET /api/watchlist/count.""" + try: + database = get_database() + count = database.get_watchlist_count() + except Exception: + count = 0 + next_run_in_seconds = 0 + with watchlist_timer_lock: + if watchlist_next_run_time > 0: + next_run_in_seconds = max(0, int(watchlist_next_run_time - time.time())) + return { + 'success': True, + 'count': count, + 'next_run_in_seconds': next_run_in_seconds + } + +def _emit_service_status_loop(): + """Background thread that pushes service status every 10 seconds.""" + while True: + socketio.sleep(10) + try: + socketio.emit('status:update', _build_status_payload()) + except Exception as e: + logger.debug(f"Error emitting service status: {e}") + +def _emit_watchlist_count_loop(): + """Background thread that pushes watchlist count every 30 seconds.""" + while True: + socketio.sleep(30) + try: + socketio.emit('watchlist:count', _build_watchlist_count_payload()) + except Exception as e: + logger.debug(f"Error emitting watchlist count: {e}") + +def _emit_download_status_loop(): + """Background thread that pushes download batch status every 2 seconds to subscribed rooms.""" + while True: + socketio.sleep(2) + try: + live_transfers_lookup = get_cached_transfer_data() + with tasks_lock: + for batch_id, batch in download_batches.items(): + try: + status_data = _build_batch_status_data( + batch_id, batch, live_transfers_lookup + ) + socketio.emit('downloads:batch_update', { + 'batch_id': batch_id, + 'data': status_data + }, room=f'batch:{batch_id}') + except Exception as e: + logger.debug(f"Error building batch status for {batch_id}: {e}") + except Exception as e: + logger.debug(f"Error in download status emit loop: {e}") + +# --- Socket.IO event handlers --- + +@socketio.on('connect') +def handle_connect(): + logger.info("WebSocket client connected") + +@socketio.on('disconnect') +def handle_disconnect(): + logger.info("WebSocket client disconnected") + +@socketio.on('downloads:subscribe') +def handle_download_subscribe(data): + """Client subscribes to download batch updates by joining rooms.""" + batch_ids = data.get('batch_ids', []) + for bid in batch_ids: + join_room(f'batch:{bid}') + logger.debug(f"Client subscribed to batches: {batch_ids}") + +@socketio.on('downloads:unsubscribe') +def handle_download_unsubscribe(data): + """Client unsubscribes from download batch updates by leaving rooms.""" + batch_ids = data.get('batch_ids', []) + for bid in batch_ids: + leave_room(f'batch:{bid}') + logger.debug(f"Client unsubscribed from batches: {batch_ids}") + +# --- Phase 2: Dashboard emitters --- + +def _emit_system_stats_loop(): + """Background thread that pushes system stats every 10 seconds.""" + while True: + socketio.sleep(10) + try: + socketio.emit('dashboard:stats', _build_system_stats()) + except Exception as e: + logger.debug(f"Error emitting system stats: {e}") + +def _emit_activity_feed_loop(): + """Background thread that pushes activity feed every 5 seconds.""" + while True: + socketio.sleep(5) + try: + with activity_feed_lock: + activities = activity_feed[-10:][::-1] + socketio.emit('dashboard:activity', {'activities': activities}) + except Exception as e: + logger.debug(f"Error emitting activity feed: {e}") + +def _emit_db_stats_loop(): + """Background thread that pushes database stats every 30 seconds.""" + while True: + socketio.sleep(30) + try: + db = get_database() + stats = db.get_database_info_for_server() + socketio.emit('dashboard:db_stats', stats) + except Exception as e: + logger.debug(f"Error emitting db stats: {e}") + +def _emit_wishlist_count_loop(): + """Background thread that pushes wishlist count every 30 seconds.""" + while True: + socketio.sleep(30) + try: + from core.wishlist_service import get_wishlist_service + count = get_wishlist_service().get_wishlist_count() + socketio.emit('dashboard:wishlist_count', {'count': count}) + except Exception as e: + logger.debug(f"Error emitting wishlist count: {e}") + +# Note: Toasts are NOT on a timer — they emit instantly from add_activity_item() + +# --- Phase 3: Enrichment sidebar worker emitters --- + +def _emit_enrichment_status_loop(): + """Background thread that pushes all enrichment worker statuses every 2 seconds.""" + workers = { + 'musicbrainz': lambda: mb_worker, + 'audiodb': lambda: audiodb_worker, + 'deezer': lambda: deezer_worker, + 'spotify-enrichment': lambda: spotify_enrichment_worker, + 'itunes-enrichment': lambda: itunes_enrichment_worker, + 'hydrabase': lambda: hydrabase_worker, + 'repair': lambda: repair_worker, + } + while True: + socketio.sleep(2) + for name, get_worker in workers.items(): + try: + worker = get_worker() + if worker is None: + continue + status = worker.get_stats() + socketio.emit(f'enrichment:{name}', status) + except Exception as e: + logger.debug(f"Error emitting {name} status: {e}") + +def _emit_tool_progress_loop(): + """Background thread that pushes all tool progress statuses every 1 second.""" + while True: + socketio.sleep(1) + # Stream status + try: + with stream_lock: + socketio.emit('tool:stream', { + "status": stream_state["status"], + "progress": stream_state["progress"], + "track_info": stream_state["track_info"], + "error_message": stream_state["error_message"] + }) + except Exception as e: + logger.debug(f"Error emitting stream status: {e}") + # Quality Scanner + try: + with quality_scanner_lock: + socketio.emit('tool:quality-scanner', dict(quality_scanner_state)) + except Exception as e: + logger.debug(f"Error emitting quality scanner status: {e}") + # Duplicate Cleaner (add computed space_freed_mb) + try: + with duplicate_cleaner_lock: + state_copy = duplicate_cleaner_state.copy() + state_copy["space_freed_mb"] = duplicate_cleaner_state["space_freed"] / (1024 * 1024) + socketio.emit('tool:duplicate-cleaner', state_copy) + except Exception as e: + logger.debug(f"Error emitting duplicate cleaner status: {e}") + # Retag + try: + with retag_lock: + socketio.emit('tool:retag', dict(retag_state)) + except Exception as e: + logger.debug(f"Error emitting retag status: {e}") + # DB Update + try: + with db_update_lock: + socketio.emit('tool:db-update', dict(db_update_state)) + except Exception as e: + logger.debug(f"Error emitting db update status: {e}") + # Metadata Update (match HTTP wrapper: {success, status}) + try: + state_copy = metadata_update_state.copy() + if state_copy.get('started_at'): + state_copy['started_at'] = state_copy['started_at'].isoformat() + if state_copy.get('completed_at'): + state_copy['completed_at'] = state_copy['completed_at'].isoformat() + socketio.emit('tool:metadata', {"success": True, "status": state_copy}) + except Exception as e: + logger.debug(f"Error emitting metadata status: {e}") + # Logs (format activity_feed same as HTTP endpoint) + try: + with activity_feed_lock: + recent = activity_feed[-50:][::-1] + formatted = [] + for a in recent: + ts = a.get('time', 'Unknown') + icon = a.get('icon', '•') + title = a.get('title', 'Activity') + sub = a.get('subtitle', '') + formatted.append(f"[{ts}] {icon} {title} - {sub}" if sub else f"[{ts}] {icon} {title}") + if not formatted: + formatted = ["No recent activity.", "Sync and download operations..."] + socketio.emit('tool:logs', {'logs': formatted}) + except Exception as e: + logger.debug(f"Error emitting logs: {e}") + +@socketio.on('sync:subscribe') +def handle_sync_subscribe(data): + for pid in data.get('playlist_ids', []): + join_room(f'sync:{pid}') + +@socketio.on('sync:unsubscribe') +def handle_sync_unsubscribe(data): + for pid in data.get('playlist_ids', []): + leave_room(f'sync:{pid}') + +@socketio.on('discovery:subscribe') +def handle_discovery_subscribe(data): + for pid in data.get('ids', []): + join_room(f'discovery:{pid}') + +@socketio.on('discovery:unsubscribe') +def handle_discovery_unsubscribe(data): + for pid in data.get('ids', []): + leave_room(f'discovery:{pid}') + +def _emit_sync_progress_loop(): + """Push sync progress to subscribed rooms every 1 second.""" + while True: + socketio.sleep(1) + try: + with sync_lock: + for pid, state in list(sync_states.items()): + try: + socketio.emit('sync:progress', { + 'playlist_id': pid, **state + }, room=f'sync:{pid}') + except Exception: + pass + except Exception as e: + logger.debug(f"Error in sync progress loop: {e}") + +def _emit_discovery_progress_loop(): + """Push discovery progress to subscribed rooms every 1 second.""" + platform_states = { + 'tidal': lambda: tidal_discovery_states, + 'youtube': lambda: youtube_playlist_states, + 'beatport': lambda: beatport_chart_states, + 'listenbrainz': lambda: listenbrainz_playlist_states, + } + while True: + socketio.sleep(1) + for platform, get_states in platform_states.items(): + try: + states_dict = get_states() + for pid, state in list(states_dict.items()): + try: + phase = state.get('phase', '') + if phase in ('', 'idle'): + continue + payload = { + 'platform': platform, + 'id': pid, + 'phase': state.get('phase'), + 'status': state.get('status', 'unknown'), + 'progress': state.get('discovery_progress', 0), + 'discovery_progress': state.get('discovery_progress', {}), + 'spotify_matches': state.get('spotify_matches', 0), + 'spotify_total': state.get('spotify_total', 0), + 'results': state.get('discovery_results', state.get('results', [])), + 'complete': state.get('phase') == 'discovered', + } + socketio.emit('discovery:progress', payload, room=f'discovery:{pid}') + except Exception: + pass + except Exception as e: + logger.debug(f"Error in {platform} discovery loop: {e}") + +def _emit_scan_status_loop(): + """Push watchlist and media scan status every 2 seconds.""" + while True: + socketio.sleep(2) + # Watchlist scan + try: + state = watchlist_scan_state.copy() + if state.get('started_at'): + state['started_at'] = state['started_at'].isoformat() + if state.get('completed_at'): + state['completed_at'] = state['completed_at'].isoformat() + state.pop('results', None) + socketio.emit('scan:watchlist', {"success": True, **state}) + except Exception as e: + logger.debug(f"Error emitting watchlist scan: {e}") + # Media scan + try: + if web_scan_manager: + scan_status = web_scan_manager.get_scan_status() + socketio.emit('scan:media', {"success": True, "status": scan_status}) + except Exception as e: + logger.debug(f"Error emitting media scan: {e}") + # Wishlist stats (auto-processing detection + countdown refresh) + try: + next_run = 0 + with wishlist_timer_lock: + if wishlist_next_run_time > 0: + next_run = max(0, int(wishlist_next_run_time - time.time())) + socketio.emit('wishlist:stats', { + "is_auto_processing": is_wishlist_actually_processing(), + "next_run_in_seconds": next_run, + }) + except Exception as e: + logger.debug(f"Error emitting wishlist stats: {e}") + +# ================================================================================================ +# END WEBSOCKET HANDLERS +# ================================================================================================ + + if __name__ == '__main__': # Initialize logging for web server from utils.logging_config import setup_logging @@ -29694,4 +30067,25 @@ if __name__ == '__main__': # Add a test activity to verify the system is working add_activity_item("🔧", "Debug Test", "Activity feed system test", "Now") - app.run(host='0.0.0.0', port=8008, debug=False) + # Start WebSocket background emitters + print("🔧 Starting WebSocket background emitters...") + # Phase 1: Global pollers + socketio.start_background_task(_emit_service_status_loop) + socketio.start_background_task(_emit_watchlist_count_loop) + socketio.start_background_task(_emit_download_status_loop) + # Phase 2: Dashboard pollers + socketio.start_background_task(_emit_system_stats_loop) + socketio.start_background_task(_emit_activity_feed_loop) + socketio.start_background_task(_emit_db_stats_loop) + socketio.start_background_task(_emit_wishlist_count_loop) + # Phase 3: Enrichment sidebar workers + socketio.start_background_task(_emit_enrichment_status_loop) + # Phase 4: Tool progress pollers + socketio.start_background_task(_emit_tool_progress_loop) + # Phase 5: Sync/discovery progress + scans + socketio.start_background_task(_emit_sync_progress_loop) + socketio.start_background_task(_emit_discovery_progress_loop) + socketio.start_background_task(_emit_scan_status_loop) + print("✅ WebSocket emitters started (Phase 1-5: global/dashboard/enrichment/tools/sync)") + + socketio.run(app, host='0.0.0.0', port=8008, debug=False, allow_unsafe_werkzeug=True) diff --git a/webui/index.html b/webui/index.html index a58c900c..76abc36f 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4358,6 +4358,7 @@ + diff --git a/webui/static/script.js b/webui/static/script.js index c7345a1f..f424f8e6 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -38,6 +38,12 @@ let watchlistCountdownInterval = null; // Countdown timer for watchlist overvie let spotifyPlaylists = []; let selectedPlaylists = new Set(); let activeSyncPollers = {}; // Key: playlist_id, Value: intervalId +// Phase 5: WebSocket sync/discovery/scan state +let _syncProgressCallbacks = {}; +let _discoveryProgressCallbacks = {}; +let _lastWatchlistScanStatus = null; +let _lastMediaScanStatus = null; +let _lastWishlistStats = null; let playlistTrackCache = {}; // Key: playlist_id, Value: tracks array let spotifyPlaylistsLoaded = false; let activeDownloadProcesses = {}; @@ -116,6 +122,209 @@ function observeLazyBackgrounds(container) { elements.forEach(el => lazyBgObserver.observe(el)); } +// =============================== +// WEBSOCKET CONNECTION MANAGER +// =============================== +let socket = null; +let socketConnected = false; + +function initializeWebSocket() { + if (typeof io === 'undefined') { + console.warn('Socket.IO client not loaded — falling back to HTTP polling'); + return; + } + + socket = io({ + transports: ['websocket', 'polling'], + reconnection: true, + reconnectionAttempts: Infinity, + reconnectionDelay: 1000, + reconnectionDelayMax: 10000, + timeout: 20000 + }); + + socket.on('connect', () => { + console.log('WebSocket connected'); + socketConnected = true; + resubscribeDownloadBatches(); + }); + + socket.on('disconnect', (reason) => { + console.warn('WebSocket disconnected:', reason); + socketConnected = false; + }); + + socket.on('reconnect', (attemptNumber) => { + console.log(`WebSocket reconnected after ${attemptNumber} attempts`); + // Phase 1: Full state refresh on reconnect + fetchAndUpdateServiceStatus(); + updateWatchlistButtonCount(); + resubscribeDownloadBatches(); + // Phase 2: Refresh dashboard data if on dashboard page + if (currentPage === 'dashboard') { + fetchAndUpdateSystemStats(); + fetchAndUpdateActivityFeed(); + fetchAndUpdateDbStats(); + updateWishlistCount(); + } + }); + + // Phase 1 event listeners + socket.on('status:update', handleServiceStatusUpdate); + socket.on('watchlist:count', handleWatchlistCountUpdate); + socket.on('downloads:batch_update', handleDownloadBatchUpdate); + + // Phase 2 event listeners (dashboard pollers) + socket.on('dashboard:stats', handleDashboardStats); + socket.on('dashboard:activity', handleDashboardActivity); + socket.on('dashboard:toast', handleDashboardToast); + socket.on('dashboard:db_stats', handleDashboardDbStats); + socket.on('dashboard:wishlist_count', handleDashboardWishlistCount); + + // Phase 3 event listeners (enrichment sidebar workers) + socket.on('enrichment:musicbrainz', (data) => updateMusicBrainzStatusFromData(data)); + socket.on('enrichment:audiodb', (data) => updateAudioDBStatusFromData(data)); + socket.on('enrichment:deezer', (data) => updateDeezerStatusFromData(data)); + socket.on('enrichment:spotify-enrichment', (data) => updateSpotifyEnrichmentStatusFromData(data)); + socket.on('enrichment:itunes-enrichment', (data) => updateiTunesEnrichmentStatusFromData(data)); + socket.on('enrichment:hydrabase', (data) => updateHydrabaseStatusFromData(data)); + socket.on('enrichment:repair', (data) => updateRepairStatusFromData(data)); + + // Phase 4 event listeners (tool progress) + socket.on('tool:stream', (data) => updateStreamStatusFromData(data)); + socket.on('tool:quality-scanner', (data) => updateQualityScanProgressFromData(data)); + socket.on('tool:duplicate-cleaner', (data) => updateDuplicateCleanProgressFromData(data)); + socket.on('tool:retag', (data) => updateRetagStatusFromData(data)); + socket.on('tool:db-update', (data) => updateDbProgressFromData(data)); + socket.on('tool:metadata', (data) => updateMetadataStatusFromData(data)); + socket.on('tool:logs', (data) => updateLogsFromData(data)); + + // Phase 5 event listeners (sync/discovery progress + scans) + socket.on('sync:progress', (data) => updateSyncProgressFromData(data)); + socket.on('discovery:progress', (data) => updateDiscoveryProgressFromData(data)); + socket.on('scan:watchlist', (data) => updateWatchlistScanFromData(data)); + socket.on('scan:media', (data) => updateMediaScanFromData(data)); + socket.on('wishlist:stats', (data) => updateWishlistStatsFromData(data)); +} + +function handleServiceStatusUpdate(data) { + // Same logic as fetchAndUpdateServiceStatus response handler + updateServiceStatus('spotify', data.spotify); + updateServiceStatus('media-server', data.media_server); + updateServiceStatus('soulseek', data.soulseek); + + updateSidebarServiceStatus('spotify', data.spotify); + updateSidebarServiceStatus('media-server', data.media_server); + updateSidebarServiceStatus('soulseek', data.soulseek); +} + +function handleWatchlistCountUpdate(data) { + if (data.success) { + const watchlistButton = document.getElementById('watchlist-button'); + if (watchlistButton) { + const countdownText = data.next_run_in_seconds ? formatCountdownTime(data.next_run_in_seconds) : ''; + watchlistButton.textContent = `\u{1F441}\uFE0F Watchlist (${data.count})`; + if (countdownText) { + watchlistButton.title = `Next auto-scan in ${countdownText}`; + } + } + } +} + +function handleDownloadBatchUpdate(payload) { + const { batch_id, data } = payload; + // Find which playlistId maps to this batch_id + for (const [playlistId, process] of Object.entries(activeDownloadProcesses)) { + if (process.batchId === batch_id) { + processModalStatusUpdate(playlistId, data); + break; + } + } +} + +function resubscribeDownloadBatches() { + if (!socket || !socketConnected) return; + const activeBatchIds = []; + Object.entries(activeDownloadProcesses).forEach(([playlistId, process]) => { + if (process.batchId && process.status === 'running') { + activeBatchIds.push(process.batchId); + } + }); + if (activeBatchIds.length > 0) { + socket.emit('downloads:subscribe', { batch_ids: activeBatchIds }); + console.log(`WebSocket subscribed to ${activeBatchIds.length} download batches`); + } +} + +function subscribeToDownloadBatch(batchId) { + if (socket && socketConnected && batchId) { + socket.emit('downloads:subscribe', { batch_ids: [batchId] }); + } +} + +function unsubscribeFromDownloadBatch(batchId) { + if (socket && socketConnected && batchId) { + socket.emit('downloads:unsubscribe', { batch_ids: [batchId] }); + } +} + +// --- Phase 2: Dashboard event handlers --- + +function handleDashboardStats(data) { + // Same logic as fetchAndUpdateSystemStats response handler + updateStatCard('active-downloads-card', data.active_downloads, 'Currently downloading'); + updateStatCard('finished-downloads-card', data.finished_downloads, 'Completed this session'); + updateStatCard('download-speed-card', data.download_speed, 'Combined speed'); + updateStatCard('active-syncs-card', data.active_syncs, 'Playlists syncing'); + updateStatCard('uptime-card', data.uptime, 'Application runtime'); + updateStatCard('memory-card', data.memory_usage, 'Current usage'); +} + +function handleDashboardActivity(data) { + // Same logic as fetchAndUpdateActivityFeed response handler + updateActivityFeed(data.activities || []); +} + +function handleDashboardToast(activity) { + // Same logic as checkForActivityToasts response handler + let toastType = 'info'; + if (activity.icon === '\u2705' || activity.title.includes('Complete')) { + toastType = 'success'; + } else if (activity.icon === '\u274C' || activity.title.includes('Failed') || activity.title.includes('Error')) { + toastType = 'error'; + } else if (activity.icon === '\uD83D\uDEAB' || activity.title.includes('Cancelled')) { + toastType = 'warning'; + } + showToast(`${activity.title}: ${activity.subtitle}`, toastType); +} + +function handleDashboardDbStats(stats) { + // Same logic as fetchAndUpdateDbStats response handler + updateDashboardStatCards(stats); + updateDbUpdaterCardInfo(stats); +} + +function handleDashboardWishlistCount(data) { + // Same logic as updateWishlistCount response handler + const count = data.count || 0; + const wishlistButton = document.getElementById('wishlist-button'); + if (wishlistButton) { + wishlistButton.textContent = `\uD83C\uDFB5 Wishlist (${count})`; + if (count === 0) { + wishlistButton.classList.remove('wishlist-active'); + wishlistButton.classList.add('wishlist-inactive'); + } else { + wishlistButton.classList.remove('wishlist-inactive'); + wishlistButton.classList.add('wishlist-active'); + } + } + checkForAutoInitiatedWishlistProcess(); +} + +// =============================== +// END WEBSOCKET CONNECTION MANAGER +// =============================== + // --- MusicBrainz Integration Constants --- const MUSICBRAINZ_LOGO_URL = 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/MusicBrainz_Logo_%282016%29.svg/500px-MusicBrainz_Logo_%282016%29.svg.png'; const DEEZER_LOGO_URL = 'https://cdn.brandfetch.io/idEUKgCNtu/theme/dark/symbol.svg?c=1bxid64Mup7aczewSAYMX&t=1758260798610'; @@ -454,9 +663,13 @@ document.addEventListener('DOMContentLoaded', function () { } + // Initialize WebSocket connection (falls back to HTTP polling if unavailable) + initializeWebSocket(); + // Start global service status polling for sidebar (works on all pages) + // Initial fetch for immediate data, then setInterval as fallback when WebSocket is disconnected fetchAndUpdateServiceStatus(); - setInterval(fetchAndUpdateServiceStatus, 10000); // Every 10 seconds + setInterval(fetchAndUpdateServiceStatus, 10000); // Every 10 seconds (no-op when WebSocket active) // Refresh key data immediately when user returns to this tab document.addEventListener('visibilitychange', () => { @@ -1134,7 +1347,73 @@ function stopStreamStatusPolling() { } } +// Phase 4: Track last known tool statuses to prevent repeated toasts on terminal states +let _lastToolStatus = {}; + +// Phase 5: Sync/Discovery/Scan WebSocket router functions +function updateSyncProgressFromData(data) { + const pid = data.playlist_id; + const callback = _syncProgressCallbacks[pid]; + if (callback) callback(data); +} + +function updateDiscoveryProgressFromData(data) { + const id = data.id; + const callback = _discoveryProgressCallbacks[id]; + if (callback) callback(data); +} + +function updateWatchlistScanFromData(data) { + if (!data.success) return; + if (_lastWatchlistScanStatus === data.status && data.status !== 'scanning') return; + _lastWatchlistScanStatus = data.status; + handleWatchlistScanData(data); +} + +function updateMediaScanFromData(data) { + if (!data.success || !data.status) return; + const status = data.status; + const statusKey = status.is_scanning ? 'scanning' : (status.status || 'unknown'); + if (_lastMediaScanStatus === statusKey && statusKey !== 'scanning') return; + _lastMediaScanStatus = statusKey; + + const phaseLabel = document.getElementById('media-scan-phase-label'); + const progressLabel = document.getElementById('media-scan-progress-label'); + const button = document.getElementById('media-scan-btn'); + const progressBar = document.getElementById('media-scan-progress-bar'); + const statusValue = document.getElementById('media-scan-status'); + + if (status.is_scanning) { + if (phaseLabel) phaseLabel.textContent = 'Media server scanning...'; + if (progressLabel) progressLabel.textContent = status.progress_message || 'Scan in progress'; + } else if (status.status === 'idle') { + if (button) button.disabled = false; + if (phaseLabel) phaseLabel.textContent = 'Scan completed successfully'; + if (progressBar) progressBar.style.width = '0%'; + if (progressLabel) progressLabel.textContent = 'Ready for next scan'; + if (statusValue) { + statusValue.textContent = 'Idle'; + statusValue.style.color = '#b3b3b3'; + } + showToast('✅ Media scan completed', 'success', 3000); + } +} + +function updateWishlistStatsFromData(data) { + // Auto-processing detection: close modal and notify + if (data.is_auto_processing) { + if (typeof closeWishlistOverviewModal === 'function') { + closeWishlistOverviewModal(); + showToast('Wishlist auto-processing started. View progress in Download Manager.', 'info'); + } + return; + } + // Store latest stats for countdown timer refresh + _lastWishlistStats = data; +} + async function updateStreamStatus() { + if (socketConnected) return; // WebSocket handles this // Poll server for streaming progress and handle state changes with enhanced error recovery try { const controller = new AbortController(); @@ -1229,6 +1508,51 @@ async function updateStreamStatus() { } } +function updateStreamStatusFromData(data) { + const prev = _lastToolStatus['stream']; + _lastToolStatus['stream'] = data.status; + // Skip repeated terminal states to avoid duplicate toasts/actions + if (prev !== undefined && data.status === prev && data.status !== 'loading' && data.status !== 'queued') return; + + currentStream.status = data.status; + currentStream.progress = data.progress; + + switch (data.status) { + case 'loading': + setLoadingProgress(data.progress); + const loadingText = document.querySelector('.loading-text'); + if (loadingText && data.progress > 0) { + loadingText.textContent = `Downloading... ${Math.round(data.progress)}%`; + } + break; + case 'queued': + const queueText = document.querySelector('.loading-text'); + if (queueText) { + queueText.textContent = 'Queuing with uploader...'; + } + setLoadingProgress(0); + break; + case 'ready': + console.log('🎵 Stream ready, starting audio playback'); + stopStreamStatusPolling(); + startAudioPlayback(); + break; + case 'error': + console.error('❌ Streaming error:', data.error_message); + stopStreamStatusPolling(); + hideLoadingAnimation(); + showToast(`Streaming error: ${data.error_message || 'Unknown error'}`, 'error'); + clearTrack(); + break; + case 'stopped': + console.log('🛑 Stream stopped'); + stopStreamStatusPolling(); + hideLoadingAnimation(); + clearTrack(); + break; + } +} + async function startAudioPlayback() { // Start HTML5 audio playback of the streamed file with enhanced state management try { @@ -4422,6 +4746,7 @@ async function rehydrateArtistAlbumModal(virtualPlaylistId, playlistName, batchI if (process) { process.status = 'running'; process.batchId = batchId; + subscribeToDownloadBatch(batchId); // Update button states to reflect running status const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); @@ -4506,6 +4831,7 @@ async function rehydrateDiscoverPlaylistModal(virtualPlaylistId, playlistName, b if (process) { process.status = 'running'; process.batchId = batchId; + subscribeToDownloadBatch(batchId); const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); if (beginBtn) beginBtn.style.display = 'none'; @@ -4607,6 +4933,7 @@ async function rehydrateDiscoverPlaylistModal(virtualPlaylistId, playlistName, b if (process) { process.status = 'running'; process.batchId = batchId; + subscribeToDownloadBatch(batchId); // Update button states to reflect running status const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); @@ -4702,6 +5029,7 @@ async function rehydrateEnhancedSearchModal(virtualPlaylistId, playlistName, bat if (process) { process.status = 'running'; process.batchId = batchId; + subscribeToDownloadBatch(batchId); const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); @@ -4753,6 +5081,7 @@ async function rehydrateEnhancedSearchModal(virtualPlaylistId, playlistName, bat if (process) { process.status = 'running'; process.batchId = batchId; + subscribeToDownloadBatch(batchId); const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); @@ -7419,6 +7748,23 @@ function startWishlistCountdownTimer(currentCycle, initialSeconds) { // Check if auto-processing has started (every 2 seconds to avoid overwhelming backend) if (remainingSeconds % 2 === 0 || remainingSeconds <= 0) { + // Use WebSocket data if available, otherwise fall back to HTTP + if (socketConnected && _lastWishlistStats) { + const data = _lastWishlistStats; + if (data.is_auto_processing) { + closeWishlistOverviewModal(); + showToast('Wishlist auto-processing started. View progress in Download Manager.', 'info'); + return; + } + if (remainingSeconds <= 0) { + remainingSeconds = data.next_run_in_seconds || 0; + const timerElement = document.getElementById('wishlist-next-auto-timer'); + if (timerElement) { + const countdownText = formatCountdownTime(remainingSeconds); + timerElement.textContent = `Next Auto: ${nextCycleText}${countdownText ? ' in ' + countdownText : ''}`; + } + } + } else { try { const response = await fetch('/api/wishlist/stats'); const data = await response.json(); @@ -7448,6 +7794,7 @@ function startWishlistCountdownTimer(currentCycle, initialSeconds) { } catch (error) { console.debug('Error updating wishlist countdown:', error); } + } // end else (HTTP fallback) } // Always update the display countdown @@ -8673,6 +9020,10 @@ let globalPollingFailureCount = 0; // Track consecutive failures for exponential let globalPollingBaseInterval = 2000; // Base polling interval in ms - MATCHES sync.py exactly function startGlobalDownloadPolling() { + if (socketConnected) { + console.debug('🔄 [Global Polling] WebSocket active, skipping HTTP polling'); + return; // WebSocket handles download updates via room subscriptions + } if (globalDownloadStatusPoller) { console.debug('🔄 [Global Polling] Already running, skipping start'); return; // Prevent duplicate pollers @@ -9971,9 +10322,26 @@ function startSyncPolling(playlistId) { clearInterval(activeSyncPollers[playlistId]); } + // Phase 5: Subscribe via WebSocket + if (socketConnected) { + socket.emit('sync:subscribe', { playlist_ids: [playlistId] }); + _syncProgressCallbacks[playlistId] = (data) => { + if (data.status === 'syncing') { + const progress = data.progress; + updateCardToSyncing(playlistId, progress.progress, progress); + updateModalSyncProgress(playlistId, progress); + } else if (data.status === 'finished' || data.status === 'error' || data.status === 'cancelled') { + stopSyncPolling(playlistId); + updateCardToDefault(playlistId, data); + closePlaylistDetailsModal(); + } + }; + } + // Start a new poller that checks every 2 seconds console.log(`🔄 Starting sync polling for playlist: ${playlistId}`); activeSyncPollers[playlistId] = setInterval(async () => { + if (socketConnected) return; // Phase 5: WS handles updates try { console.log(`📊 Polling sync status for: ${playlistId}`); const response = await fetch(`/api/sync/status/${playlistId}`); @@ -10011,6 +10379,11 @@ function stopSyncPolling(playlistId) { clearInterval(activeSyncPollers[playlistId]); delete activeSyncPollers[playlistId]; } + // Phase 5: Unsubscribe and clean up callback + if (_syncProgressCallbacks[playlistId]) { + if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [playlistId] }); + delete _syncProgressCallbacks[playlistId]; + } updateRefreshButtonState(); } @@ -13945,6 +14318,7 @@ async function handleQualityScanButtonClick() { } async function checkAndUpdateQualityScanProgress() { + if (socketConnected) return; // WebSocket handles this try { const response = await fetch('/api/quality-scanner/status', { signal: AbortSignal.timeout(10000) // 10 second timeout @@ -13967,6 +14341,13 @@ async function checkAndUpdateQualityScanProgress() { } } +function updateQualityScanProgressFromData(data) { + const prev = _lastToolStatus['quality-scanner']; + _lastToolStatus['quality-scanner'] = data.status; + if (prev !== undefined && data.status === prev && data.status !== 'running') return; + updateQualityScanProgressUI(data); +} + function updateQualityScanProgressUI(state) { const button = document.getElementById('quality-scan-button'); const phaseLabel = document.getElementById('quality-phase-label'); @@ -14073,6 +14454,7 @@ async function handleDuplicateCleanButtonClick() { } async function checkAndUpdateDuplicateCleanProgress() { + if (socketConnected) return; // WebSocket handles this try { const response = await fetch('/api/duplicate-cleaner/status', { signal: AbortSignal.timeout(10000) // 10 second timeout @@ -14095,6 +14477,13 @@ async function checkAndUpdateDuplicateCleanProgress() { } } +function updateDuplicateCleanProgressFromData(data) { + const prev = _lastToolStatus['duplicate-cleaner']; + _lastToolStatus['duplicate-cleaner'] = data.status; + if (prev !== undefined && data.status === prev && data.status !== 'running') return; + updateDuplicateCleanProgressUI(data); +} + function updateDuplicateCleanProgressUI(state) { const button = document.getElementById('duplicate-clean-button'); const phaseLabel = document.getElementById('duplicate-phase-label'); @@ -14900,6 +15289,7 @@ function startRetagPolling() { } async function checkRetagStatus() { + if (socketConnected) return; // WebSocket handles this try { const response = await fetch('/api/retag/status'); const state = await response.json(); @@ -14924,6 +15314,22 @@ async function checkRetagStatus() { } } +function updateRetagStatusFromData(data) { + const prev = _lastToolStatus['retag']; + _lastToolStatus['retag'] = data.status; + if (prev !== undefined && data.status === prev && data.status !== 'running') return; + updateRetagProgressUI(data); + // Handle terminal state toasts (only on transition) + if (prev === 'running' || prev === undefined) { + if (data.status === 'finished') { + showToast('Retag completed successfully', 'success'); + loadRetagStats(); + } else if (data.status === 'error') { + showToast(`Retag error: ${data.error_message || 'Unknown error'}`, 'error'); + } + } +} + function updateRetagProgressUI(state) { const phaseLabel = document.getElementById('retag-phase-label'); const progressBar = document.getElementById('retag-progress-bar'); @@ -15269,6 +15675,7 @@ async function loadDashboardData() { // --- Data Fetching and UI Updates --- async function fetchAndUpdateDbStats() { + if (socketConnected) return; // WebSocket handles this try { const response = await fetch('/api/database/stats'); if (!response.ok) return; @@ -15443,6 +15850,7 @@ function updateDbUpdaterCardInfo(stats) { // --- Wishlist Count Functions --- async function updateWishlistCount() { + if (socketConnected) return; // WebSocket handles this try { const response = await fetch('/api/wishlist/count'); if (!response.ok) return; @@ -15519,6 +15927,7 @@ async function checkForAutoInitiatedWishlistProcess() { } async function checkAndUpdateDbProgress() { + if (socketConnected) return; // WebSocket handles this try { const response = await fetch('/api/database/update/status', { signal: AbortSignal.timeout(10000) // 10 second timeout @@ -15541,6 +15950,13 @@ async function checkAndUpdateDbProgress() { } } +function updateDbProgressFromData(data) { + const prev = _lastToolStatus['db-update']; + _lastToolStatus['db-update'] = data.status; + if (prev !== undefined && data.status === prev && data.status !== 'running') return; + updateDbProgressUI(data); +} + function updateDbProgressUI(state) { const button = document.getElementById('db-update-button'); const phaseLabel = document.getElementById('db-phase-label'); @@ -16128,7 +16544,56 @@ function startTidalDiscoveryPolling(fakeUrlHash, playlistId) { clearInterval(activeYouTubePollers[fakeUrlHash]); } + // Phase 5: Subscribe via WebSocket + if (socketConnected) { + socket.emit('discovery:subscribe', { ids: [playlistId] }); + _discoveryProgressCallbacks[playlistId] = (data) => { + if (data.error) { + if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; } + socket.emit('discovery:unsubscribe', { ids: [playlistId] }); delete _discoveryProgressCallbacks[playlistId]; + return; + } + // Transform to YouTube modal format + const transformed = { + progress: data.progress, spotify_matches: data.spotify_matches, spotify_total: data.spotify_total, + results: (data.results || []).map((r, i) => { + const isFound = r.status === 'found' || r.status === '✅ Found' || r.status_class === 'found' || r.spotify_data || r.spotify_track; + return { + index: i, yt_track: r.tidal_track ? r.tidal_track.name : 'Unknown', + yt_artist: r.tidal_track ? (r.tidal_track.artists ? r.tidal_track.artists.join(', ') : 'Unknown') : 'Unknown', + status: isFound ? '✅ Found' : '❌ Not Found', status_class: 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.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 + }; + }) + }; + 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 (tidalPlaylistStates[playlistId]) { + tidalPlaylistStates[playlistId].phase = data.phase; + tidalPlaylistStates[playlistId].discovery_results = data.results; + tidalPlaylistStates[playlistId].spotify_matches = data.spotify_matches; + tidalPlaylistStates[playlistId].discovery_progress = data.progress; + updateTidalCardPhase(playlistId, data.phase); + } + updateTidalCardProgress(playlistId, data); + if (data.complete) { + if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; } + socket.emit('discovery:unsubscribe', { ids: [playlistId] }); delete _discoveryProgressCallbacks[playlistId]; + } + }; + } + const pollInterval = setInterval(async () => { + if (socketConnected) return; // Phase 5: WS handles updates try { const response = await fetch(`/api/tidal/discovery/status/${playlistId}`); const status = await response.json(); @@ -16439,6 +16904,10 @@ async function startTidalPlaylistSync(urlHash) { return; } + // Capture sync_playlist_id for WebSocket subscription + const syncPlaylistId = result.sync_playlist_id; + if (state) state.syncPlaylistId = syncPlaylistId; + // Update card and modal to syncing phase updateTidalCardPhase(playlistId, 'syncing'); @@ -16446,7 +16915,7 @@ async function startTidalPlaylistSync(urlHash) { updateTidalModalButtons(urlHash, 'syncing'); // Start sync polling - startTidalSyncPolling(urlHash); + startTidalSyncPolling(urlHash, syncPlaylistId); showToast('Tidal playlist sync started!', 'success'); @@ -16456,7 +16925,7 @@ async function startTidalPlaylistSync(urlHash) { } } -function startTidalSyncPolling(urlHash) { +function startTidalSyncPolling(urlHash, syncPlaylistId) { // Stop any existing polling if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); @@ -16465,8 +16934,42 @@ function startTidalSyncPolling(urlHash) { const state = youtubePlaylistStates[urlHash]; const playlistId = state.tidal_playlist_id; - // Define the polling function + // Resolve syncPlaylistId from argument or stored state + syncPlaylistId = syncPlaylistId || (state && state.syncPlaylistId); + + // Phase 6: Subscribe via WebSocket + if (socketConnected && syncPlaylistId) { + socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] }); + _syncProgressCallbacks[syncPlaylistId] = (data) => { + const progress = data.progress || {}; + updateTidalCardSyncProgress(playlistId, progress); + updateTidalModalSyncProgress(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 (tidalPlaylistStates[playlistId]) tidalPlaylistStates[playlistId].phase = 'sync_complete'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete'; + updateTidalCardPhase(playlistId, 'sync_complete'); + updateTidalModalButtons(urlHash, 'sync_complete'); + showToast('Tidal 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 (tidalPlaylistStates[playlistId]) tidalPlaylistStates[playlistId].phase = 'discovered'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered'; + updateTidalCardPhase(playlistId, 'discovered'); + updateTidalModalButtons(urlHash, 'discovered'); + showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error'); + } + }; + } + + // Define the polling function (HTTP fallback) const pollFunction = async () => { + if (socketConnected) return; // Phase 6: WS handles updates try { const response = await fetch(`/api/tidal/sync/status/${playlistId}`); const status = await response.json(); @@ -16478,52 +16981,26 @@ function startTidalSyncPolling(urlHash) { return; } - // Update card progress with sync stats updateTidalCardSyncProgress(playlistId, status.progress); - - // Update modal sync display if open updateTidalModalSyncProgress(urlHash, status.progress); - // Check if complete if (status.complete) { clearInterval(pollInterval); delete activeYouTubePollers[urlHash]; - - // Update both states to sync_complete - if (tidalPlaylistStates[playlistId]) { - tidalPlaylistStates[playlistId].phase = 'sync_complete'; - } - if (youtubePlaylistStates[urlHash]) { - youtubePlaylistStates[urlHash].phase = 'sync_complete'; - } - - // Update card phase to sync complete + if (tidalPlaylistStates[playlistId]) tidalPlaylistStates[playlistId].phase = 'sync_complete'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete'; updateTidalCardPhase(playlistId, 'sync_complete'); - - // Update modal buttons updateTidalModalButtons(urlHash, 'sync_complete'); - - console.log('✅ Tidal sync complete:', urlHash); showToast('Tidal playlist sync complete!', 'success'); } else if (status.sync_status === 'error') { clearInterval(pollInterval); delete activeYouTubePollers[urlHash]; - - // Update both states to discovered (revert on error) - if (tidalPlaylistStates[playlistId]) { - tidalPlaylistStates[playlistId].phase = 'discovered'; - } - if (youtubePlaylistStates[urlHash]) { - youtubePlaylistStates[urlHash].phase = 'discovered'; - } - - // Revert to discovered phase on error + if (tidalPlaylistStates[playlistId]) tidalPlaylistStates[playlistId].phase = 'discovered'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered'; updateTidalCardPhase(playlistId, 'discovered'); updateTidalModalButtons(urlHash, 'discovered'); - showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error'); } - } catch (error) { console.error('❌ Error polling Tidal sync:', error); if (activeYouTubePollers[urlHash]) { @@ -16533,8 +17010,8 @@ function startTidalSyncPolling(urlHash) { } }; - // Run immediately to get current status - pollFunction(); + // Run immediately to get current status (skip if WS active) + if (!socketConnected) pollFunction(); // Then continue polling at regular intervals const pollInterval = setInterval(pollFunction, 1000); @@ -16569,6 +17046,13 @@ async function cancelTidalSync(urlHash) { delete activeYouTubePollers[urlHash]; } + // Phase 6: Clean up WS subscription + const syncId = state && state.syncPlaylistId; + if (syncId && _syncProgressCallbacks[syncId]) { + if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [syncId] }); + delete _syncProgressCallbacks[syncId]; + } + // Revert to discovered phase updateTidalCardPhase(playlistId, 'discovered'); updateTidalModalButtons(urlHash, 'discovered'); @@ -17889,7 +18373,50 @@ function startBeatportDiscoveryPolling(urlHash) { clearInterval(activeYouTubePollers[urlHash]); } + // Phase 5: Subscribe via WebSocket + if (socketConnected) { + socket.emit('discovery:subscribe', { ids: [urlHash] }); + _discoveryProgressCallbacks[urlHash] = (data) => { + if (data.error) { + if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } + socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash]; + return; + } + if (youtubePlaylistStates[urlHash]) { + const transformed = { + progress: data.progress || 0, spotify_matches: data.spotify_matches || 0, spotify_total: data.spotify_total || 0, + results: (data.results || []).map((r, i) => ({ + index: r.index !== undefined ? r.index : i, + yt_track: r.beatport_track ? r.beatport_track.title : 'Unknown', + yt_artist: r.beatport_track ? r.beatport_track.artist : 'Unknown', + status: (r.status === 'found' || r.status === '✅ Found' || r.status_class === 'found') ? '✅ Found' : (r.status === 'error' ? '❌ Error' : '❌ Not Found'), + status_class: r.status_class || ((r.status === 'found' || r.status === '✅ Found') ? 'found' : (r.status === 'error' ? 'error' : 'not-found')), + spotify_track: r.spotify_data ? r.spotify_data.name : (r.spotify_track || '-'), + spotify_artist: r.spotify_data && r.spotify_data.artists ? r.spotify_data.artists.map(a => a.name || a).join(', ') : (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 + })) + }; + const st = youtubePlaylistStates[urlHash]; + 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 || 'discovering'; + const chartHash = st.beatport_chart_hash || urlHash; + updateBeatportCardPhase(chartHash, data.phase || 'discovering'); + updateBeatportCardProgress(chartHash, { spotify_total: data.spotify_total || 0, spotify_matches: data.spotify_matches || 0, failed: (data.spotify_total || 0) - (data.spotify_matches || 0) }); + if (beatportChartStates[chartHash]) beatportChartStates[chartHash].phase = data.phase || 'discovering'; + updateYouTubeDiscoveryModal(urlHash, transformed); + } + if (data.phase === 'discovered' || data.phase === 'error') { + if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } + socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash]; + } + }; + } + const pollInterval = setInterval(async () => { + if (socketConnected) return; // Phase 5: WS handles updates try { const response = await fetch(`/api/beatport/discovery/status/${urlHash}`); const status = await response.json(); @@ -18536,13 +19063,17 @@ async function startBeatportPlaylistSync(urlHash) { return; } + // Capture sync_playlist_id for WebSocket subscription (Beatport returns sync_id) + const syncPlaylistId = result.sync_id || result.sync_playlist_id; + if (state) state.syncPlaylistId = syncPlaylistId; + // Update state to syncing state.phase = 'syncing'; updateBeatportCardPhase(state.beatport_chart_hash || urlHash, 'syncing'); // Update modal buttons and start polling updateBeatportModalButtons(urlHash, 'syncing'); - startBeatportSyncPolling(urlHash); + startBeatportSyncPolling(urlHash, syncPlaylistId); showToast('Starting Beatport playlist sync...', 'success'); @@ -18552,14 +19083,49 @@ async function startBeatportPlaylistSync(urlHash) { } } -function startBeatportSyncPolling(urlHash) { +function startBeatportSyncPolling(urlHash, syncPlaylistId) { // Stop any existing polling (reuse activeYouTubePollers for Beatport) if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); } - // Define the polling function + // Resolve syncPlaylistId from argument or stored state + const bpState = youtubePlaylistStates[urlHash]; + syncPlaylistId = syncPlaylistId || (bpState && bpState.syncPlaylistId); + + // Phase 6: Subscribe via WebSocket + if (socketConnected && syncPlaylistId) { + socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] }); + _syncProgressCallbacks[syncPlaylistId] = (data) => { + const progress = data.progress || {}; + updateBeatportModalSyncProgress(urlHash, progress); + + if (data.status === 'finished' || 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]; + + const state = youtubePlaylistStates[urlHash]; + if (state) { + const chartHash = state.beatport_chart_hash || urlHash; + if (data.status === 'finished') { + state.phase = 'sync_complete'; + updateBeatportCardPhase(chartHash, 'sync_complete'); + updateBeatportModalButtons(urlHash, 'sync_complete'); + if (beatportChartStates[chartHash]) beatportChartStates[chartHash].phase = 'sync_complete'; + } else { + state.phase = 'discovered'; + updateBeatportCardPhase(chartHash, 'discovered'); + if (beatportChartStates[chartHash]) beatportChartStates[chartHash].phase = 'discovered'; + } + } + } + }; + } + + // Define the polling function (HTTP fallback) const pollFunction = async () => { + if (socketConnected) return; // Phase 6: WS handles updates try { const response = await fetch(`/api/beatport/sync/status/${urlHash}`); const status = await response.json(); @@ -18567,18 +19133,13 @@ function startBeatportSyncPolling(urlHash) { if (status.error) { console.error('❌ Error polling Beatport sync:', status.error); clearInterval(pollInterval); - delete activeTidalPollers[urlHash]; + delete activeYouTubePollers[urlHash]; return; } - // Update modal with sync progress updateBeatportModalSyncProgress(urlHash, status.progress); - // Stop polling when sync is complete if (status.complete || status.status === 'error') { - console.log(`✅ Beatport sync polling complete for: ${urlHash}`); - - // Update final state const state = youtubePlaylistStates[urlHash]; if (state) { const chartHash = state.beatport_chart_hash || urlHash; @@ -18587,28 +19148,16 @@ function startBeatportSyncPolling(urlHash) { state.convertedSpotifyPlaylistId = status.converted_spotify_playlist_id; updateBeatportCardPhase(chartHash, 'sync_complete'); updateBeatportModalButtons(urlHash, 'sync_complete'); - - // Sync with backend Beatport chart state - if (beatportChartStates[chartHash]) { - beatportChartStates[chartHash].phase = 'sync_complete'; - } - - console.log('✅ Beatport sync complete:', urlHash); + if (beatportChartStates[chartHash]) beatportChartStates[chartHash].phase = 'sync_complete'; } else { - state.phase = 'discovered'; // Revert on error + state.phase = 'discovered'; updateBeatportCardPhase(chartHash, 'discovered'); - - // Sync with backend Beatport chart state - if (beatportChartStates[chartHash]) { - beatportChartStates[chartHash].phase = 'discovered'; - } + if (beatportChartStates[chartHash]) beatportChartStates[chartHash].phase = 'discovered'; } } - clearInterval(pollInterval); delete activeYouTubePollers[urlHash]; } - } catch (error) { console.error('❌ Error polling Beatport sync:', error); if (activeYouTubePollers[urlHash]) { @@ -18618,11 +19167,11 @@ function startBeatportSyncPolling(urlHash) { } }; - // Run immediately to get current status - pollFunction(); + // Run immediately to get current status (skip if WS active) + if (!socketConnected) pollFunction(); // Then continue polling at regular intervals - const pollInterval = setInterval(pollFunction, 2000); // Poll every 2 seconds + const pollInterval = setInterval(pollFunction, 2000); activeYouTubePollers[urlHash] = pollInterval; } @@ -18653,6 +19202,13 @@ async function cancelBeatportSync(urlHash) { delete activeYouTubePollers[urlHash]; } + // Phase 6: Clean up WS subscription + const bpSyncId = state && state.syncPlaylistId; + if (bpSyncId && _syncProgressCallbacks[bpSyncId]) { + if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [bpSyncId] }); + delete _syncProgressCallbacks[bpSyncId]; + } + // Revert to discovered phase const chartHash = state.beatport_chart_hash || urlHash; state.phase = 'discovered'; @@ -20338,7 +20894,31 @@ function startYouTubeDiscoveryPolling(urlHash) { clearInterval(activeYouTubePollers[urlHash]); } + // Phase 5: Subscribe via WebSocket + if (socketConnected) { + socket.emit('discovery:subscribe', { ids: [urlHash] }); + _discoveryProgressCallbacks[urlHash] = (data) => { + if (data.error) { + if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } + socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash]; + return; + } + updateYouTubeCardProgress(urlHash, data); + const st = youtubePlaylistStates[urlHash]; + if (st) { st.discoveryResults = data.results || []; st.discoveryProgress = data.progress || 0; st.spotifyMatches = data.spotify_matches || 0; } + updateYouTubeDiscoveryModal(urlHash, data); + if (data.complete) { + if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } + socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash]; + updateYouTubeCardPhase(urlHash, 'discovered'); + updateYouTubeModalButtons(urlHash, 'discovered'); + showToast('YouTube discovery complete!', 'success'); + } + }; + } + const pollInterval = setInterval(async () => { + if (socketConnected) return; // Phase 5: WS handles updates try { const response = await fetch(`/api/youtube/discovery/status/${urlHash}`); const status = await response.json(); @@ -21103,6 +21683,11 @@ async function startYouTubePlaylistSync(urlHash) { return; } + // Capture sync_playlist_id for WebSocket subscription + const syncPlaylistId = result.sync_playlist_id; + const ytState = youtubePlaylistStates[urlHash]; + if (ytState) ytState.syncPlaylistId = syncPlaylistId; + // Update card and modal to syncing phase updateYouTubeCardPhase(urlHash, 'syncing'); @@ -21110,7 +21695,7 @@ async function startYouTubePlaylistSync(urlHash) { updateYouTubeModalButtons(urlHash, 'syncing'); // Start sync polling - startYouTubeSyncPolling(urlHash); + startYouTubeSyncPolling(urlHash, syncPlaylistId); showToast('YouTube playlist sync started!', 'success'); @@ -21120,14 +21705,45 @@ async function startYouTubePlaylistSync(urlHash) { } } -function startYouTubeSyncPolling(urlHash) { +function startYouTubeSyncPolling(urlHash, syncPlaylistId) { // Stop any existing polling if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); } - // Define the polling function + // Resolve syncPlaylistId from argument or stored state + const ytState = youtubePlaylistStates[urlHash]; + syncPlaylistId = syncPlaylistId || (ytState && ytState.syncPlaylistId); + + // Phase 6: Subscribe via WebSocket + if (socketConnected && syncPlaylistId) { + socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] }); + _syncProgressCallbacks[syncPlaylistId] = (data) => { + const progress = data.progress || {}; + updateYouTubeCardSyncProgress(urlHash, progress); + updateYouTubeModalSyncProgress(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]; + updateYouTubeCardPhase(urlHash, 'sync_complete'); + updateYouTubeModalButtons(urlHash, 'sync_complete'); + showToast('YouTube 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]; + updateYouTubeCardPhase(urlHash, 'discovered'); + updateYouTubeModalButtons(urlHash, 'discovered'); + showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error'); + } + }; + } + + // Define the polling function (HTTP fallback) const pollFunction = async () => { + if (socketConnected) return; // Phase 6: WS handles updates try { const response = await fetch(`/api/youtube/sync/status/${urlHash}`); const status = await response.json(); @@ -21139,36 +21755,22 @@ function startYouTubeSyncPolling(urlHash) { return; } - // Update card progress with sync stats updateYouTubeCardSyncProgress(urlHash, status.progress); - - // Update modal sync display if open updateYouTubeModalSyncProgress(urlHash, status.progress); - // Check if complete if (status.complete) { clearInterval(pollInterval); delete activeYouTubePollers[urlHash]; - - // Update card phase to sync complete updateYouTubeCardPhase(urlHash, 'sync_complete'); - - // Update modal buttons updateYouTubeModalButtons(urlHash, 'sync_complete'); - - console.log('✅ YouTube sync complete:', urlHash); showToast('YouTube playlist sync complete!', 'success'); } else if (status.sync_status === 'error') { clearInterval(pollInterval); delete activeYouTubePollers[urlHash]; - - // Revert to discovered phase on error updateYouTubeCardPhase(urlHash, 'discovered'); updateYouTubeModalButtons(urlHash, 'discovered'); - showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error'); } - } catch (error) { console.error('❌ Error polling YouTube sync:', error); if (activeYouTubePollers[urlHash]) { @@ -21178,8 +21780,8 @@ function startYouTubeSyncPolling(urlHash) { } }; - // Run immediately to get current status - pollFunction(); + // Run immediately to get current status (skip if WS active) + if (!socketConnected) pollFunction(); // Then continue polling at regular intervals const pollInterval = setInterval(pollFunction, 1000); @@ -21207,6 +21809,14 @@ async function cancelYouTubeSync(urlHash) { delete activeYouTubePollers[urlHash]; } + // Phase 6: Clean up WS subscription + const ytCancelState = youtubePlaylistStates[urlHash]; + const ytSyncId = ytCancelState && ytCancelState.syncPlaylistId; + if (ytSyncId && _syncProgressCallbacks[ytSyncId]) { + if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [ytSyncId] }); + delete _syncProgressCallbacks[ytSyncId]; + } + // Revert to discovered phase updateYouTubeCardPhase(urlHash, 'discovered'); updateYouTubeModalButtons(urlHash, 'discovered'); @@ -21509,7 +22119,53 @@ function startListenBrainzDiscoveryPolling(playlistMbid) { clearInterval(activeYouTubePollers[playlistMbid]); } + // Phase 5: Subscribe via WebSocket + if (socketConnected) { + socket.emit('discovery:subscribe', { ids: [playlistMbid] }); + _discoveryProgressCallbacks[playlistMbid] = (data) => { + if (data.error) { + if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; } + socket.emit('discovery:unsubscribe', { ids: [playlistMbid] }); delete _discoveryProgressCallbacks[playlistMbid]; + return; + } + if (listenbrainzPlaylistStates[playlistMbid]) { + const transformed = { + progress: data.progress || 0, spotify_matches: data.spotify_matches || 0, spotify_total: data.spotify_total || 0, + results: (data.results || []).map((r, i) => ({ + index: r.index !== undefined ? r.index : i, + yt_track: r.lb_track || r.track_name || 'Unknown', + yt_artist: r.lb_artist || r.artist_name || 'Unknown', + status: (r.status === 'found' || r.status === '✅ Found' || r.status_class === 'found') ? '✅ Found' : (r.status === 'error' ? '❌ Error' : '❌ Not Found'), + status_class: r.status_class || ((r.status === 'found' || r.status === '✅ Found') ? 'found' : (r.status === 'error' ? 'error' : 'not-found')), + spotify_track: r.spotify_data ? r.spotify_data.name : (r.spotify_track || '-'), + spotify_artist: r.spotify_data ? (r.spotify_data.artists && r.spotify_data.artists[0] ? r.spotify_data.artists[0] : '-') : (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, duration: r.duration || '0:00' + })), + complete: data.complete || data.phase === 'discovered' + }; + const st = listenbrainzPlaylistStates[playlistMbid]; + st.discovery_results = data.results || []; st.discoveryResults = transformed.results; + st.discovery_progress = data.progress || 0; st.discoveryProgress = data.progress || 0; + st.spotify_matches = data.spotify_matches || 0; st.spotifyMatches = data.spotify_matches || 0; + st.spotify_total = data.spotify_total || 0; st.spotifyTotal = data.spotify_total || 0; + updateYouTubeDiscoveryModal(playlistMbid, transformed); + } + if (data.complete || data.phase === 'discovered') { + if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; } + socket.emit('discovery:unsubscribe', { ids: [playlistMbid] }); delete _discoveryProgressCallbacks[playlistMbid]; + if (listenbrainzPlaylistStates[playlistMbid]) listenbrainzPlaylistStates[playlistMbid].phase = 'discovered'; + updateYouTubeModalButtons(playlistMbid, 'discovered'); + const playlistIdEl = `discover-lb-playlist-${playlistMbid}`; + const syncBtn = document.getElementById(`${playlistIdEl}-sync-btn`); + if (syncBtn) syncBtn.style.display = 'inline-block'; + showToast('ListenBrainz discovery complete!', 'success'); + } + }; + } + const pollInterval = setInterval(async () => { + if (socketConnected) return; // Phase 5: WS handles updates try { const response = await fetch(`/api/listenbrainz/discovery/status/${playlistMbid}`); const status = await response.json(); @@ -21604,14 +22260,42 @@ function startListenBrainzDiscoveryPolling(playlistMbid) { activeYouTubePollers[playlistMbid] = pollInterval; } -function startListenBrainzSyncPolling(playlistMbid) { +function startListenBrainzSyncPolling(playlistMbid, syncPlaylistId) { // Stop any existing polling if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); } - // Define the polling function + // Resolve syncPlaylistId from argument or stored state + const lbState = listenbrainzPlaylistStates[playlistMbid]; + syncPlaylistId = syncPlaylistId || (lbState && lbState.syncPlaylistId); + + // Phase 6: Subscribe via WebSocket + if (socketConnected && syncPlaylistId) { + socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] }); + _syncProgressCallbacks[syncPlaylistId] = (data) => { + const progress = data.progress || {}; + updateYouTubeModalSyncProgress(playlistMbid, progress); + + if (data.status === 'finished') { + if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; } + socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); + delete _syncProgressCallbacks[syncPlaylistId]; + updateYouTubeModalButtons(playlistMbid, 'sync_complete'); + showToast('ListenBrainz playlist sync complete!', 'success'); + } else if (data.status === 'error' || data.status === 'cancelled') { + if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; } + socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); + delete _syncProgressCallbacks[syncPlaylistId]; + updateYouTubeModalButtons(playlistMbid, 'discovered'); + showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error'); + } + }; + } + + // Define the polling function (HTTP fallback) const pollFunction = async () => { + if (socketConnected) return; // Phase 6: WS handles updates try { const response = await fetch(`/api/listenbrainz/sync/status/${playlistMbid}`); const status = await response.json(); @@ -21623,29 +22307,19 @@ function startListenBrainzSyncPolling(playlistMbid) { return; } - // Update modal sync display if open updateYouTubeModalSyncProgress(playlistMbid, status.progress); - // Check if complete if (status.complete) { clearInterval(pollInterval); delete activeYouTubePollers[playlistMbid]; - - // Update modal buttons updateYouTubeModalButtons(playlistMbid, 'sync_complete'); - - console.log('✅ ListenBrainz sync complete:', playlistMbid); showToast('ListenBrainz playlist sync complete!', 'success'); } else if (status.sync_status === 'error') { clearInterval(pollInterval); delete activeYouTubePollers[playlistMbid]; - - // Revert to discovered phase on error updateYouTubeModalButtons(playlistMbid, 'discovered'); - showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error'); } - } catch (error) { console.error('❌ Error polling ListenBrainz sync:', error); if (activeYouTubePollers[playlistMbid]) { @@ -21655,8 +22329,8 @@ function startListenBrainzSyncPolling(playlistMbid) { } }; - // Run immediately to get current status - pollFunction(); + // Run immediately to get current status (skip if WS active) + if (!socketConnected) pollFunction(); // Then continue polling at regular intervals const pollInterval = setInterval(pollFunction, 1000); @@ -21751,14 +22425,19 @@ async function startListenBrainzPlaylistSync(playlistMbid) { throw new Error(error.error || 'Failed to start sync'); } + // Capture sync_playlist_id for WebSocket subscription + const result = await response.json(); + const syncPlaylistId = result.sync_playlist_id; + if (state) state.syncPlaylistId = syncPlaylistId; + // Update phase to syncing state.phase = 'syncing'; // Start polling for sync progress if (isFromListing) { - startListenBrainzListingSyncPolling(playlistMbid, listingPlaylistId); + startListenBrainzListingSyncPolling(playlistMbid, listingPlaylistId, syncPlaylistId); } else { - startListenBrainzSyncPolling(playlistMbid); + startListenBrainzSyncPolling(playlistMbid, syncPlaylistId); updateYouTubeModalButtons(playlistMbid, 'syncing'); } @@ -21770,7 +22449,7 @@ async function startListenBrainzPlaylistSync(playlistMbid) { } } -function startListenBrainzListingSyncPolling(playlistMbid, listingPlaylistId) { +function startListenBrainzListingSyncPolling(playlistMbid, listingPlaylistId, syncPlaylistId) { console.log(`🔄 Starting listing sync polling for: ${playlistMbid} (UI: ${listingPlaylistId})`); // Stop any existing polling @@ -21778,7 +22457,56 @@ function startListenBrainzListingSyncPolling(playlistMbid, listingPlaylistId) { clearInterval(activeYouTubePollers[playlistMbid]); } + // Resolve syncPlaylistId from argument or stored state + const lbState = listenbrainzPlaylistStates[playlistMbid]; + syncPlaylistId = syncPlaylistId || (lbState && lbState.syncPlaylistId); + + // Phase 6: Subscribe via WebSocket + if (socketConnected && syncPlaylistId) { + socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] }); + _syncProgressCallbacks[syncPlaylistId] = (data) => { + const progress = data.progress || {}; + const total = progress.total_tracks || 0; + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + const percentage = total > 0 ? Math.round((matched / total) * 100) : 0; + + const totalEl = document.getElementById(`${listingPlaylistId}-sync-total`); + const matchedEl = document.getElementById(`${listingPlaylistId}-sync-matched`); + const failedEl = document.getElementById(`${listingPlaylistId}-sync-failed`); + const percentageEl = document.getElementById(`${listingPlaylistId}-sync-percentage`); + + if (totalEl) totalEl.textContent = total; + if (matchedEl) matchedEl.textContent = matched; + if (failedEl) failedEl.textContent = failed; + if (percentageEl) percentageEl.textContent = percentage; + + if (data.status === 'finished') { + if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; } + socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); + delete _syncProgressCallbacks[syncPlaylistId]; + + const statusDisplay = document.getElementById(`${listingPlaylistId}-sync-status`); + const syncButton = document.getElementById(`${listingPlaylistId}-sync-btn`); + if (statusDisplay) setTimeout(() => { statusDisplay.style.display = 'none'; }, 3000); + if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; } + + if (listenbrainzPlaylistStates[playlistMbid]) { + listenbrainzPlaylistStates[playlistMbid].phase = 'sync_complete'; + } + + showToast(`Sync complete: ${matched}/${total} tracks matched`, 'success'); + } else if (data.status === 'error' || data.status === 'cancelled') { + if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; } + socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); + delete _syncProgressCallbacks[syncPlaylistId]; + showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error'); + } + }; + } + const pollInterval = setInterval(async () => { + if (socketConnected) return; // Phase 6: WS handles updates try { const response = await fetch(`/api/listenbrainz/sync/status/${playlistMbid}`); const status = await response.json(); @@ -21790,19 +22518,11 @@ function startListenBrainzListingSyncPolling(playlistMbid, listingPlaylistId) { return; } - // Update UI elements in listing const totalEl = document.getElementById(`${listingPlaylistId}-sync-total`); const matchedEl = document.getElementById(`${listingPlaylistId}-sync-matched`); const failedEl = document.getElementById(`${listingPlaylistId}-sync-failed`); const percentageEl = document.getElementById(`${listingPlaylistId}-sync-percentage`); - console.log(`📊 ListenBrainz listing sync progress:`, { - total: status.progress?.total_tracks, - matched: status.progress?.matched_tracks, - failed: status.progress?.failed_tracks, - complete: status.complete - }); - if (totalEl) totalEl.textContent = status.progress?.total_tracks || 0; if (matchedEl) matchedEl.textContent = status.progress?.matched_tracks || 0; if (failedEl) failedEl.textContent = status.progress?.failed_tracks || 0; @@ -21812,34 +22532,21 @@ function startListenBrainzListingSyncPolling(playlistMbid, listingPlaylistId) { : 0; if (percentageEl) percentageEl.textContent = percentage; - // Check if complete if (status.complete) { clearInterval(pollInterval); delete activeYouTubePollers[playlistMbid]; const statusDisplay = document.getElementById(`${listingPlaylistId}-sync-status`); const syncButton = document.getElementById(`${listingPlaylistId}-sync-btn`); + if (statusDisplay) setTimeout(() => { statusDisplay.style.display = 'none'; }, 3000); + if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; } - if (statusDisplay) { - setTimeout(() => { - statusDisplay.style.display = 'none'; - }, 3000); - } - - if (syncButton) { - syncButton.disabled = false; - syncButton.style.opacity = '1'; - } - - // Update state if (listenbrainzPlaylistStates[playlistMbid]) { listenbrainzPlaylistStates[playlistMbid].phase = 'sync_complete'; } showToast(`Sync complete: ${status.progress?.matched_tracks || 0}/${status.progress?.total_tracks || 0} tracks matched`, 'success'); - console.log('✅ ListenBrainz listing sync complete:', playlistMbid); } - } catch (error) { console.error('❌ Error polling ListenBrainz listing sync:', error); clearInterval(pollInterval); @@ -25590,6 +26297,7 @@ function escapeHtml(text) { async function fetchAndUpdateServiceStatus() { if (document.hidden) return; // Skip polling when tab is not visible + if (socketConnected) return; // WebSocket is pushing updates — skip HTTP poll try { const response = await fetch('/status'); if (!response.ok) return; @@ -25696,6 +26404,7 @@ function updateSidebarServiceStatus(service, statusData) { } async function fetchAndUpdateSystemStats() { + if (socketConnected) return; // WebSocket handles this if (document.hidden) return; // Skip polling when tab is not visible try { const response = await fetch('/api/system/stats'); @@ -25732,6 +26441,7 @@ function updateStatCard(cardId, value, subtitle) { } async function fetchAndUpdateActivityFeed() { + if (socketConnected) return; // WebSocket handles this if (document.hidden) return; // Skip polling when tab is not visible try { const response = await fetch('/api/activity/feed'); @@ -25802,6 +26512,7 @@ function updateActivityFeed(activities) { } async function checkForActivityToasts() { + if (socketConnected) return; // WebSocket handles this (instant push) if (document.hidden) return; // Skip polling when tab is not visible try { const response = await fetch('/api/activity/toasts'); @@ -25918,6 +26629,7 @@ async function toggleWatchlist(event, artistId, artistName) { */ async function updateWatchlistButtonCount() { if (document.hidden) return; // Skip polling when tab is not visible + if (socketConnected) return; // WebSocket is pushing updates — skip HTTP poll try { const response = await fetch('/api/watchlist/count'); const data = await response.json(); @@ -26721,132 +27433,138 @@ async function startWatchlistScan() { /** * Poll watchlist scan status */ +function handleWatchlistScanData(data) { + const button = document.getElementById('scan-watchlist-btn'); + const liveActivity = document.getElementById('watchlist-live-activity'); + + // Update live visual activity display + if (liveActivity && data.status === 'scanning') { + liveActivity.style.display = 'flex'; + + // Update artist image and name + const artistImg = document.getElementById('watchlist-artist-img'); + const artistName = document.getElementById('watchlist-artist-name'); + if (artistImg && data.current_artist_image_url) { + artistImg.src = data.current_artist_image_url; + artistImg.style.display = 'block'; + } + if (artistName) { + artistName.textContent = data.current_artist_name || 'Processing...'; + } + + // Update album image and name + const albumImg = document.getElementById('watchlist-album-img'); + const albumName = document.getElementById('watchlist-album-name'); + if (albumImg && data.current_album_image_url) { + albumImg.src = data.current_album_image_url; + albumImg.style.display = 'block'; + } else if (albumImg) { + albumImg.style.display = 'none'; + } + if (albumName) { + albumName.textContent = data.current_album || (data.current_phase === 'fetching_discography' ? 'Fetching releases...' : 'Processing...'); + } + + // Update current track + const trackName = document.getElementById('watchlist-track-name'); + if (trackName) { + trackName.textContent = data.current_track_name || (data.current_phase === 'fetching_discography' ? 'Fetching releases...' : 'Processing...'); + } + + // Update wishlist additions feed + const additionsFeed = document.getElementById('watchlist-additions-feed'); + if (additionsFeed) { + if (data.recent_wishlist_additions && data.recent_wishlist_additions.length > 0) { + additionsFeed.innerHTML = data.recent_wishlist_additions.map(item => ` +
+ +
+
${item.track_name}
+
${item.artist_name}
+
+
+ `).join(''); + } else { + additionsFeed.innerHTML = '
No tracks added yet...
'; + } + } + } else if (liveActivity && data.status !== 'scanning') { + liveActivity.style.display = 'none'; + } + + if (data.status === 'completed') { + if (button) { + button.disabled = false; + button.textContent = 'Scan for New Releases'; + } + + // Hide live activity + if (liveActivity) { + liveActivity.style.display = 'none'; + } + + // Show completion message in status div + const statusDiv = document.getElementById('watchlist-scan-status'); + if (statusDiv && data.summary) { + const newTracks = data.summary.new_tracks_found || 0; + const addedTracks = data.summary.tracks_added_to_wishlist || 0; + const totalArtists = data.summary.total_artists || 0; + const successfulScans = data.summary.successful_scans || 0; + + let completionMessage = `Scan completed: ${successfulScans}/${totalArtists} artists scanned`; + if (newTracks > 0) { + completionMessage += `, found ${newTracks} new track${newTracks !== 1 ? 's' : ''}`; + if (addedTracks > 0) { + completionMessage += `, added ${addedTracks} to wishlist`; + } + } else { + completionMessage += ', no new tracks found'; + } + + // Update the scan status display with completion message and summary + statusDiv.innerHTML = ` +
+
${completionMessage}
+
+ Artists: ${totalArtists} + + New tracks: ${newTracks} + + Added to wishlist: ${addedTracks} +
+
+ `; + } + + // Update watchlist count + updateWatchlistButtonCount(); + + console.log('Watchlist scan completed:', data.summary); + + } else if (data.status === 'error') { + if (button) { + button.disabled = false; + button.textContent = 'Scan for New Releases'; + } + + // Hide live activity + if (liveActivity) { + liveActivity.style.display = 'none'; + } + + console.error('Watchlist scan error:', data.error); + } +} + async function pollWatchlistScanStatus() { + if (socketConnected) return; // Phase 5: WS handles scan updates try { const response = await fetch('/api/watchlist/scan/status'); const data = await response.json(); if (data.success) { - const button = document.getElementById('scan-watchlist-btn'); - const liveActivity = document.getElementById('watchlist-live-activity'); - - // Update live visual activity display - if (liveActivity && data.status === 'scanning') { - liveActivity.style.display = 'flex'; - - // Update artist image and name - const artistImg = document.getElementById('watchlist-artist-img'); - const artistName = document.getElementById('watchlist-artist-name'); - if (artistImg && data.current_artist_image_url) { - artistImg.src = data.current_artist_image_url; - artistImg.style.display = 'block'; - } - if (artistName) { - artistName.textContent = data.current_artist_name || 'Processing...'; - } - - // Update album image and name - const albumImg = document.getElementById('watchlist-album-img'); - const albumName = document.getElementById('watchlist-album-name'); - if (albumImg && data.current_album_image_url) { - albumImg.src = data.current_album_image_url; - albumImg.style.display = 'block'; - } else if (albumImg) { - albumImg.style.display = 'none'; - } - if (albumName) { - albumName.textContent = data.current_album || (data.current_phase === 'fetching_discography' ? 'Fetching releases...' : 'Processing...'); - } - - // Update current track - const trackName = document.getElementById('watchlist-track-name'); - if (trackName) { - trackName.textContent = data.current_track_name || (data.current_phase === 'fetching_discography' ? 'Fetching releases...' : 'Processing...'); - } - - // Update wishlist additions feed - const additionsFeed = document.getElementById('watchlist-additions-feed'); - if (additionsFeed) { - if (data.recent_wishlist_additions && data.recent_wishlist_additions.length > 0) { - additionsFeed.innerHTML = data.recent_wishlist_additions.map(item => ` -
- -
-
${item.track_name}
-
${item.artist_name}
-
-
- `).join(''); - } else { - additionsFeed.innerHTML = '
No tracks added yet...
'; - } - } - } else if (liveActivity && data.status !== 'scanning') { - liveActivity.style.display = 'none'; - } - - if (data.status === 'completed') { - if (button) { - button.disabled = false; - button.textContent = 'Scan for New Releases'; - } - - // Hide live activity - if (liveActivity) { - liveActivity.style.display = 'none'; - } - - // Show completion message in status div - const statusDiv = document.getElementById('watchlist-scan-status'); - if (statusDiv && data.summary) { - const newTracks = data.summary.new_tracks_found || 0; - const addedTracks = data.summary.tracks_added_to_wishlist || 0; - const totalArtists = data.summary.total_artists || 0; - const successfulScans = data.summary.successful_scans || 0; - - let completionMessage = `Scan completed: ${successfulScans}/${totalArtists} artists scanned`; - if (newTracks > 0) { - completionMessage += `, found ${newTracks} new track${newTracks !== 1 ? 's' : ''}`; - if (addedTracks > 0) { - completionMessage += `, added ${addedTracks} to wishlist`; - } - } else { - completionMessage += ', no new tracks found'; - } - - // Update the scan status display with completion message and summary - statusDiv.innerHTML = ` -
-
${completionMessage}
-
- Artists: ${totalArtists} - - New tracks: ${newTracks} - - Added to wishlist: ${addedTracks} -
-
- `; - } - - // Update watchlist count - updateWatchlistButtonCount(); - - console.log('Watchlist scan completed:', data.summary); - return; // Stop polling - - } else if (data.status === 'error') { - if (button) { - button.disabled = false; - button.textContent = 'Scan for New Releases'; - } - - // Hide live activity - if (liveActivity) { - liveActivity.style.display = 'none'; - } - - console.error('Watchlist scan error:', data.error); + handleWatchlistScanData(data); + if (data.status === 'completed' || data.status === 'error') { return; // Stop polling } } @@ -27154,6 +27872,7 @@ function stopMetadataUpdatePolling() { * Check current metadata update status and update UI */ async function checkMetadataUpdateStatus() { + if (socketConnected) return; // WebSocket handles this try { const response = await fetch('/api/metadata/status'); const data = await response.json(); @@ -27172,6 +27891,17 @@ async function checkMetadataUpdateStatus() { } } +function updateMetadataStatusFromData(data) { + if (!data.success || !data.status) return; + const prev = _lastToolStatus['metadata']; + _lastToolStatus['metadata'] = data.status.status; + if (prev !== undefined && data.status.status === prev && data.status.status !== 'running' && data.status.status !== 'stopping') return; + updateMetadataProgressUI(data.status); + if (data.status.status === 'completed' || data.status.status === 'error') { + stopMetadataUpdatePolling(); + } +} + /** * Update metadata progress UI elements */ @@ -27399,6 +28129,7 @@ async function handleMediaScanButtonClick() { const maxPolls = 150; // 5 minutes pollInterval = setInterval(async () => { + if (socketConnected) return; // Phase 5: WS handles scan status pollCount++; if (pollCount > maxPolls) { @@ -27544,32 +28275,11 @@ function stopLogPolling() { * Load and display activity feed as logs */ async function loadLogs() { + if (socketConnected) return; // WebSocket handles this try { const response = await fetch('/api/logs'); const data = await response.json(); - - if (data.logs && Array.isArray(data.logs)) { - const logArea = document.getElementById('sync-log-area'); - if (!logArea) return; - - // Join logs with newlines and update textarea - const logText = data.logs.join('\n'); - - // Store current scroll state - const wasAtTop = logArea.scrollTop <= 10; - const wasUserScrolled = logArea.scrollTop < logArea.scrollHeight - logArea.clientHeight - 10; - - // Update content only if it has changed - if (logArea.value !== logText) { - logArea.value = logText; - - // Smart scrolling: stay at top for new entries, preserve user position if scrolled - if (wasAtTop || !wasUserScrolled) { - logArea.scrollTop = 0; // Stay at top since newest entries are now at top - } - // If user had scrolled, keep their position (browser handles this automatically) - } - } + updateLogsFromData(data); } catch (error) { console.warn('Could not load activity logs for sync page:', error); const logArea = document.getElementById('sync-log-area'); @@ -27579,6 +28289,28 @@ async function loadLogs() { } } +function updateLogsFromData(data) { + if (!data.logs || !Array.isArray(data.logs)) return; + const logArea = document.getElementById('sync-log-area'); + if (!logArea) return; + + const logText = data.logs.join('\n'); + + // Store current scroll state + const wasAtTop = logArea.scrollTop <= 10; + const wasUserScrolled = logArea.scrollTop < logArea.scrollHeight - logArea.clientHeight - 10; + + // Update content only if it has changed + if (logArea.value !== logText) { + logArea.value = logText; + + // Smart scrolling: stay at top for new entries, preserve user position if scrolled + if (wasAtTop || !wasUserScrolled) { + logArea.scrollTop = 0; // Stay at top since newest entries are now at top + } + } +} + /** * Stop log polling when leaving sync page */ @@ -35015,7 +35747,36 @@ function startDecadeSyncPolling(decade, virtualPlaylistId) { clearInterval(discoverSyncPollers[pollerId]); } + // Phase 5: Subscribe via WebSocket + if (socketConnected) { + socket.emit('sync:subscribe', { playlist_ids: [virtualPlaylistId] }); + _syncProgressCallbacks[virtualPlaylistId] = (data) => { + const progress = data.progress || {}; + const total = progress.total_tracks || 0; + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + const processed = matched + failed; + const pending = total - processed; + const pct = total > 0 ? Math.round((processed / total) * 100) : 0; + const el = (id) => document.getElementById(id); + if (el(`decade-${decade}-sync-completed`)) el(`decade-${decade}-sync-completed`).textContent = matched; + if (el(`decade-${decade}-sync-pending`)) el(`decade-${decade}-sync-pending`).textContent = pending; + if (el(`decade-${decade}-sync-failed`)) el(`decade-${decade}-sync-failed`).textContent = failed; + if (el(`decade-${decade}-sync-percentage`)) el(`decade-${decade}-sync-percentage`).textContent = pct; + if (data.status === 'finished') { + if (discoverSyncPollers[pollerId]) { clearInterval(discoverSyncPollers[pollerId]); delete discoverSyncPollers[pollerId]; } + socket.emit('sync:unsubscribe', { playlist_ids: [virtualPlaylistId] }); + delete _syncProgressCallbacks[virtualPlaylistId]; + const syncButton = el(`decade-${decade}-sync-btn`); + if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; } + showToast(`${decade}s Classics sync complete!`, 'success'); + setTimeout(() => { const sd = el(`decade-${decade}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000); + } + }; + } + discoverSyncPollers[pollerId] = setInterval(async () => { + if (socketConnected) return; // Phase 5: WS handles updates try { const response = await fetch(`/api/sync/status/${virtualPlaylistId}`); if (!response.ok) return; @@ -35387,7 +36148,36 @@ function startGenreSyncPolling(genreName, genreId, virtualPlaylistId) { clearInterval(discoverSyncPollers[pollerId]); } + // Phase 5: Subscribe via WebSocket + if (socketConnected) { + socket.emit('sync:subscribe', { playlist_ids: [virtualPlaylistId] }); + _syncProgressCallbacks[virtualPlaylistId] = (data) => { + const progress = data.progress || {}; + const total = progress.total_tracks || 0; + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + const processed = matched + failed; + const pending = total - processed; + const pct = total > 0 ? Math.round((processed / total) * 100) : 0; + const el = (id) => document.getElementById(id); + if (el(`genre-${genreId}-sync-completed`)) el(`genre-${genreId}-sync-completed`).textContent = matched; + if (el(`genre-${genreId}-sync-pending`)) el(`genre-${genreId}-sync-pending`).textContent = pending; + if (el(`genre-${genreId}-sync-failed`)) el(`genre-${genreId}-sync-failed`).textContent = failed; + if (el(`genre-${genreId}-sync-percentage`)) el(`genre-${genreId}-sync-percentage`).textContent = pct; + if (data.status === 'finished') { + if (discoverSyncPollers[pollerId]) { clearInterval(discoverSyncPollers[pollerId]); delete discoverSyncPollers[pollerId]; } + socket.emit('sync:unsubscribe', { playlist_ids: [virtualPlaylistId] }); + delete _syncProgressCallbacks[virtualPlaylistId]; + const syncButton = el(`genre-${genreId}-sync-btn`); + if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; } + showToast(`${capitalizeGenre(genreName)} Mix sync complete!`, 'success'); + setTimeout(() => { const sd = el(`genre-${genreId}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000); + } + }; + } + discoverSyncPollers[pollerId] = setInterval(async () => { + if (socketConnected) return; // Phase 5: WS handles updates try { const response = await fetch(`/api/sync/status/${virtualPlaylistId}`); if (!response.ok) return; @@ -37285,8 +38075,45 @@ function startDiscoverSyncPolling(playlistType, virtualPlaylistId) { console.log(`🔄 Starting sync polling for ${playlistType} (${virtualPlaylistId})`); + // Phase 5: Subscribe via WebSocket + if (socketConnected) { + socket.emit('sync:subscribe', { playlist_ids: [virtualPlaylistId] }); + _syncProgressCallbacks[virtualPlaylistId] = (data) => { + const prefix = playlistType.replace(/_/g, '-'); + const progress = data.progress || {}; + const total = progress.total_tracks || 0; + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + const processed = matched + failed; + const pending = total - processed; + const pct = total > 0 ? Math.round((processed / total) * 100) : 0; + const el = (id) => document.getElementById(id); + if (el(`${prefix}-sync-completed`)) el(`${prefix}-sync-completed`).textContent = matched; + if (el(`${prefix}-sync-pending`)) el(`${prefix}-sync-pending`).textContent = pending; + if (el(`${prefix}-sync-failed`)) el(`${prefix}-sync-failed`).textContent = failed; + if (el(`${prefix}-sync-percentage`)) el(`${prefix}-sync-percentage`).textContent = pct; + if (data.status === 'finished') { + if (discoverSyncPollers[playlistType]) { clearInterval(discoverSyncPollers[playlistType]); delete discoverSyncPollers[playlistType]; } + socket.emit('sync:unsubscribe', { playlist_ids: [virtualPlaylistId] }); + delete _syncProgressCallbacks[virtualPlaylistId]; + const buttonId = playlistType.replace(/_/g, '-') + '-sync-btn'; + const syncButton = el(buttonId); + if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; } + const playlistNames = { + 'release_radar': 'Fresh Tape', 'discovery_weekly': 'The Archives', + 'seasonal_playlist': 'Seasonal Mix', 'popular_picks': 'Popular Picks', + 'hidden_gems': 'Hidden Gems', 'discovery_shuffle': 'Discovery Shuffle', + 'familiar_favorites': 'Familiar Favorites', 'build_playlist': 'Custom Playlist' + }; + showToast(`${playlistNames[playlistType] || playlistType} sync complete!`, 'success'); + setTimeout(() => { const sd = el(`${prefix}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000); + } + }; + } + // Poll every 500ms for progress updates discoverSyncPollers[playlistType] = setInterval(async () => { + if (socketConnected) return; // Phase 5: WS handles updates try { const response = await fetch(`/api/sync/status/${virtualPlaylistId}`); if (!response.ok) { @@ -37495,11 +38322,35 @@ function monitorDiscoverDownload(playlistId) { let notFoundCount = 0; const maxNotFoundAttempts = 5; // Give sync 10 seconds to start (5 checks * 2 seconds) + // Phase 5: Subscribe via WebSocket for sync status updates + if (socketConnected) { + socket.emit('sync:subscribe', { playlist_ids: [playlistId] }); + _syncProgressCallbacks[playlistId] = (data) => { + if (!discoverDownloads[playlistId]) return; + if (data.status === 'complete' || data.status === 'finished') { + discoverDownloads[playlistId].status = 'completed'; + updateDiscoverDownloadBar(); + updateDashboardDownloads(); + socket.emit('sync:unsubscribe', { playlist_ids: [playlistId] }); + delete _syncProgressCallbacks[playlistId]; + setTimeout(() => { + if (discoverDownloads[playlistId] && discoverDownloads[playlistId].status === 'completed') { + removeDiscoverDownload(playlistId); + } + }, 30000); + } + }; + } + const checkInterval = setInterval(async () => { try { // Check if download still exists if (!discoverDownloads[playlistId]) { clearInterval(checkInterval); + if (_syncProgressCallbacks[playlistId]) { + if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [playlistId] }); + delete _syncProgressCallbacks[playlistId]; + } return; } @@ -37526,6 +38377,7 @@ function monitorDiscoverDownload(playlistId) { } // Check sync status API (for sync-based downloads) + if (socketConnected) return; // Phase 5: WS handles sync status const response = await fetch(`/api/sync/status/${playlistId}`); if (response.ok) { const data = await response.json(); @@ -37809,6 +38661,7 @@ async function rehydrateDiscoverDownloadModal(playlistId) { if (process) { process.status = 'running'; process.batchId = batchId; + subscribeToDownloadBatch(batchId); const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`); const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`); if (beginBtn) beginBtn.style.display = 'none'; @@ -37873,6 +38726,7 @@ async function rehydrateDiscoverDownloadModal(playlistId) { if (process) { process.status = 'running'; process.batchId = batchId; + subscribeToDownloadBatch(batchId); const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`); const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`); if (beginBtn) beginBtn.style.display = 'none'; @@ -37948,6 +38802,7 @@ async function rehydrateDiscoverDownloadModal(playlistId) { if (process) { process.status = 'running'; process.batchId = batchId; + subscribeToDownloadBatch(batchId); const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`); const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`); if (beginBtn) beginBtn.style.display = 'none'; @@ -38017,6 +38872,7 @@ async function rehydrateDiscoverDownloadModal(playlistId) { if (process) { process.status = 'running'; process.batchId = batchId; + subscribeToDownloadBatch(batchId); // Update button states const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`); @@ -38181,94 +39037,86 @@ if (document.readyState === 'loading') { * Poll MusicBrainz status every 2 seconds and update UI */ async function updateMusicBrainzStatus() { + if (socketConnected) return; // WebSocket handles this if (document.hidden) return; // Skip polling when tab is not visible try { const response = await fetch('/api/musicbrainz/status'); - if (!response.ok) { - console.warn('MusicBrainz status endpoint unavailable'); - return; - } - + if (!response.ok) { console.warn('MusicBrainz status endpoint unavailable'); return; } const data = await response.json(); - const button = document.getElementById('musicbrainz-button'); - if (!button) return; - - // Update button state classes - button.classList.remove('active', 'paused', 'complete'); - if (data.idle) { - button.classList.add('complete'); - } else if (data.running && !data.paused) { - button.classList.add('active'); - } else if (data.paused) { - button.classList.add('paused'); - } - - // Update tooltip content - const tooltipStatus = document.getElementById('mb-tooltip-status'); - const tooltipCurrent = document.getElementById('mb-tooltip-current'); - const tooltipProgress = document.getElementById('mb-tooltip-progress'); - - if (tooltipStatus) { - if (data.idle) { - tooltipStatus.textContent = 'Complete'; - } else if (data.running && !data.paused) { - tooltipStatus.textContent = 'Running'; - } else if (data.paused) { - tooltipStatus.textContent = 'Paused'; - } else { - tooltipStatus.textContent = 'Idle'; - } - } - - if (tooltipCurrent) { - if (data.idle) { - tooltipCurrent.textContent = 'All items processed'; - } else if (data.current_item && data.current_item.name) { - const type = data.current_item.type || 'item'; - const name = data.current_item.name; - tooltipCurrent.textContent = `${type.charAt(0).toUpperCase() + type.slice(1)}: "${name}"`; - } else { - tooltipCurrent.textContent = 'No active matches'; - } - } - - if (tooltipProgress && data.progress) { - const artists = data.progress.artists || {}; - const albums = data.progress.albums || {}; - const tracks = data.progress.tracks || {}; - - // Determine which phase we're in by checking: - // 1. Current item type (if available) - // 2. Which entity type still has pending work - const currentType = data.current_item?.type; - let progressText = ''; - - // Check if each phase is complete (all items have been attempted) - const artistsComplete = artists.matched >= artists.total; - const albumsComplete = albums.matched >= albums.total; - - if (currentType === 'artist' || (!artistsComplete && !currentType)) { - // Show artists if not complete OR if explicitly processing an artist - progressText = `Artists: ${artists.matched || 0} / ${artists.total} (${artists.percent || 0}%)`; - } else if (currentType === 'album' || (artistsComplete && !albumsComplete)) { - // Show albums if artists done and albums not done - progressText = `Albums: ${albums.matched || 0} / ${albums.total} (${albums.percent || 0}%)`; - } else if (currentType === 'track' || (artistsComplete && albumsComplete)) { - // Show tracks if both artists and albums done - progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total} (${tracks.percent || 0}%)`; - } else { - // Fallback to artists - progressText = `Artists: ${artists.matched || 0} / ${artists.total} (${artists.percent || 0}%)`; - } - - tooltipProgress.textContent = progressText; - } - + updateMusicBrainzStatusFromData(data); } catch (error) { console.error('Error updating MusicBrainz status:', error); } } +function updateMusicBrainzStatusFromData(data) { + const button = document.getElementById('musicbrainz-button'); + if (!button) return; + + // Update button state classes + button.classList.remove('active', 'paused', 'complete'); + if (data.idle) { + button.classList.add('complete'); + } else if (data.running && !data.paused) { + button.classList.add('active'); + } else if (data.paused) { + button.classList.add('paused'); + } + + // Update tooltip content + const tooltipStatus = document.getElementById('mb-tooltip-status'); + const tooltipCurrent = document.getElementById('mb-tooltip-current'); + const tooltipProgress = document.getElementById('mb-tooltip-progress'); + + if (tooltipStatus) { + if (data.idle) { + tooltipStatus.textContent = 'Complete'; + } else if (data.running && !data.paused) { + tooltipStatus.textContent = 'Running'; + } else if (data.paused) { + tooltipStatus.textContent = 'Paused'; + } else { + tooltipStatus.textContent = 'Idle'; + } + } + + if (tooltipCurrent) { + if (data.idle) { + tooltipCurrent.textContent = 'All items processed'; + } else if (data.current_item && data.current_item.name) { + const type = data.current_item.type || 'item'; + const name = data.current_item.name; + tooltipCurrent.textContent = `${type.charAt(0).toUpperCase() + type.slice(1)}: "${name}"`; + } else { + tooltipCurrent.textContent = 'No active matches'; + } + } + + if (tooltipProgress && data.progress) { + const artists = data.progress.artists || {}; + const albums = data.progress.albums || {}; + const tracks = data.progress.tracks || {}; + + const currentType = data.current_item?.type; + let progressText = ''; + + const artistsComplete = artists.matched >= artists.total; + const albumsComplete = albums.matched >= albums.total; + + if (currentType === 'artist' || (!artistsComplete && !currentType)) { + progressText = `Artists: ${artists.matched || 0} / ${artists.total} (${artists.percent || 0}%)`; + } else if (currentType === 'album' || (artistsComplete && !albumsComplete)) { + progressText = `Albums: ${albums.matched || 0} / ${albums.total} (${albums.percent || 0}%)`; + } else if (currentType === 'track' || (artistsComplete && albumsComplete)) { + progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total} (${tracks.percent || 0}%)`; + } else { + progressText = `Artists: ${artists.matched || 0} / ${artists.total} (${artists.percent || 0}%)`; + } + + tooltipProgress.textContent = progressText; + } +} + /** * Toggle MusicBrainz enrichment pause/resume */ @@ -38327,86 +39175,79 @@ if (document.readyState === 'loading') { * Poll AudioDB status every 2 seconds and update UI */ async function updateAudioDBStatus() { + if (socketConnected) return; // WebSocket handles this if (document.hidden) return; // Skip polling when tab is not visible try { const response = await fetch('/api/audiodb/status'); - if (!response.ok) { - console.warn('AudioDB status endpoint unavailable'); - return; - } - + if (!response.ok) { console.warn('AudioDB status endpoint unavailable'); return; } const data = await response.json(); - const button = document.getElementById('audiodb-button'); - if (!button) return; - - // Update button state classes - button.classList.remove('active', 'paused', 'complete'); - if (data.idle) { - button.classList.add('complete'); - } else if (data.running && !data.paused) { - button.classList.add('active'); - } else if (data.paused) { - button.classList.add('paused'); - } - - // Update tooltip content - const tooltipStatus = document.getElementById('audiodb-tooltip-status'); - const tooltipCurrent = document.getElementById('audiodb-tooltip-current'); - const tooltipProgress = document.getElementById('audiodb-tooltip-progress'); - - if (tooltipStatus) { - if (data.idle) { - tooltipStatus.textContent = 'Complete'; - } else if (data.running && !data.paused) { - tooltipStatus.textContent = 'Running'; - } else if (data.paused) { - tooltipStatus.textContent = 'Paused'; - } else { - tooltipStatus.textContent = 'Idle'; - } - } - - if (tooltipCurrent) { - if (data.idle) { - tooltipCurrent.textContent = 'All items processed'; - } else if (data.current_item && data.current_item.name) { - const type = data.current_item.type || 'item'; - const name = data.current_item.name; - tooltipCurrent.textContent = `${type.charAt(0).toUpperCase() + type.slice(1)}: "${name}"`; - } else { - tooltipCurrent.textContent = 'No active matches'; - } - } - - if (tooltipProgress && data.progress) { - const artists = data.progress.artists || {}; - const albums = data.progress.albums || {}; - const tracks = data.progress.tracks || {}; - - const currentType = data.current_item?.type; - let progressText = ''; - - const artistsComplete = artists.matched >= artists.total; - const albumsComplete = albums.matched >= albums.total; - - if (currentType === 'artist' || (!artistsComplete && !currentType)) { - progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; - } else if (currentType === 'album' || (artistsComplete && !albumsComplete)) { - progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; - } else if (currentType === 'track' || (artistsComplete && albumsComplete)) { - progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; - } else { - progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; - } - - tooltipProgress.textContent = progressText; - } - + updateAudioDBStatusFromData(data); } catch (error) { console.error('Error updating AudioDB status:', error); } } +function updateAudioDBStatusFromData(data) { + const button = document.getElementById('audiodb-button'); + if (!button) return; + + button.classList.remove('active', 'paused', 'complete'); + if (data.idle) { + button.classList.add('complete'); + } else if (data.running && !data.paused) { + button.classList.add('active'); + } else if (data.paused) { + button.classList.add('paused'); + } + + const tooltipStatus = document.getElementById('audiodb-tooltip-status'); + const tooltipCurrent = document.getElementById('audiodb-tooltip-current'); + const tooltipProgress = document.getElementById('audiodb-tooltip-progress'); + + if (tooltipStatus) { + if (data.idle) { tooltipStatus.textContent = 'Complete'; } + else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; } + else if (data.paused) { tooltipStatus.textContent = 'Paused'; } + else { tooltipStatus.textContent = 'Idle'; } + } + + if (tooltipCurrent) { + if (data.idle) { + tooltipCurrent.textContent = 'All items processed'; + } else if (data.current_item && data.current_item.name) { + const type = data.current_item.type || 'item'; + const name = data.current_item.name; + tooltipCurrent.textContent = `${type.charAt(0).toUpperCase() + type.slice(1)}: "${name}"`; + } else { + tooltipCurrent.textContent = 'No active matches'; + } + } + + if (tooltipProgress && data.progress) { + const artists = data.progress.artists || {}; + const albums = data.progress.albums || {}; + const tracks = data.progress.tracks || {}; + + const currentType = data.current_item?.type; + let progressText = ''; + + const artistsComplete = artists.matched >= artists.total; + const albumsComplete = albums.matched >= albums.total; + + if (currentType === 'artist' || (!artistsComplete && !currentType)) { + progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; + } else if (currentType === 'album' || (artistsComplete && !albumsComplete)) { + progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; + } else if (currentType === 'track' || (artistsComplete && albumsComplete)) { + progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; + } else { + progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; + } + + tooltipProgress.textContent = progressText; + } +} + /** * Toggle AudioDB enrichment pause/resume */ @@ -38460,82 +39301,75 @@ if (document.readyState === 'loading') { // =================================================================== async function updateDeezerStatus() { + if (socketConnected) return; // WebSocket handles this if (document.hidden) return; // Skip polling when tab is not visible try { const response = await fetch('/api/deezer/status'); - if (!response.ok) { - console.warn('Deezer status endpoint unavailable'); - return; - } - + if (!response.ok) { console.warn('Deezer status endpoint unavailable'); return; } const data = await response.json(); - const button = document.getElementById('deezer-button'); - if (!button) return; - - // Update button state classes - button.classList.remove('active', 'paused', 'complete'); - if (data.idle) { - button.classList.add('complete'); - } else if (data.running && !data.paused) { - button.classList.add('active'); - } else if (data.paused) { - button.classList.add('paused'); - } - - // Update tooltip content - const tooltipStatus = document.getElementById('deezer-tooltip-status'); - const tooltipCurrent = document.getElementById('deezer-tooltip-current'); - const tooltipProgress = document.getElementById('deezer-tooltip-progress'); - - if (tooltipStatus) { - if (data.idle) { - tooltipStatus.textContent = 'Complete'; - } else if (data.running && !data.paused) { - tooltipStatus.textContent = 'Running'; - } else if (data.paused) { - tooltipStatus.textContent = 'Paused'; - } else { - tooltipStatus.textContent = 'Idle'; - } - } - - if (tooltipCurrent) { - if (data.idle) { - tooltipCurrent.textContent = 'All items processed'; - } else if (data.current_item && data.current_item.name) { - tooltipCurrent.textContent = `Now: ${data.current_item.name}`; - } - } - - if (data.progress && tooltipProgress) { - const artists = data.progress.artists || {}; - const albums = data.progress.albums || {}; - const tracks = data.progress.tracks || {}; - - const currentType = data.current_item?.type; - let progressText = ''; - - const artistsComplete = artists.matched >= artists.total; - const albumsComplete = albums.matched >= albums.total; - - if (currentType === 'artist' || (!artistsComplete && !currentType)) { - progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; - } else if (currentType === 'album' || (artistsComplete && !albumsComplete)) { - progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; - } else if (currentType === 'track' || (artistsComplete && albumsComplete)) { - progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; - } else { - progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; - } - - tooltipProgress.textContent = progressText; - } - + updateDeezerStatusFromData(data); } catch (error) { console.error('Error updating Deezer status:', error); } } +function updateDeezerStatusFromData(data) { + const button = document.getElementById('deezer-button'); + if (!button) return; + + button.classList.remove('active', 'paused', 'complete'); + if (data.idle) { + button.classList.add('complete'); + } else if (data.running && !data.paused) { + button.classList.add('active'); + } else if (data.paused) { + button.classList.add('paused'); + } + + const tooltipStatus = document.getElementById('deezer-tooltip-status'); + const tooltipCurrent = document.getElementById('deezer-tooltip-current'); + const tooltipProgress = document.getElementById('deezer-tooltip-progress'); + + if (tooltipStatus) { + if (data.idle) { tooltipStatus.textContent = 'Complete'; } + else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; } + else if (data.paused) { tooltipStatus.textContent = 'Paused'; } + else { tooltipStatus.textContent = 'Idle'; } + } + + if (tooltipCurrent) { + if (data.idle) { + tooltipCurrent.textContent = 'All items processed'; + } else if (data.current_item && data.current_item.name) { + tooltipCurrent.textContent = `Now: ${data.current_item.name}`; + } + } + + if (data.progress && tooltipProgress) { + const artists = data.progress.artists || {}; + const albums = data.progress.albums || {}; + const tracks = data.progress.tracks || {}; + + const currentType = data.current_item?.type; + let progressText = ''; + + const artistsComplete = artists.matched >= artists.total; + const albumsComplete = albums.matched >= albums.total; + + if (currentType === 'artist' || (!artistsComplete && !currentType)) { + progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; + } else if (currentType === 'album' || (artistsComplete && !albumsComplete)) { + progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; + } else if (currentType === 'track' || (artistsComplete && albumsComplete)) { + progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; + } else { + progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; + } + + tooltipProgress.textContent = progressText; + } +} + async function toggleDeezerEnrichment() { try { const button = document.getElementById('deezer-button'); @@ -38586,99 +39420,90 @@ if (document.readyState === 'loading') { // =================================================================== async function updateSpotifyEnrichmentStatus() { + if (socketConnected) return; // WebSocket handles this if (document.hidden) return; // Skip polling when tab is not visible try { const response = await fetch('/api/spotify-enrichment/status'); - if (!response.ok) { - console.warn('Spotify enrichment status endpoint unavailable'); - return; - } - + if (!response.ok) { console.warn('Spotify enrichment status endpoint unavailable'); return; } const data = await response.json(); - const button = document.getElementById('spotify-enrich-button'); - if (!button) return; - - const notAuthenticated = data.authenticated === false; - - // Update button state classes - button.classList.remove('active', 'paused', 'complete', 'no-auth'); - if (notAuthenticated) { - button.classList.add('no-auth'); - } else if (data.idle) { - button.classList.add('complete'); - } else if (data.running && !data.paused) { - button.classList.add('active'); - } else if (data.paused) { - button.classList.add('paused'); - } - - // Update tooltip content - const tooltipStatus = document.getElementById('spotify-enrich-tooltip-status'); - const tooltipCurrent = document.getElementById('spotify-enrich-tooltip-current'); - const tooltipProgress = document.getElementById('spotify-enrich-tooltip-progress'); - - if (tooltipStatus) { - if (notAuthenticated) { - tooltipStatus.textContent = 'Not Authenticated'; - } else if (data.idle) { - tooltipStatus.textContent = 'Complete'; - } else if (data.running && !data.paused) { - tooltipStatus.textContent = 'Running'; - } else if (data.paused) { - tooltipStatus.textContent = 'Paused'; - } else { - tooltipStatus.textContent = 'Idle'; - } - } - - if (tooltipCurrent) { - if (notAuthenticated) { - tooltipCurrent.textContent = 'Connect Spotify in Settings to enrich'; - } else if (data.idle) { - tooltipCurrent.textContent = 'All items processed'; - } else if (data.current_item && data.current_item.name) { - tooltipCurrent.textContent = `Now: ${data.current_item.name}`; - } - } - - if (data.progress && tooltipProgress) { - if (notAuthenticated) { - tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`; - } else { - const artists = data.progress.artists || {}; - const albums = data.progress.albums || {}; - const tracks = data.progress.tracks || {}; - - const currentType = data.current_item?.type || ''; - let progressText = ''; - - const artistsComplete = artists.matched >= artists.total; - const albumsComplete = albums.matched >= albums.total; - - // Prioritize currentType over completion-based inference - if (currentType === 'artist') { - progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; - } else if (currentType.includes('album')) { - progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; - } else if (currentType.includes('track')) { - progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; - } else if (!artistsComplete) { - progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; - } else if (!albumsComplete) { - progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; - } else { - progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; - } - - tooltipProgress.textContent = progressText; - } - } - + updateSpotifyEnrichmentStatusFromData(data); } catch (error) { console.error('Error updating Spotify enrichment status:', error); } } +function updateSpotifyEnrichmentStatusFromData(data) { + const button = document.getElementById('spotify-enrich-button'); + if (!button) return; + + const notAuthenticated = data.authenticated === false; + + button.classList.remove('active', 'paused', 'complete', 'no-auth'); + if (notAuthenticated) { + button.classList.add('no-auth'); + } else if (data.idle) { + button.classList.add('complete'); + } else if (data.running && !data.paused) { + button.classList.add('active'); + } else if (data.paused) { + button.classList.add('paused'); + } + + const tooltipStatus = document.getElementById('spotify-enrich-tooltip-status'); + const tooltipCurrent = document.getElementById('spotify-enrich-tooltip-current'); + const tooltipProgress = document.getElementById('spotify-enrich-tooltip-progress'); + + if (tooltipStatus) { + if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; } + else if (data.idle) { tooltipStatus.textContent = 'Complete'; } + else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; } + else if (data.paused) { tooltipStatus.textContent = 'Paused'; } + else { tooltipStatus.textContent = 'Idle'; } + } + + if (tooltipCurrent) { + if (notAuthenticated) { + tooltipCurrent.textContent = 'Connect Spotify in Settings to enrich'; + } else if (data.idle) { + tooltipCurrent.textContent = 'All items processed'; + } else if (data.current_item && data.current_item.name) { + tooltipCurrent.textContent = `Now: ${data.current_item.name}`; + } + } + + if (data.progress && tooltipProgress) { + if (notAuthenticated) { + tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`; + } else { + const artists = data.progress.artists || {}; + const albums = data.progress.albums || {}; + const tracks = data.progress.tracks || {}; + + const currentType = data.current_item?.type || ''; + let progressText = ''; + + const artistsComplete = artists.matched >= artists.total; + const albumsComplete = albums.matched >= albums.total; + + if (currentType === 'artist') { + progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; + } else if (currentType.includes('album')) { + progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; + } else if (currentType.includes('track')) { + progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; + } else if (!artistsComplete) { + progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; + } else if (!albumsComplete) { + progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; + } else { + progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; + } + + tooltipProgress.textContent = progressText; + } + } +} + async function toggleSpotifyEnrichment() { try { const button = document.getElementById('spotify-enrich-button'); @@ -38725,87 +39550,79 @@ if (document.readyState === 'loading') { // =================================================================== async function updateiTunesEnrichmentStatus() { + if (socketConnected) return; // WebSocket handles this if (document.hidden) return; // Skip polling when tab is not visible try { const response = await fetch('/api/itunes-enrichment/status'); - if (!response.ok) { - console.warn('iTunes enrichment status endpoint unavailable'); - return; - } - + if (!response.ok) { console.warn('iTunes enrichment status endpoint unavailable'); return; } const data = await response.json(); - const button = document.getElementById('itunes-enrich-button'); - if (!button) return; - - // Update button state classes - button.classList.remove('active', 'paused', 'complete'); - if (data.idle) { - button.classList.add('complete'); - } else if (data.running && !data.paused) { - button.classList.add('active'); - } else if (data.paused) { - button.classList.add('paused'); - } - - // Update tooltip content - const tooltipStatus = document.getElementById('itunes-enrich-tooltip-status'); - const tooltipCurrent = document.getElementById('itunes-enrich-tooltip-current'); - const tooltipProgress = document.getElementById('itunes-enrich-tooltip-progress'); - - if (tooltipStatus) { - if (data.idle) { - tooltipStatus.textContent = 'Complete'; - } else if (data.running && !data.paused) { - tooltipStatus.textContent = 'Running'; - } else if (data.paused) { - tooltipStatus.textContent = 'Paused'; - } else { - tooltipStatus.textContent = 'Idle'; - } - } - - if (tooltipCurrent) { - if (data.idle) { - tooltipCurrent.textContent = 'All items processed'; - } else if (data.current_item && data.current_item.name) { - tooltipCurrent.textContent = `Now: ${data.current_item.name}`; - } - } - - if (data.progress && tooltipProgress) { - const artists = data.progress.artists || {}; - const albums = data.progress.albums || {}; - const tracks = data.progress.tracks || {}; - - const currentType = data.current_item?.type || ''; - let progressText = ''; - - const artistsComplete = artists.matched >= artists.total; - const albumsComplete = albums.matched >= albums.total; - - // Prioritize currentType over completion-based inference - if (currentType === 'artist') { - progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; - } else if (currentType.includes('album')) { - progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; - } else if (currentType.includes('track')) { - progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; - } else if (!artistsComplete) { - progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; - } else if (!albumsComplete) { - progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; - } else { - progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; - } - - tooltipProgress.textContent = progressText; - } - + updateiTunesEnrichmentStatusFromData(data); } catch (error) { console.error('Error updating iTunes enrichment status:', error); } } +function updateiTunesEnrichmentStatusFromData(data) { + const button = document.getElementById('itunes-enrich-button'); + if (!button) return; + + button.classList.remove('active', 'paused', 'complete'); + if (data.idle) { + button.classList.add('complete'); + } else if (data.running && !data.paused) { + button.classList.add('active'); + } else if (data.paused) { + button.classList.add('paused'); + } + + const tooltipStatus = document.getElementById('itunes-enrich-tooltip-status'); + const tooltipCurrent = document.getElementById('itunes-enrich-tooltip-current'); + const tooltipProgress = document.getElementById('itunes-enrich-tooltip-progress'); + + if (tooltipStatus) { + if (data.idle) { tooltipStatus.textContent = 'Complete'; } + else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; } + else if (data.paused) { tooltipStatus.textContent = 'Paused'; } + else { tooltipStatus.textContent = 'Idle'; } + } + + if (tooltipCurrent) { + if (data.idle) { + tooltipCurrent.textContent = 'All items processed'; + } else if (data.current_item && data.current_item.name) { + tooltipCurrent.textContent = `Now: ${data.current_item.name}`; + } + } + + if (data.progress && tooltipProgress) { + const artists = data.progress.artists || {}; + const albums = data.progress.albums || {}; + const tracks = data.progress.tracks || {}; + + const currentType = data.current_item?.type || ''; + let progressText = ''; + + const artistsComplete = artists.matched >= artists.total; + const albumsComplete = albums.matched >= albums.total; + + if (currentType === 'artist') { + progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; + } else if (currentType.includes('album')) { + progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; + } else if (currentType.includes('track')) { + progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; + } else if (!artistsComplete) { + progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; + } else if (!albumsComplete) { + progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; + } else { + progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; + } + + tooltipProgress.textContent = progressText; + } +} + async function toggleiTunesEnrichment() { try { const button = document.getElementById('itunes-enrich-button'); @@ -38852,40 +39669,44 @@ if (document.readyState === 'loading') { // =================================================================== async function updateHydrabaseStatus() { + if (socketConnected) return; // WebSocket handles this if (document.hidden) return; // Skip polling when tab is not visible try { const response = await fetch('/api/hydrabase-worker/status'); if (!response.ok) return; const data = await response.json(); - const button = document.getElementById('hydrabase-button'); - if (!button) return; - - button.classList.remove('active', 'paused'); - if (data.running && !data.paused) { - button.classList.add('active'); - } else if (data.paused) { - button.classList.add('paused'); - } - - // Update tooltip - const statusEl = document.getElementById('hydrabase-tooltip-status'); - if (statusEl) { - if (data.paused) { - statusEl.textContent = 'Paused'; - statusEl.style.color = '#ffc107'; - } else if (data.running) { - statusEl.textContent = 'Active'; - statusEl.style.color = '#ffffff'; - } else { - statusEl.textContent = 'Stopped'; - statusEl.style.color = '#ff5252'; - } - } + updateHydrabaseStatusFromData(data); } catch (error) { // Silently ignore — worker may not be available } } +function updateHydrabaseStatusFromData(data) { + const button = document.getElementById('hydrabase-button'); + if (!button) return; + + button.classList.remove('active', 'paused'); + if (data.running && !data.paused) { + button.classList.add('active'); + } else if (data.paused) { + button.classList.add('paused'); + } + + const statusEl = document.getElementById('hydrabase-tooltip-status'); + if (statusEl) { + if (data.paused) { + statusEl.textContent = 'Paused'; + statusEl.style.color = '#ffc107'; + } else if (data.running) { + statusEl.textContent = 'Active'; + statusEl.style.color = '#ffffff'; + } else { + statusEl.textContent = 'Stopped'; + statusEl.style.color = '#ff5252'; + } + } +} + async function toggleHydrabaseWorker() { const button = document.getElementById('hydrabase-button'); if (!button) return; @@ -38923,67 +39744,60 @@ if (document.readyState === 'loading') { // =================================================================== async function updateRepairStatus() { + if (socketConnected) return; // WebSocket handles this if (document.hidden) return; // Skip polling when tab is not visible try { const response = await fetch('/api/repair/status'); - if (!response.ok) { - console.warn('Repair status endpoint unavailable'); - return; - } - + if (!response.ok) { console.warn('Repair status endpoint unavailable'); return; } const data = await response.json(); - const button = document.getElementById('repair-button'); - if (!button) return; - - // Update button state classes - button.classList.remove('active', 'paused', 'complete'); - if (data.idle) { - button.classList.add('complete'); - } else if (data.running && !data.paused) { - button.classList.add('active'); - } else if (data.paused) { - button.classList.add('paused'); - } - - // Update tooltip content - const tooltipStatus = document.getElementById('repair-tooltip-status'); - const tooltipCurrent = document.getElementById('repair-tooltip-current'); - const tooltipProgress = document.getElementById('repair-tooltip-progress'); - - if (tooltipStatus) { - if (data.idle) { - tooltipStatus.textContent = 'Complete'; - } else if (data.running && !data.paused) { - tooltipStatus.textContent = 'Running'; - } else if (data.paused) { - tooltipStatus.textContent = 'Paused'; - } else { - tooltipStatus.textContent = 'Idle'; - } - } - - if (tooltipCurrent) { - if (data.idle) { - tooltipCurrent.textContent = 'All items processed'; - } else if (data.current_item && data.current_item.name) { - const type = data.current_item.type || 'item'; - const name = data.current_item.name; - tooltipCurrent.textContent = `${type.charAt(0).toUpperCase() + type.slice(1)}: "${name}"`; - } else { - tooltipCurrent.textContent = 'No active repairs'; - } - } - - if (tooltipProgress && data.progress) { - const tracks = data.progress.tracks || {}; - tooltipProgress.textContent = `Checked: ${tracks.checked || 0} / ${tracks.total || 0} (${tracks.percent || 0}%) | Repaired: ${tracks.repaired || 0}`; - } - + updateRepairStatusFromData(data); } catch (error) { console.error('Error updating repair status:', error); } } +function updateRepairStatusFromData(data) { + const button = document.getElementById('repair-button'); + if (!button) return; + + button.classList.remove('active', 'paused', 'complete'); + if (data.idle) { + button.classList.add('complete'); + } else if (data.running && !data.paused) { + button.classList.add('active'); + } else if (data.paused) { + button.classList.add('paused'); + } + + const tooltipStatus = document.getElementById('repair-tooltip-status'); + const tooltipCurrent = document.getElementById('repair-tooltip-current'); + const tooltipProgress = document.getElementById('repair-tooltip-progress'); + + if (tooltipStatus) { + if (data.idle) { tooltipStatus.textContent = 'Complete'; } + else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; } + else if (data.paused) { tooltipStatus.textContent = 'Paused'; } + else { tooltipStatus.textContent = 'Idle'; } + } + + if (tooltipCurrent) { + if (data.idle) { + tooltipCurrent.textContent = 'All items processed'; + } else if (data.current_item && data.current_item.name) { + const type = data.current_item.type || 'item'; + const name = data.current_item.name; + tooltipCurrent.textContent = `${type.charAt(0).toUpperCase() + type.slice(1)}: "${name}"`; + } else { + tooltipCurrent.textContent = 'No active repairs'; + } + } + + if (tooltipProgress && data.progress) { + const tracks = data.progress.tracks || {}; + tooltipProgress.textContent = `Checked: ${tracks.checked || 0} / ${tracks.total || 0} (${tracks.percent || 0}%) | Repaired: ${tracks.repaired || 0}`; + } +} + /** * Toggle repair worker pause/resume */ diff --git a/webui/static/style.css b/webui/static/style.css index 861f4120..5ba19a68 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -5460,7 +5460,7 @@ body { .header-button { flex: 1; - min-width: 120px; + min-width: 100%; } .service-status-grid, diff --git a/webui/static/vendor/socket.io.min.js b/webui/static/vendor/socket.io.min.js new file mode 100644 index 00000000..d6b2d601 --- /dev/null +++ b/webui/static/vendor/socket.io.min.js @@ -0,0 +1,7 @@ +/*! + * Socket.IO v4.7.5 + * (c) 2014-2024 Guillermo Rauch + * Released under the MIT License. + */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).io=t()}(this,(function(){"use strict";function e(t){return e="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},e(t)}function t(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function n(e,t){for(var n=0;ne.length)&&(t=e.length);for(var n=0,r=new Array(t);n=e.length?{done:!0}:{done:!1,value:e[r++]}},e:function(e){throw e},f:i}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var o,s=!0,a=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return s=e.done,e},e:function(e){a=!0,o=e},f:function(){try{s||null==n.return||n.return()}finally{if(a)throw o}}}}var v=Object.create(null);v.open="0",v.close="1",v.ping="2",v.pong="3",v.message="4",v.upgrade="5",v.noop="6";var g=Object.create(null);Object.keys(v).forEach((function(e){g[v[e]]=e}));var m,b={type:"error",data:"parser error"},k="function"==typeof Blob||"undefined"!=typeof Blob&&"[object BlobConstructor]"===Object.prototype.toString.call(Blob),w="function"==typeof ArrayBuffer,_=function(e){return"function"==typeof ArrayBuffer.isView?ArrayBuffer.isView(e):e&&e.buffer instanceof ArrayBuffer},E=function(e,t,n){var r=e.type,i=e.data;return k&&i instanceof Blob?t?n(i):A(i,n):w&&(i instanceof ArrayBuffer||_(i))?t?n(i):A(new Blob([i]),n):n(v[r]+(i||""))},A=function(e,t){var n=new FileReader;return n.onload=function(){var e=n.result.split(",")[1];t("b"+(e||""))},n.readAsDataURL(e)};function O(e){return e instanceof Uint8Array?e:e instanceof ArrayBuffer?new Uint8Array(e):new Uint8Array(e.buffer,e.byteOffset,e.byteLength)}for(var T="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",R="undefined"==typeof Uint8Array?[]:new Uint8Array(256),C=0;C<64;C++)R[T.charCodeAt(C)]=C;var B,S="function"==typeof ArrayBuffer,N=function(e,t){if("string"!=typeof e)return{type:"message",data:x(e,t)};var n=e.charAt(0);return"b"===n?{type:"message",data:L(e.substring(1),t)}:g[n]?e.length>1?{type:g[n],data:e.substring(1)}:{type:g[n]}:b},L=function(e,t){if(S){var n=function(e){var t,n,r,i,o,s=.75*e.length,a=e.length,c=0;"="===e[e.length-1]&&(s--,"="===e[e.length-2]&&s--);var u=new ArrayBuffer(s),h=new Uint8Array(u);for(t=0;t>4,h[c++]=(15&r)<<4|i>>2,h[c++]=(3&i)<<6|63&o;return u}(e);return x(n,t)}return{base64:!0,data:e}},x=function(e,t){return"blob"===t?e instanceof Blob?e:new Blob([e]):e instanceof ArrayBuffer?e:e.buffer},P=String.fromCharCode(30);function j(){return new TransformStream({transform:function(e,t){!function(e,t){k&&e.data instanceof Blob?e.data.arrayBuffer().then(O).then(t):w&&(e.data instanceof ArrayBuffer||_(e.data))?t(O(e.data)):E(e,!1,(function(e){m||(m=new TextEncoder),t(m.encode(e))}))}(e,(function(n){var r,i=n.length;if(i<126)r=new Uint8Array(1),new DataView(r.buffer).setUint8(0,i);else if(i<65536){r=new Uint8Array(3);var o=new DataView(r.buffer);o.setUint8(0,126),o.setUint16(1,i)}else{r=new Uint8Array(9);var s=new DataView(r.buffer);s.setUint8(0,127),s.setBigUint64(1,BigInt(i))}e.data&&"string"!=typeof e.data&&(r[0]|=128),t.enqueue(r),t.enqueue(n)}))}})}function q(e){return e.reduce((function(e,t){return e+t.length}),0)}function D(e,t){if(e[0].length===t)return e.shift();for(var n=new Uint8Array(t),r=0,i=0;i1?t-1:0),r=1;r1&&void 0!==arguments[1]?arguments[1]:{};return e+"://"+this._hostname()+this._port()+this.opts.path+this._query(t)}},{key:"_hostname",value:function(){var e=this.opts.hostname;return-1===e.indexOf(":")?e:"["+e+"]"}},{key:"_port",value:function(){return this.opts.port&&(this.opts.secure&&Number(443!==this.opts.port)||!this.opts.secure&&80!==Number(this.opts.port))?":"+this.opts.port:""}},{key:"_query",value:function(e){var t=function(e){var t="";for(var n in e)e.hasOwnProperty(n)&&(t.length&&(t+="&"),t+=encodeURIComponent(n)+"="+encodeURIComponent(e[n]));return t}(e);return t.length?"?"+t:""}}]),i}(U),z="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_".split(""),J=64,$={},Q=0,X=0;function G(e){var t="";do{t=z[e%J]+t,e=Math.floor(e/J)}while(e>0);return t}function Z(){var e=G(+new Date);return e!==K?(Q=0,K=e):e+"."+G(Q++)}for(;X0&&void 0!==arguments[0]?arguments[0]:{};return i(e,{xd:this.xd,cookieJar:this.cookieJar},this.opts),new se(this.uri(),e)}},{key:"doWrite",value:function(e,t){var n=this,r=this.request({method:"POST",data:e});r.on("success",t),r.on("error",(function(e,t){n.onError("xhr post error",e,t)}))}},{key:"doPoll",value:function(){var e=this,t=this.request();t.on("data",this.onData.bind(this)),t.on("error",(function(t,n){e.onError("xhr poll error",t,n)})),this.pollXhr=t}}]),s}(W),se=function(e){o(i,e);var n=l(i);function i(e,r){var o;return t(this,i),H(f(o=n.call(this)),r),o.opts=r,o.method=r.method||"GET",o.uri=e,o.data=void 0!==r.data?r.data:null,o.create(),o}return r(i,[{key:"create",value:function(){var e,t=this,n=F(this.opts,"agent","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","autoUnref");n.xdomain=!!this.opts.xd;var r=this.xhr=new ne(n);try{r.open(this.method,this.uri,!0);try{if(this.opts.extraHeaders)for(var o in r.setDisableHeaderCheck&&r.setDisableHeaderCheck(!0),this.opts.extraHeaders)this.opts.extraHeaders.hasOwnProperty(o)&&r.setRequestHeader(o,this.opts.extraHeaders[o])}catch(e){}if("POST"===this.method)try{r.setRequestHeader("Content-type","text/plain;charset=UTF-8")}catch(e){}try{r.setRequestHeader("Accept","*/*")}catch(e){}null===(e=this.opts.cookieJar)||void 0===e||e.addCookies(r),"withCredentials"in r&&(r.withCredentials=this.opts.withCredentials),this.opts.requestTimeout&&(r.timeout=this.opts.requestTimeout),r.onreadystatechange=function(){var e;3===r.readyState&&(null===(e=t.opts.cookieJar)||void 0===e||e.parseCookies(r)),4===r.readyState&&(200===r.status||1223===r.status?t.onLoad():t.setTimeoutFn((function(){t.onError("number"==typeof r.status?r.status:0)}),0))},r.send(this.data)}catch(e){return void this.setTimeoutFn((function(){t.onError(e)}),0)}"undefined"!=typeof document&&(this.index=i.requestsCount++,i.requests[this.index]=this)}},{key:"onError",value:function(e){this.emitReserved("error",e,this.xhr),this.cleanup(!0)}},{key:"cleanup",value:function(e){if(void 0!==this.xhr&&null!==this.xhr){if(this.xhr.onreadystatechange=re,e)try{this.xhr.abort()}catch(e){}"undefined"!=typeof document&&delete i.requests[this.index],this.xhr=null}}},{key:"onLoad",value:function(){var e=this.xhr.responseText;null!==e&&(this.emitReserved("data",e),this.emitReserved("success"),this.cleanup())}},{key:"abort",value:function(){this.cleanup()}}]),i}(U);if(se.requestsCount=0,se.requests={},"undefined"!=typeof document)if("function"==typeof attachEvent)attachEvent("onunload",ae);else if("function"==typeof addEventListener){addEventListener("onpagehide"in I?"pagehide":"unload",ae,!1)}function ae(){for(var e in se.requests)se.requests.hasOwnProperty(e)&&se.requests[e].abort()}var ce="function"==typeof Promise&&"function"==typeof Promise.resolve?function(e){return Promise.resolve().then(e)}:function(e,t){return t(e,0)},ue=I.WebSocket||I.MozWebSocket,he="undefined"!=typeof navigator&&"string"==typeof navigator.product&&"reactnative"===navigator.product.toLowerCase(),fe=function(e){o(i,e);var n=l(i);function i(e){var r;return t(this,i),(r=n.call(this,e)).supportsBinary=!e.forceBase64,r}return r(i,[{key:"name",get:function(){return"websocket"}},{key:"doOpen",value:function(){if(this.check()){var e=this.uri(),t=this.opts.protocols,n=he?{}:F(this.opts,"agent","perMessageDeflate","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","localAddress","protocolVersion","origin","maxPayload","family","checkServerIdentity");this.opts.extraHeaders&&(n.headers=this.opts.extraHeaders);try{this.ws=he?new ue(e,t,n):t?new ue(e,t):new ue(e)}catch(e){return this.emitReserved("error",e)}this.ws.binaryType=this.socket.binaryType,this.addEventListeners()}}},{key:"addEventListeners",value:function(){var e=this;this.ws.onopen=function(){e.opts.autoUnref&&e.ws._socket.unref(),e.onOpen()},this.ws.onclose=function(t){return e.onClose({description:"websocket connection closed",context:t})},this.ws.onmessage=function(t){return e.onData(t.data)},this.ws.onerror=function(t){return e.onError("websocket error",t)}}},{key:"write",value:function(e){var t=this;this.writable=!1;for(var n=function(){var n=e[r],i=r===e.length-1;E(n,t.supportsBinary,(function(e){try{t.ws.send(e)}catch(e){}i&&ce((function(){t.writable=!0,t.emitReserved("drain")}),t.setTimeoutFn)}))},r=0;rMath.pow(2,21)-1){a.enqueue(b);break}i=l*Math.pow(2,32)+f.getUint32(4),r=3}else{if(q(n)e){a.enqueue(b);break}}}})}(Number.MAX_SAFE_INTEGER,e.socket.binaryType),r=t.readable.pipeThrough(n).getReader(),i=j();i.readable.pipeTo(t.writable),e.writer=i.writable.getWriter();!function t(){r.read().then((function(n){var r=n.done,i=n.value;r||(e.onPacket(i),t())})).catch((function(e){}))}();var o={type:"open"};e.query.sid&&(o.data='{"sid":"'.concat(e.query.sid,'"}')),e.writer.write(o).then((function(){return e.onOpen()}))}))})))}},{key:"write",value:function(e){var t=this;this.writable=!1;for(var n=function(){var n=e[r],i=r===e.length-1;t.writer.write(n).then((function(){i&&ce((function(){t.writable=!0,t.emitReserved("drain")}),t.setTimeoutFn)}))},r=0;r1&&void 0!==arguments[1]?arguments[1]:{};return t(this,a),(r=s.call(this)).binaryType="arraybuffer",r.writeBuffer=[],n&&"object"===e(n)&&(o=n,n=null),n?(n=ve(n),o.hostname=n.host,o.secure="https"===n.protocol||"wss"===n.protocol,o.port=n.port,n.query&&(o.query=n.query)):o.host&&(o.hostname=ve(o.host).host),H(f(r),o),r.secure=null!=o.secure?o.secure:"undefined"!=typeof location&&"https:"===location.protocol,o.hostname&&!o.port&&(o.port=r.secure?"443":"80"),r.hostname=o.hostname||("undefined"!=typeof location?location.hostname:"localhost"),r.port=o.port||("undefined"!=typeof location&&location.port?location.port:r.secure?"443":"80"),r.transports=o.transports||["polling","websocket","webtransport"],r.writeBuffer=[],r.prevBufferLen=0,r.opts=i({path:"/engine.io",agent:!1,withCredentials:!1,upgrade:!0,timestampParam:"t",rememberUpgrade:!1,addTrailingSlash:!0,rejectUnauthorized:!0,perMessageDeflate:{threshold:1024},transportOptions:{},closeOnBeforeunload:!1},o),r.opts.path=r.opts.path.replace(/\/$/,"")+(r.opts.addTrailingSlash?"/":""),"string"==typeof r.opts.query&&(r.opts.query=function(e){for(var t={},n=e.split("&"),r=0,i=n.length;r1))return this.writeBuffer;for(var e,t=1,n=0;n=57344?n+=3:(r++,n+=4);return n}(e):Math.ceil(1.33*(e.byteLength||e.size))),n>0&&t>this.maxPayload)return this.writeBuffer.slice(0,n);t+=2}return this.writeBuffer}},{key:"write",value:function(e,t,n){return this.sendPacket("message",e,t,n),this}},{key:"send",value:function(e,t,n){return this.sendPacket("message",e,t,n),this}},{key:"sendPacket",value:function(e,t,n,r){if("function"==typeof t&&(r=t,t=void 0),"function"==typeof n&&(r=n,n=null),"closing"!==this.readyState&&"closed"!==this.readyState){(n=n||{}).compress=!1!==n.compress;var i={type:e,data:t,options:n};this.emitReserved("packetCreate",i),this.writeBuffer.push(i),r&&this.once("flush",r),this.flush()}}},{key:"close",value:function(){var e=this,t=function(){e.onClose("forced close"),e.transport.close()},n=function n(){e.off("upgrade",n),e.off("upgradeError",n),t()},r=function(){e.once("upgrade",n),e.once("upgradeError",n)};return"opening"!==this.readyState&&"open"!==this.readyState||(this.readyState="closing",this.writeBuffer.length?this.once("drain",(function(){e.upgrading?r():t()})):this.upgrading?r():t()),this}},{key:"onError",value:function(e){a.priorWebsocketSuccess=!1,this.emitReserved("error",e),this.onClose("transport error",e)}},{key:"onClose",value:function(e,t){"opening"!==this.readyState&&"open"!==this.readyState&&"closing"!==this.readyState||(this.clearTimeoutFn(this.pingTimeoutTimer),this.transport.removeAllListeners("close"),this.transport.close(),this.transport.removeAllListeners(),"function"==typeof removeEventListener&&(removeEventListener("beforeunload",this.beforeunloadEventListener,!1),removeEventListener("offline",this.offlineEventListener,!1)),this.readyState="closed",this.id=null,this.emitReserved("close",e,t),this.writeBuffer=[],this.prevBufferLen=0)}},{key:"filterUpgrades",value:function(e){for(var t=[],n=0,r=e.length;n=0&&t.num1?t-1:0),r=1;r1?n-1:0),i=1;in._opts.retries&&(n._queue.shift(),t&&t(e));else if(n._queue.shift(),t){for(var i=arguments.length,o=new Array(i>1?i-1:0),s=1;s0&&void 0!==arguments[0]&&arguments[0];if(this.connected&&0!==this._queue.length){var t=this._queue[0];t.pending&&!e||(t.pending=!0,t.tryCount++,this.flags=t.flags,this.emit.apply(this,t.args))}}},{key:"packet",value:function(e){e.nsp=this.nsp,this.io._packet(e)}},{key:"onopen",value:function(){var e=this;"function"==typeof this.auth?this.auth((function(t){e._sendConnectPacket(t)})):this._sendConnectPacket(this.auth)}},{key:"_sendConnectPacket",value:function(e){this.packet({type:Be.CONNECT,data:this._pid?i({pid:this._pid,offset:this._lastOffset},e):e})}},{key:"onerror",value:function(e){this.connected||this.emitReserved("connect_error",e)}},{key:"onclose",value:function(e,t){this.connected=!1,delete this.id,this.emitReserved("disconnect",e,t),this._clearAcks()}},{key:"_clearAcks",value:function(){var e=this;Object.keys(this.acks).forEach((function(t){if(!e.sendBuffer.some((function(e){return String(e.id)===t}))){var n=e.acks[t];delete e.acks[t],n.withError&&n.call(e,new Error("socket has been disconnected"))}}))}},{key:"onpacket",value:function(e){if(e.nsp===this.nsp)switch(e.type){case Be.CONNECT:e.data&&e.data.sid?this.onconnect(e.data.sid,e.data.pid):this.emitReserved("connect_error",new Error("It seems you are trying to reach a Socket.IO server in v2.x with a v3.x client, but they are not compatible (more information here: https://socket.io/docs/v3/migrating-from-2-x-to-3-0/)"));break;case Be.EVENT:case Be.BINARY_EVENT:this.onevent(e);break;case Be.ACK:case Be.BINARY_ACK:this.onack(e);break;case Be.DISCONNECT:this.ondisconnect();break;case Be.CONNECT_ERROR:this.destroy();var t=new Error(e.data.message);t.data=e.data.data,this.emitReserved("connect_error",t)}}},{key:"onevent",value:function(e){var t=e.data||[];null!=e.id&&t.push(this.ack(e.id)),this.connected?this.emitEvent(t):this.receiveBuffer.push(Object.freeze(t))}},{key:"emitEvent",value:function(e){if(this._anyListeners&&this._anyListeners.length){var t,n=y(this._anyListeners.slice());try{for(n.s();!(t=n.n()).done;){t.value.apply(this,e)}}catch(e){n.e(e)}finally{n.f()}}p(s(a.prototype),"emit",this).apply(this,e),this._pid&&e.length&&"string"==typeof e[e.length-1]&&(this._lastOffset=e[e.length-1])}},{key:"ack",value:function(e){var t=this,n=!1;return function(){if(!n){n=!0;for(var r=arguments.length,i=new Array(r),o=0;o0&&e.jitter<=1?e.jitter:0,this.attempts=0}Ie.prototype.duration=function(){var e=this.ms*Math.pow(this.factor,this.attempts++);if(this.jitter){var t=Math.random(),n=Math.floor(t*this.jitter*e);e=0==(1&Math.floor(10*t))?e-n:e+n}return 0|Math.min(e,this.max)},Ie.prototype.reset=function(){this.attempts=0},Ie.prototype.setMin=function(e){this.ms=e},Ie.prototype.setMax=function(e){this.max=e},Ie.prototype.setJitter=function(e){this.jitter=e};var Fe=function(n){o(s,n);var i=l(s);function s(n,r){var o,a;t(this,s),(o=i.call(this)).nsps={},o.subs=[],n&&"object"===e(n)&&(r=n,n=void 0),(r=r||{}).path=r.path||"/socket.io",o.opts=r,H(f(o),r),o.reconnection(!1!==r.reconnection),o.reconnectionAttempts(r.reconnectionAttempts||1/0),o.reconnectionDelay(r.reconnectionDelay||1e3),o.reconnectionDelayMax(r.reconnectionDelayMax||5e3),o.randomizationFactor(null!==(a=r.randomizationFactor)&&void 0!==a?a:.5),o.backoff=new Ie({min:o.reconnectionDelay(),max:o.reconnectionDelayMax(),jitter:o.randomizationFactor()}),o.timeout(null==r.timeout?2e4:r.timeout),o._readyState="closed",o.uri=n;var c=r.parser||je;return o.encoder=new c.Encoder,o.decoder=new c.Decoder,o._autoConnect=!1!==r.autoConnect,o._autoConnect&&o.open(),o}return r(s,[{key:"reconnection",value:function(e){return arguments.length?(this._reconnection=!!e,this):this._reconnection}},{key:"reconnectionAttempts",value:function(e){return void 0===e?this._reconnectionAttempts:(this._reconnectionAttempts=e,this)}},{key:"reconnectionDelay",value:function(e){var t;return void 0===e?this._reconnectionDelay:(this._reconnectionDelay=e,null===(t=this.backoff)||void 0===t||t.setMin(e),this)}},{key:"randomizationFactor",value:function(e){var t;return void 0===e?this._randomizationFactor:(this._randomizationFactor=e,null===(t=this.backoff)||void 0===t||t.setJitter(e),this)}},{key:"reconnectionDelayMax",value:function(e){var t;return void 0===e?this._reconnectionDelayMax:(this._reconnectionDelayMax=e,null===(t=this.backoff)||void 0===t||t.setMax(e),this)}},{key:"timeout",value:function(e){return arguments.length?(this._timeout=e,this):this._timeout}},{key:"maybeReconnectOnOpen",value:function(){!this._reconnecting&&this._reconnection&&0===this.backoff.attempts&&this.reconnect()}},{key:"open",value:function(e){var t=this;if(~this._readyState.indexOf("open"))return this;this.engine=new ge(this.uri,this.opts);var n=this.engine,r=this;this._readyState="opening",this.skipReconnect=!1;var i=qe(n,"open",(function(){r.onopen(),e&&e()})),o=function(n){t.cleanup(),t._readyState="closed",t.emitReserved("error",n),e?e(n):t.maybeReconnectOnOpen()},s=qe(n,"error",o);if(!1!==this._timeout){var a=this._timeout,c=this.setTimeoutFn((function(){i(),o(new Error("timeout")),n.close()}),a);this.opts.autoUnref&&c.unref(),this.subs.push((function(){t.clearTimeoutFn(c)}))}return this.subs.push(i),this.subs.push(s),this}},{key:"connect",value:function(e){return this.open(e)}},{key:"onopen",value:function(){this.cleanup(),this._readyState="open",this.emitReserved("open");var e=this.engine;this.subs.push(qe(e,"ping",this.onping.bind(this)),qe(e,"data",this.ondata.bind(this)),qe(e,"error",this.onerror.bind(this)),qe(e,"close",this.onclose.bind(this)),qe(this.decoder,"decoded",this.ondecoded.bind(this)))}},{key:"onping",value:function(){this.emitReserved("ping")}},{key:"ondata",value:function(e){try{this.decoder.add(e)}catch(e){this.onclose("parse error",e)}}},{key:"ondecoded",value:function(e){var t=this;ce((function(){t.emitReserved("packet",e)}),this.setTimeoutFn)}},{key:"onerror",value:function(e){this.emitReserved("error",e)}},{key:"socket",value:function(e,t){var n=this.nsps[e];return n?this._autoConnect&&!n.active&&n.connect():(n=new Ue(this,e,t),this.nsps[e]=n),n}},{key:"_destroy",value:function(e){for(var t=0,n=Object.keys(this.nsps);t=this._reconnectionAttempts)this.backoff.reset(),this.emitReserved("reconnect_failed"),this._reconnecting=!1;else{var n=this.backoff.duration();this._reconnecting=!0;var r=this.setTimeoutFn((function(){t.skipReconnect||(e.emitReserved("reconnect_attempt",t.backoff.attempts),t.skipReconnect||t.open((function(n){n?(t._reconnecting=!1,t.reconnect(),e.emitReserved("reconnect_error",n)):t.onreconnect()})))}),n);this.opts.autoUnref&&r.unref(),this.subs.push((function(){e.clearTimeoutFn(r)}))}}},{key:"onreconnect",value:function(){var e=this.backoff.attempts;this._reconnecting=!1,this.backoff.reset(),this.emitReserved("reconnect",e)}}]),s}(U),Me={};function Ve(t,n){"object"===e(t)&&(n=t,t=void 0);var r,i=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",n=arguments.length>2?arguments[2]:void 0,r=e;n=n||"undefined"!=typeof location&&location,null==e&&(e=n.protocol+"//"+n.host),"string"==typeof e&&("/"===e.charAt(0)&&(e="/"===e.charAt(1)?n.protocol+e:n.host+e),/^(https?|wss?):\/\//.test(e)||(e=void 0!==n?n.protocol+"//"+e:"https://"+e),r=ve(e)),r.port||(/^(http|ws)$/.test(r.protocol)?r.port="80":/^(http|ws)s$/.test(r.protocol)&&(r.port="443")),r.path=r.path||"/";var i=-1!==r.host.indexOf(":")?"["+r.host+"]":r.host;return r.id=r.protocol+"://"+i+":"+r.port+t,r.href=r.protocol+"://"+i+(n&&n.port===r.port?"":":"+r.port),r}(t,(n=n||{}).path||"/socket.io"),o=i.source,s=i.id,a=i.path,c=Me[s]&&a in Me[s].nsps;return n.forceNew||n["force new connection"]||!1===n.multiplex||c?r=new Fe(o,n):(Me[s]||(Me[s]=new Fe(o,n)),r=Me[s]),i.query&&!n.query&&(n.query=i.queryKey),r.socket(i.path,n)}return i(Ve,{Manager:Fe,Socket:Ue,io:Ve,connect:Ve}),Ve})); +//# sourceMappingURL=socket.io.min.js.map