From 9898bd11906e833ba7dba31187415a346a6c1949 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:18:00 -0700 Subject: [PATCH] Add batch context panel to Downloads page Split Downloads page into main list (left) and batch panel (right). Each active batch gets a color-coded card with artwork thumbnail, progress bar, per-track status with download percentages, and expandable track list. Download rows get matching color indicators. - Click batch name to open its download/wishlist modal - Filter icon narrows main list to one batch with clear banner - Collapsible panel toggle for full-width list view - Completed batches fade out after 15 seconds - 7-day batch history with source type color dots - Artwork fallback shows colored initial when no art available - Per-track progress: download %, spinner for searching, proc label - source_page column on sync_history for UI origin tracking - /api/downloads/all includes batch summaries and per-track progress - /api/downloads/batch-history endpoint for history queries - Responsive layout, overflow-x hidden to prevent scroll flicker --- database/music_database.py | 38 +++- web_server.py | 76 ++++++- webui/index.html | 56 +++-- webui/static/helper.js | 1 + webui/static/script.js | 386 ++++++++++++++++++++++++++++++- webui/static/style.css | 454 ++++++++++++++++++++++++++++++++++++- 6 files changed, 976 insertions(+), 35 deletions(-) diff --git a/database/music_database.py b/database/music_database.py index 9cd3ca67..d7ad5e64 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -645,6 +645,16 @@ class MusicDatabase: except Exception: pass + # Migration: add source_page column to sync_history (UI origin context for batch panel) + try: + cursor.execute("SELECT source_page FROM sync_history LIMIT 1") + except Exception: + try: + cursor.execute("ALTER TABLE sync_history ADD COLUMN source_page TEXT") + logger.info("Added source_page column to sync_history table") + except Exception: + pass + # Migration: add track_artist column for per-track artist on compilations/DJ mixes try: cursor.execute("SELECT track_artist FROM tracks LIMIT 1") @@ -10202,7 +10212,7 @@ class MusicDatabase: def add_sync_history_entry(self, batch_id, playlist_id, playlist_name, source, sync_type, tracks_json, artist_context=None, album_context=None, thumb_url=None, total_tracks=0, is_album_download=False, - playlist_folder_mode=False): + playlist_folder_mode=False, source_page=None): """Record a new sync operation to sync_history.""" try: conn = self._get_connection() @@ -10210,11 +10220,11 @@ class MusicDatabase: cursor.execute(""" INSERT INTO sync_history (batch_id, playlist_id, playlist_name, source, sync_type, tracks_json, artist_context, album_context, thumb_url, total_tracks, - is_album_download, playlist_folder_mode) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + is_album_download, playlist_folder_mode, source_page) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, (batch_id, playlist_id, playlist_name, source, sync_type, tracks_json, artist_context, album_context, thumb_url, total_tracks, - int(is_album_download), int(playlist_folder_mode))) + int(is_album_download), int(playlist_folder_mode), source_page)) conn.commit() # Cap at 100 entries cursor.execute(""" @@ -10364,6 +10374,26 @@ class MusicDatabase: logger.debug(f"Error getting sync history stats: {e}") return {} + def get_recent_batch_history(self, days: int = 7, limit: int = 50) -> List[Dict[str, Any]]: + """Get completed batch history from the last N days for the downloads batch panel.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT id, batch_id, playlist_name, source, sync_type, source_page, + total_tracks, tracks_found, tracks_downloaded, tracks_failed, + thumb_url, is_album_download, started_at, completed_at + FROM sync_history + WHERE completed_at IS NOT NULL + AND started_at >= datetime('now', ? || ' days') + ORDER BY started_at DESC + LIMIT ? + """, (f'-{days}', limit)) + return [dict(row) for row in cursor.fetchall()] + except Exception as e: + logger.error(f"Error getting recent batch history: {e}") + return [] + def api_get_recently_added(self, entity_type: str = "albums", limit: int = 50) -> List[Dict[str, Any]]: """Get recently added entities, ordered by created_at DESC.""" table = {"artists": "artists", "albums": "albums", "tracks": "tracks"}.get(entity_type) diff --git a/web_server.py b/web_server.py index 585dcf4c..12d1e9cf 100644 --- a/web_server.py +++ b/web_server.py @@ -22313,6 +22313,7 @@ def get_version_info(): "• Reject Qobuz 30-second sample/preview downloads", "• Fix slskd timeout spam — dashboard and download status skip slskd polling when Soulseek is not active or disconnected", "• Fix Soulseek search queries missing album name — reduces wrong-artist downloads", + "• Downloads batch panel — color-coded batch cards with progress, cancel, expand, and 7-day history", ], }, { @@ -30282,6 +30283,22 @@ def get_all_downloads_unified(): artwork = images[0].get('url', '') if isinstance(images[0], dict) else str(images[0]) status = task.get('status', 'queued') + # Determine download progress percentage + progress = 0 + if status == 'completed': + progress = 100 + elif status == 'post_processing': + progress = 95 + elif status in ('downloading', 'searching'): + # Check live transfer data for real progress + task_filename = task.get('filename') or track_info.get('filename') + task_username = task.get('username') or track_info.get('username') + if task_filename and task_username: + lookup_key = _make_context_key(task_username, task_filename) + live_info = get_cached_transfer_data().get(lookup_key) + if live_info: + progress = live_info.get('percentComplete', 0) + items.append({ 'task_id': task_id, 'title': title, @@ -30289,6 +30306,7 @@ def get_all_downloads_unified(): 'album': album, 'artwork': artwork, 'status': status, + 'progress': progress, 'error': task.get('error_message'), 'batch_id': batch_id, 'batch_name': batch.get('playlist_name') or batch.get('album_name') or '', @@ -30302,10 +30320,30 @@ def get_all_downloads_unified(): # Sort: active first (by priority), then by timestamp desc within each group items.sort(key=lambda x: (x['priority'], -x['timestamp'])) + # Build batch summaries for the batch context panel + batch_summaries = [] + with tasks_lock: + for bid, batch in download_batches.items(): + queue = batch.get('queue', []) + statuses = [download_tasks[tid]['status'] for tid in queue if tid in download_tasks] + batch_summaries.append({ + 'batch_id': bid, + 'playlist_id': batch.get('playlist_id', ''), + 'batch_name': batch.get('playlist_name') or batch.get('album_name') or '', + 'source_page': batch.get('source_page') or batch.get('initiated_from') or '', + 'phase': batch.get('phase', 'unknown'), + 'total': len(queue), + 'completed': sum(1 for s in statuses if s in ('completed', 'skipped', 'already_owned')), + 'failed': sum(1 for s in statuses if s in ('failed', 'not_found', 'cancelled')), + 'active': sum(1 for s in statuses if s in ('downloading', 'searching', 'post_processing')), + 'queued': sum(1 for s in statuses if s in ('queued', 'pending')), + }) + return jsonify({ 'success': True, 'downloads': items[:limit], 'total': len(items), + 'batches': batch_summaries, 'timestamp': time.time(), }) except Exception as e: @@ -30313,6 +30351,20 @@ def get_all_downloads_unified(): return jsonify({'success': False, 'error': str(e)}), 500 +@app.route('/api/downloads/batch-history', methods=['GET']) +def get_batch_history(): + """Return completed batch summaries from the last N days for the batch panel history section.""" + try: + days = int(request.args.get('days', 7)) + limit = int(request.args.get('limit', 50)) + database = get_database() + history = database.get_recent_batch_history(days=days, limit=limit) + return jsonify({'success': True, 'history': history}) + except Exception as e: + logger.error(f"Error getting batch history: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + @app.route('/api/downloads/clear-completed', methods=['POST']) def clear_completed_downloads(): """Remove completed/failed/cancelled tasks from the download tracker.""" @@ -31248,7 +31300,7 @@ def _detect_sync_source(playlist_id): def _record_sync_history_start(batch_id, playlist_id, playlist_name, tracks, is_album_download, album_context, artist_context, - playlist_folder_mode): + playlist_folder_mode, source_page=None): """Record a sync start to the database. If a previous sync history entry exists for the same playlist_id, update it instead of creating a duplicate.""" @@ -31289,7 +31341,7 @@ def _record_sync_history_start(batch_id, playlist_id, playlist_name, tracks, SET batch_id = ?, playlist_name = ?, source = ?, sync_type = ?, tracks_json = ?, artist_context = ?, album_context = ?, thumb_url = ?, total_tracks = ?, is_album_download = ?, - playlist_folder_mode = ?, started_at = CURRENT_TIMESTAMP, + playlist_folder_mode = ?, source_page = ?, started_at = CURRENT_TIMESTAMP, completed_at = NULL, tracks_found = 0, tracks_downloaded = 0, tracks_failed = 0 WHERE id = ? """, (batch_id, playlist_name, source, sync_type, @@ -31297,7 +31349,7 @@ def _record_sync_history_start(batch_id, playlist_id, playlist_name, tracks, json.dumps(artist_context, ensure_ascii=False) if artist_context else None, json.dumps(album_context, ensure_ascii=False) if album_context else None, thumb_url, len(tracks), int(is_album_download), int(playlist_folder_mode), - existing['id'])) + source_page, existing['id'])) conn.commit() logger.info(f"Updated existing sync history entry {existing['id']} for '{playlist_name}'") return @@ -31316,7 +31368,8 @@ def _record_sync_history_start(batch_id, playlist_id, playlist_name, tracks, thumb_url=thumb_url, total_tracks=len(tracks), is_album_download=is_album_download, - playlist_folder_mode=playlist_folder_mode + playlist_folder_mode=playlist_folder_mode, + source_page=source_page ) except Exception as e: logger.warning(f"Failed to record sync history start: {e}") @@ -32031,10 +32084,18 @@ def start_missing_tracks_process(playlist_id): 'wing_it': wing_it, } - # Record sync history + # Record sync history — derive source_page from context + if playlist_id == 'wishlist': + _source_page = 'wishlist' + elif is_album_download: + _source_page = 'album' + elif playlist_id.startswith('youtube_'): + _source_page = 'sync' + else: + _source_page = 'sync' _record_sync_history_start(batch_id, playlist_id, playlist_name, tracks, is_album_download, album_context, artist_context, - playlist_folder_mode) + playlist_folder_mode, source_page=_source_page) # Link YouTube playlist to download process if this is a YouTube playlist if playlist_id.startswith('youtube_'): @@ -37854,7 +37915,8 @@ def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, is_album_download=False, album_context=None, artist_context=None, - playlist_folder_mode=False + playlist_folder_mode=False, + source_page='sync' ) try: diff --git a/webui/index.html b/webui/index.html index c92051d7..8f03b600 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2339,25 +2339,51 @@