diff --git a/requirements.txt b/requirements.txt index 3dd0973..c8da5ad 100755 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,4 @@ waitress==3.0.2 celery==5.5.3 Flask==3.1.1 flask_cors==6.0.0 -deezspot-spotizerr==1.8.0 +deezspot-spotizerr==1.10.0 \ No newline at end of file diff --git a/routes/history.py b/routes/history.py index 4c2f238..e34a328 100644 --- a/routes/history.py +++ b/routes/history.py @@ -15,20 +15,38 @@ def get_download_history(): sort_by = request.args.get("sort_by", "timestamp_completed") sort_order = request.args.get("sort_order", "DESC") - # Basic filtering example: filter by status_final or download_type + # Create filters dictionary for various filter options filters = {} + + # Status filter status_filter = request.args.get("status_final") if status_filter: filters["status_final"] = status_filter + # Download type filter type_filter = request.args.get("download_type") if type_filter: filters["download_type"] = type_filter - - # Add more filters as needed, e.g., by item_name (would need LIKE for partial match) - # search_term = request.args.get('search') - # if search_term: - # filters['item_name'] = f'%{search_term}%' # This would require LIKE in get_history_entries + + # Parent task filter + parent_task_filter = request.args.get("parent_task_id") + if parent_task_filter: + filters["parent_task_id"] = parent_task_filter + + # Track status filter + track_status_filter = request.args.get("track_status") + if track_status_filter: + filters["track_status"] = track_status_filter + + # Show/hide child tracks + hide_child_tracks = request.args.get("hide_child_tracks", "false").lower() == "true" + if hide_child_tracks: + filters["parent_task_id"] = None # Only show parent entries or standalone tracks + + # Show only tracks with specific parent + only_parent_tracks = request.args.get("only_parent_tracks", "false").lower() == "true" + if only_parent_tracks and not parent_task_filter: + filters["parent_task_id"] = "NOT_NULL" # Special value to indicate we want only child tracks entries, total_count = get_history_entries( limit, offset, sort_by, sort_order, filters @@ -45,3 +63,34 @@ def get_download_history(): except Exception as e: logger.error(f"Error in /api/history endpoint: {e}", exc_info=True) return jsonify({"error": "Failed to retrieve download history"}), 500 + + +@history_bp.route("/tracks/", methods=["GET"]) +def get_tracks_for_parent(parent_task_id): + """API endpoint to retrieve all track entries for a specific parent task.""" + try: + # We don't need pagination for this endpoint as we want all tracks for a parent + filters = {"parent_task_id": parent_task_id} + + # Optional sorting + sort_by = request.args.get("sort_by", "timestamp_completed") + sort_order = request.args.get("sort_order", "DESC") + + entries, total_count = get_history_entries( + limit=1000, # High limit to get all tracks + offset=0, + sort_by=sort_by, + sort_order=sort_order, + filters=filters + ) + + return jsonify( + { + "parent_task_id": parent_task_id, + "tracks": entries, + "total_count": total_count, + } + ) + except Exception as e: + logger.error(f"Error in /api/history/tracks endpoint: {e}", exc_info=True) + return jsonify({"error": f"Failed to retrieve tracks for parent task {parent_task_id}"}), 500 diff --git a/routes/prgs.py b/routes/prgs.py index c6d0d92..23ae233 100755 --- a/routes/prgs.py +++ b/routes/prgs.py @@ -76,13 +76,21 @@ def get_task_details(task_id): last_status = get_last_task_status(task_id) status_count = len(get_task_status(task_id)) + + # Default to the full last_status object, then check for the raw callback + last_line_content = last_status + if last_status and "raw_callback" in last_status: + last_line_content = last_status["raw_callback"] + response = { "original_url": dynamic_original_url, - "last_line": last_status, + "last_line": last_line_content, "timestamp": time.time(), "task_id": task_id, "status_count": status_count, } + if last_status and last_status.get("summary"): + response["summary"] = last_status["summary"] return jsonify(response) @@ -122,33 +130,34 @@ def list_tasks(): last_status = get_last_task_status(task_id) if task_info and last_status: - detailed_tasks.append( - { - "task_id": task_id, - "type": task_info.get( - "type", task_summary.get("type", "unknown") - ), - "name": task_info.get( - "name", task_summary.get("name", "Unknown") - ), - "artist": task_info.get( - "artist", task_summary.get("artist", "") - ), - "download_type": task_info.get( - "download_type", - task_summary.get("download_type", "unknown"), - ), - "status": last_status.get( - "status", "unknown" - ), # Keep summary status for quick access - "last_status_obj": last_status, # Full last status object - "original_request": task_info.get("original_request", {}), - "created_at": task_info.get("created_at", 0), - "timestamp": last_status.get( - "timestamp", task_info.get("created_at", 0) - ), - } - ) + task_details = { + "task_id": task_id, + "type": task_info.get( + "type", task_summary.get("type", "unknown") + ), + "name": task_info.get( + "name", task_summary.get("name", "Unknown") + ), + "artist": task_info.get( + "artist", task_summary.get("artist", "") + ), + "download_type": task_info.get( + "download_type", + task_summary.get("download_type", "unknown"), + ), + "status": last_status.get( + "status", "unknown" + ), # Keep summary status for quick access + "last_status_obj": last_status, # Full last status object + "original_request": task_info.get("original_request", {}), + "created_at": task_info.get("created_at", 0), + "timestamp": last_status.get( + "timestamp", task_info.get("created_at", 0) + ), + } + if last_status.get("summary"): + task_details["summary"] = last_status["summary"] + detailed_tasks.append(task_details) elif ( task_info ): # If last_status is somehow missing, still provide some info diff --git a/routes/utils/celery_queue_manager.py b/routes/utils/celery_queue_manager.py index 548f00e..b472f70 100644 --- a/routes/utils/celery_queue_manager.py +++ b/routes/utils/celery_queue_manager.py @@ -127,6 +127,7 @@ class CeleryDownloadQueueManager: NON_BLOCKING_STATES = [ ProgressState.COMPLETE, + ProgressState.DONE, ProgressState.CANCELLED, ProgressState.ERROR, ProgressState.ERROR_RETRIED, @@ -354,7 +355,11 @@ class CeleryDownloadQueueManager: status = task.get("status") # Only cancel tasks that are not already completed or cancelled - if status not in [ProgressState.COMPLETE, ProgressState.CANCELLED]: + if status not in [ + ProgressState.COMPLETE, + ProgressState.DONE, + ProgressState.CANCELLED, + ]: result = cancel_celery_task(task_id) if result.get("status") == "cancelled": cancelled_count += 1 diff --git a/routes/utils/celery_tasks.py b/routes/utils/celery_tasks.py index 2b19f80..26d5e8d 100644 --- a/routes/utils/celery_tasks.py +++ b/routes/utils/celery_tasks.py @@ -29,7 +29,7 @@ from routes.utils.watch.db import ( ) # Import history manager function -from .history_manager import add_entry_to_history +from .history_manager import add_entry_to_history, add_tracks_from_summary # Create Redis connection for storing task data that's not part of the Celery result backend import redis @@ -238,6 +238,9 @@ def _log_task_to_history(task_id, final_status_str, error_msg=None): except Exception: spotify_id = None # Ignore errors in parsing + # Check for the new summary object in the last status + summary_obj = last_status_obj.get("summary") if last_status_obj else None + history_entry = { "task_id": task_id, "download_type": task_info.get("download_type"), @@ -271,15 +274,34 @@ def _log_task_to_history(task_id, final_status_str, error_msg=None): "bitrate": bitrate_str if bitrate_str else None, # Store None if empty string + "summary_json": json.dumps(summary_obj) if summary_obj else None, + "total_successful": summary_obj.get("total_successful") + if summary_obj + else None, + "total_skipped": summary_obj.get("total_skipped") if summary_obj else None, + "total_failed": summary_obj.get("total_failed") if summary_obj else None, } + + # Add the main history entry for the task add_entry_to_history(history_entry) + + # Process track-level entries from summary if this is a multi-track download + if summary_obj and task_info.get("download_type") in ["album", "playlist"]: + tracks_processed = add_tracks_from_summary( + summary_data=summary_obj, + parent_task_id=task_id, + parent_history_data=history_entry + ) + logger.info( + f"Track-level history: Processed {tracks_processed['successful']} successful, " + f"{tracks_processed['skipped']} skipped, and {tracks_processed['failed']} failed tracks for task {task_id}" + ) + except Exception as e: logger.error( f"History: Error preparing or logging history for task {task_id}: {e}", exc_info=True, ) - - # --- End History Logging Helper --- @@ -536,6 +558,9 @@ class ProgressTrackingTask(Task): Args: progress_data: Dictionary containing progress information from deezspot """ + # Store a copy of the original, unprocessed callback data + raw_callback_data = progress_data.copy() + task_id = self.request.id # Ensure ./logs/tasks directory exists @@ -570,9 +595,6 @@ class ProgressTrackingTask(Task): # Get status type status = progress_data.get("status", "unknown") - # Create a work copy of the data to avoid modifying the original - stored_data = progress_data.copy() - # Get task info for context task_info = get_task_info(task_id) @@ -585,44 +607,47 @@ class ProgressTrackingTask(Task): # Process based on status type using a more streamlined approach if status == "initializing": # --- INITIALIZING: Start of a download operation --- - self._handle_initializing(task_id, stored_data, task_info) + self._handle_initializing(task_id, progress_data, task_info) elif status == "downloading": # --- DOWNLOADING: Track download started --- - self._handle_downloading(task_id, stored_data, task_info) + self._handle_downloading(task_id, progress_data, task_info) elif status == "progress": # --- PROGRESS: Album/playlist track progress --- - self._handle_progress(task_id, stored_data, task_info) + self._handle_progress(task_id, progress_data, task_info) elif status == "real_time" or status == "track_progress": # --- REAL_TIME/TRACK_PROGRESS: Track download real-time progress --- - self._handle_real_time(task_id, stored_data) + self._handle_real_time(task_id, progress_data) elif status == "skipped": # --- SKIPPED: Track was skipped --- - self._handle_skipped(task_id, stored_data, task_info) + self._handle_skipped(task_id, progress_data, task_info) elif status == "retrying": # --- RETRYING: Download failed and being retried --- - self._handle_retrying(task_id, stored_data, task_info) + self._handle_retrying(task_id, progress_data, task_info) elif status == "error": # --- ERROR: Error occurred during download --- - self._handle_error(task_id, stored_data, task_info) + self._handle_error(task_id, progress_data, task_info) elif status == "done": # --- DONE: Download operation completed --- - self._handle_done(task_id, stored_data, task_info) + self._handle_done(task_id, progress_data, task_info) else: # --- UNKNOWN: Unrecognized status --- logger.info( - f"Task {task_id} {status}: {stored_data.get('message', 'No details')}" + f"Task {task_id} {status}: {progress_data.get('message', 'No details')}" ) + # Embed the raw callback data into the status object before storing + progress_data["raw_callback"] = raw_callback_data + # Store the processed status update - store_task_status(task_id, stored_data) + store_task_status(task_id, progress_data) def _handle_initializing(self, task_id, data, task_info): """Handle initializing status from deezspot""" @@ -663,7 +688,7 @@ class ProgressTrackingTask(Task): store_task_info(task_id, task_info) # Update status in data - data["status"] = ProgressState.INITIALIZING + # data["status"] = ProgressState.INITIALIZING def _handle_downloading(self, task_id, data, task_info): """Handle downloading status from deezspot""" @@ -720,7 +745,7 @@ class ProgressTrackingTask(Task): logger.info(f"Task {task_id} downloading: '{track_name}'") # Update status - data["status"] = ProgressState.DOWNLOADING + # data["status"] = ProgressState.DOWNLOADING def _handle_progress(self, task_id, data, task_info): """Handle progress status from deezspot""" @@ -776,7 +801,7 @@ class ProgressTrackingTask(Task): logger.error(f"Error parsing track numbers '{current_track_raw}': {e}") # Ensure correct status - data["status"] = ProgressState.PROGRESS + # data["status"] = ProgressState.PROGRESS def _handle_real_time(self, task_id, data): """Handle real-time progress status from deezspot""" @@ -818,11 +843,11 @@ class ProgressTrackingTask(Task): logger.debug(f"Task {task_id} track progress: {title} by {artist}: {percent}%") # Set appropriate status - data["status"] = ( - ProgressState.REAL_TIME - if data.get("status") == "real_time" - else ProgressState.TRACK_PROGRESS - ) + # data["status"] = ( + # ProgressState.REAL_TIME + # if data.get("status") == "real_time" + # else ProgressState.TRACK_PROGRESS + # ) def _handle_skipped(self, task_id, data, task_info): """Handle skipped status from deezspot""" @@ -872,7 +897,7 @@ class ProgressTrackingTask(Task): store_task_status(task_id, progress_update) # Set status - data["status"] = ProgressState.SKIPPED + # data["status"] = ProgressState.SKIPPED def _handle_retrying(self, task_id, data, task_info): """Handle retrying status from deezspot""" @@ -895,7 +920,7 @@ class ProgressTrackingTask(Task): store_task_info(task_id, task_info) # Set status - data["status"] = ProgressState.RETRYING + # data["status"] = ProgressState.RETRYING def _handle_error(self, task_id, data, task_info): """Handle error status from deezspot""" @@ -911,7 +936,7 @@ class ProgressTrackingTask(Task): store_task_info(task_id, task_info) # Set status and error message - data["status"] = ProgressState.ERROR + # data["status"] = ProgressState.ERROR data["error"] = message def _handle_done(self, task_id, data, task_info): @@ -931,7 +956,7 @@ class ProgressTrackingTask(Task): logger.info(f"Task {task_id} completed: Track '{song}'") # Update status to track_complete - data["status"] = ProgressState.TRACK_COMPLETE + # data["status"] = ProgressState.TRACK_COMPLETE # Update task info completed_tracks = task_info.get("completed_tracks", 0) + 1 @@ -989,15 +1014,28 @@ class ProgressTrackingTask(Task): logger.info(f"Task {task_id} completed: {content_type.upper()}") # Add summary - data["status"] = ProgressState.COMPLETE - data["message"] = ( - f"Download complete: {completed_tracks} tracks downloaded, {skipped_tracks} skipped" - ) + # data["status"] = ProgressState.COMPLETE + summary_obj = data.get("summary") - # Log summary - logger.info( - f"Task {task_id} summary: {completed_tracks} completed, {skipped_tracks} skipped, {error_count} errors" - ) + if summary_obj: + total_successful = summary_obj.get("total_successful", 0) + total_skipped = summary_obj.get("total_skipped", 0) + total_failed = summary_obj.get("total_failed", 0) + # data[ + # "message" + # ] = f"Download complete: {total_successful} tracks downloaded, {total_skipped} skipped, {total_failed} failed." + # Log summary from the summary object + logger.info( + f"Task {task_id} summary: {total_successful} successful, {total_skipped} skipped, {total_failed} failed." + ) + else: + # data["message"] = ( + # f"Download complete: {completed_tracks} tracks downloaded, {skipped_tracks} skipped" + # ) + # Log summary + logger.info( + f"Task {task_id} summary: {completed_tracks} completed, {skipped_tracks} skipped, {error_count} errors" + ) # Schedule deletion for completed multi-track downloads delayed_delete_task_data.apply_async( args=[task_id, "Task completed successfully and auto-cleaned."], @@ -1066,8 +1104,8 @@ class ProgressTrackingTask(Task): else: # Generic done for other types logger.info(f"Task {task_id} completed: {content_type.upper()}") - data["status"] = ProgressState.COMPLETE - data["message"] = "Download complete" + # data["status"] = ProgressState.COMPLETE + # data["message"] = "Download complete" # Celery signal handlers @@ -1134,18 +1172,11 @@ def task_postrun_handler( ) if state == states.SUCCESS: - if current_redis_status != ProgressState.COMPLETE: - store_task_status( - task_id, - { - "status": ProgressState.COMPLETE, - "timestamp": time.time(), - "type": task_info.get("type", "unknown"), - "name": task_info.get("name", "Unknown"), - "artist": task_info.get("artist", ""), - "message": "Download completed successfully.", - }, - ) + if current_redis_status not in [ProgressState.COMPLETE, "done"]: + # The final status is now set by the 'done' callback from deezspot. + # We no longer need to store a generic 'COMPLETE' status here. + # This ensures the raw callback data is the last thing in the log. + pass logger.info( f"Task {task_id} completed successfully: {task_info.get('name', 'Unknown')}" ) diff --git a/routes/utils/history_manager.py b/routes/utils/history_manager.py index 2dba42c..b6072b4 100644 --- a/routes/utils/history_manager.py +++ b/routes/utils/history_manager.py @@ -2,6 +2,7 @@ import sqlite3 import json import time import logging +import uuid from pathlib import Path logger = logging.getLogger(__name__) @@ -27,6 +28,12 @@ EXPECTED_COLUMNS = { "quality_profile": "TEXT", "convert_to": "TEXT", "bitrate": "TEXT", + "parent_task_id": "TEXT", # Reference to parent task for individual tracks + "track_status": "TEXT", # 'SUCCESSFUL', 'SKIPPED', 'FAILED' + "summary_json": "TEXT", # JSON string of the summary object from task + "total_successful": "INTEGER", # Count of successful tracks + "total_skipped": "INTEGER", # Count of skipped tracks + "total_failed": "INTEGER", # Count of failed tracks } @@ -61,7 +68,13 @@ def init_history_db(): service_used TEXT, quality_profile TEXT, convert_to TEXT, - bitrate TEXT + bitrate TEXT, + parent_task_id TEXT, + track_status TEXT, + summary_json TEXT, + total_successful INTEGER, + total_skipped INTEGER, + total_failed INTEGER ) """ cursor.execute(create_table_sql) @@ -106,6 +119,27 @@ def init_history_db(): f"Could not add column '{col_name}': {alter_e}. It might already exist or there's a schema mismatch." ) + # Add additional columns for summary data if they don't exist + for col_name, col_type in { + "summary_json": "TEXT", + "total_successful": "INTEGER", + "total_skipped": "INTEGER", + "total_failed": "INTEGER" + }.items(): + if col_name not in existing_column_names and col_name not in EXPECTED_COLUMNS: + try: + cursor.execute( + f"ALTER TABLE download_history ADD COLUMN {col_name} {col_type}" + ) + logger.info( + f"Added missing column '{col_name} {col_type}' to download_history table." + ) + added_columns = True + except sqlite3.OperationalError as alter_e: + logger.warning( + f"Could not add column '{col_name}': {alter_e}. It might already exist or there's a schema mismatch." + ) + if added_columns: conn.commit() logger.info(f"Download history table schema updated at {HISTORY_DB_FILE}") @@ -148,6 +182,12 @@ def add_entry_to_history(history_data: dict): "quality_profile", "convert_to", "bitrate", + "parent_task_id", + "track_status", + "summary_json", + "total_successful", + "total_skipped", + "total_failed", ] # Ensure all keys are present, filling with None if not for key in required_keys: @@ -164,8 +204,9 @@ def add_entry_to_history(history_data: dict): item_url, spotify_id, status_final, error_message, timestamp_added, timestamp_completed, original_request_json, last_status_obj_json, service_used, quality_profile, - convert_to, bitrate - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + convert_to, bitrate, parent_task_id, track_status, + summary_json, total_successful, total_skipped, total_failed + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( history_data["task_id"], @@ -185,6 +226,12 @@ def add_entry_to_history(history_data: dict): history_data["quality_profile"], history_data["convert_to"], history_data["bitrate"], + history_data["parent_task_id"], + history_data["track_status"], + history_data["summary_json"], + history_data["total_successful"], + history_data["total_skipped"], + history_data["total_failed"], ), ) conn.commit() @@ -239,8 +286,16 @@ def get_history_entries( for column, value in filters.items(): # Basic security: ensure column is a valid one (alphanumeric + underscore) if column.replace("_", "").isalnum(): - where_clauses.append(f"{column} = ?") - params.append(value) + # Special case for 'NOT_NULL' value for parent_task_id + if column == "parent_task_id" and value == "NOT_NULL": + where_clauses.append(f"{column} IS NOT NULL") + # Regular case for NULL value + elif value is None: + where_clauses.append(f"{column} IS NULL") + # Regular case for exact match + else: + where_clauses.append(f"{column} = ?") + params.append(value) if where_clauses: where_sql = " WHERE " + " AND ".join(where_clauses) @@ -266,6 +321,11 @@ def get_history_entries( "quality_profile", "convert_to", "bitrate", + "parent_task_id", + "track_status", + "total_successful", + "total_skipped", + "total_failed", ] if sort_by not in valid_sort_columns: sort_by = "timestamp_completed" # Default sort @@ -292,6 +352,157 @@ def get_history_entries( conn.close() +def add_track_entry_to_history(track_name, artist_name, parent_task_id, track_status, parent_history_data=None): + """Adds a track-specific entry to the history database. + + Args: + track_name (str): The name of the track + artist_name (str): The artist name + parent_task_id (str): The ID of the parent task (album or playlist) + track_status (str): The status of the track ('SUCCESSFUL', 'SKIPPED', 'FAILED') + parent_history_data (dict, optional): The history data of the parent task + + Returns: + str: The task_id of the created track entry + """ + # Generate a unique ID for this track entry + track_task_id = f"{parent_task_id}_track_{uuid.uuid4().hex[:8]}" + + # Create a copy of parent data or initialize empty dict + track_history_data = {} + if parent_history_data: + # Copy relevant fields from parent + for key in EXPECTED_COLUMNS: + if key in parent_history_data and key not in ['task_id', 'item_name', 'item_artist']: + track_history_data[key] = parent_history_data[key] + + # Set track-specific fields + track_history_data.update({ + "task_id": track_task_id, + "download_type": "track", + "item_name": track_name, + "item_artist": artist_name, + "parent_task_id": parent_task_id, + "track_status": track_status, + "status_final": "COMPLETED" if track_status == "SUCCESSFUL" else + "SKIPPED" if track_status == "SKIPPED" else "ERROR", + "timestamp_completed": time.time() + }) + + # Extract track URL if possible (from last_status_obj_json) + if parent_history_data and parent_history_data.get("last_status_obj_json"): + try: + last_status = json.loads(parent_history_data["last_status_obj_json"]) + + # Try to match track name in the tracks lists to find URL + track_key = f"{track_name} - {artist_name}" + if "raw_callback" in last_status and last_status["raw_callback"].get("url"): + track_history_data["item_url"] = last_status["raw_callback"].get("url") + + # Extract Spotify ID from URL if possible + url = last_status["raw_callback"].get("url", "") + if url and "spotify.com" in url: + try: + spotify_id = url.split("/")[-1] + if spotify_id and len(spotify_id) == 22 and spotify_id.isalnum(): + track_history_data["spotify_id"] = spotify_id + except Exception: + pass + except (json.JSONDecodeError, KeyError, AttributeError) as e: + logger.warning(f"Could not extract track URL for {track_name}: {e}") + + # Add entry to history + add_entry_to_history(track_history_data) + + return track_task_id + +def add_tracks_from_summary(summary_data, parent_task_id, parent_history_data=None): + """Processes a summary object from a completed task and adds individual track entries. + + Args: + summary_data (dict): The summary data containing track lists + parent_task_id (str): The ID of the parent task + parent_history_data (dict, optional): The history data of the parent task + + Returns: + dict: Summary of processed tracks + """ + processed = { + "successful": 0, + "skipped": 0, + "failed": 0 + } + + if not summary_data: + logger.warning(f"No summary data provided for task {parent_task_id}") + return processed + + # Process successful tracks + for track_entry in summary_data.get("successful_tracks", []): + try: + # Parse "track_name - artist_name" format + parts = track_entry.split(" - ", 1) + if len(parts) == 2: + track_name, artist_name = parts + add_track_entry_to_history( + track_name=track_name, + artist_name=artist_name, + parent_task_id=parent_task_id, + track_status="SUCCESSFUL", + parent_history_data=parent_history_data + ) + processed["successful"] += 1 + else: + logger.warning(f"Could not parse track entry: {track_entry}") + except Exception as e: + logger.error(f"Error processing successful track {track_entry}: {e}", exc_info=True) + + # Process skipped tracks + for track_entry in summary_data.get("skipped_tracks", []): + try: + parts = track_entry.split(" - ", 1) + if len(parts) == 2: + track_name, artist_name = parts + add_track_entry_to_history( + track_name=track_name, + artist_name=artist_name, + parent_task_id=parent_task_id, + track_status="SKIPPED", + parent_history_data=parent_history_data + ) + processed["skipped"] += 1 + else: + logger.warning(f"Could not parse skipped track entry: {track_entry}") + except Exception as e: + logger.error(f"Error processing skipped track {track_entry}: {e}", exc_info=True) + + # Process failed tracks + for track_entry in summary_data.get("failed_tracks", []): + try: + parts = track_entry.split(" - ", 1) + if len(parts) == 2: + track_name, artist_name = parts + add_track_entry_to_history( + track_name=track_name, + artist_name=artist_name, + parent_task_id=parent_task_id, + track_status="FAILED", + parent_history_data=parent_history_data + ) + processed["failed"] += 1 + else: + logger.warning(f"Could not parse failed track entry: {track_entry}") + except Exception as e: + logger.error(f"Error processing failed track {track_entry}: {e}", exc_info=True) + + logger.info( + f"Added {processed['successful']} successful, {processed['skipped']} skipped, " + f"and {processed['failed']} failed track entries for task {parent_task_id}" + ) + + return processed + + if __name__ == "__main__": # For testing purposes logging.basicConfig(level=logging.INFO) diff --git a/routes/utils/playlist.py b/routes/utils/playlist.py index 3266e17..5605a51 100755 --- a/routes/utils/playlist.py +++ b/routes/utils/playlist.py @@ -124,6 +124,10 @@ def download_playlist( "spotify", main ) # For blob path blob_file_path = spotify_main_creds.get("blob_file_path") + if blob_file_path is None: + raise ValueError( + f"Spotify credentials for account '{main}' don't contain a blob_file_path. Please check your credentials configuration." + ) if not Path(blob_file_path).exists(): raise FileNotFoundError( f"Spotify credentials blob file not found at {blob_file_path} for account '{main}'" @@ -180,6 +184,10 @@ def download_playlist( spotify_main_creds = get_credential("spotify", main) # For blob path blob_file_path = spotify_main_creds.get("blob_file_path") + if blob_file_path is None: + raise ValueError( + f"Spotify credentials for account '{main}' don't contain a blob_file_path. Please check your credentials configuration." + ) if not Path(blob_file_path).exists(): raise FileNotFoundError( f"Spotify credentials blob file not found at {blob_file_path} for account '{main}'" diff --git a/src/js/history.ts b/src/js/history.ts index 0ec9984..d274ce0 100644 --- a/src/js/history.ts +++ b/src/js/history.ts @@ -6,12 +6,15 @@ document.addEventListener('DOMContentLoaded', () => { const limitSelect = document.getElementById('limit-select') as HTMLSelectElement | null; const statusFilter = document.getElementById('status-filter') as HTMLSelectElement | null; const typeFilter = document.getElementById('type-filter') as HTMLSelectElement | null; + const trackFilter = document.getElementById('track-filter') as HTMLSelectElement | null; + const hideChildTracksCheckbox = document.getElementById('hide-child-tracks') as HTMLInputElement | null; let currentPage = 1; let limit = 25; let totalEntries = 0; let currentSortBy = 'timestamp_completed'; let currentSortOrder = 'DESC'; + let currentParentTaskId: string | null = null; async function fetchHistory(page = 1) { if (!historyTableBody || !prevButton || !nextButton || !pageInfo || !limitSelect || !statusFilter || !typeFilter) { @@ -30,6 +33,21 @@ document.addEventListener('DOMContentLoaded', () => { if (typeVal) { apiUrl += `&download_type=${typeVal}`; } + + // Add track status filter if present + if (trackFilter && trackFilter.value) { + apiUrl += `&track_status=${trackFilter.value}`; + } + + // Add parent task filter if viewing a specific parent's tracks + if (currentParentTaskId) { + apiUrl += `&parent_task_id=${currentParentTaskId}`; + } + + // Add hide child tracks filter if checkbox is checked + if (hideChildTracksCheckbox && hideChildTracksCheckbox.checked) { + apiUrl += `&hide_child_tracks=true`; + } try { const response = await fetch(apiUrl); @@ -42,10 +60,13 @@ document.addEventListener('DOMContentLoaded', () => { currentPage = Math.floor(offset / limit) + 1; updatePagination(); updateSortIndicators(); + + // Update page title if viewing tracks for a parent + updatePageTitle(); } catch (error) { console.error('Error fetching history:', error); if (historyTableBody) { - historyTableBody.innerHTML = 'Error loading history.'; + historyTableBody.innerHTML = 'Error loading history.'; } } } @@ -55,17 +76,43 @@ document.addEventListener('DOMContentLoaded', () => { historyTableBody.innerHTML = ''; // Clear existing rows if (!entries || entries.length === 0) { - historyTableBody.innerHTML = 'No history entries found.'; + historyTableBody.innerHTML = 'No history entries found.'; return; } entries.forEach(entry => { const row = historyTableBody.insertRow(); - row.insertCell().textContent = entry.item_name || 'N/A'; + + // Add class for parent/child styling + if (entry.parent_task_id) { + row.classList.add('child-track-row'); + } else if (entry.download_type === 'album' || entry.download_type === 'playlist') { + row.classList.add('parent-task-row'); + } + + // Item name with indentation for child tracks + const nameCell = row.insertCell(); + if (entry.parent_task_id) { + nameCell.innerHTML = `└─ ${entry.item_name || 'N/A'}`; + } else { + nameCell.textContent = entry.item_name || 'N/A'; + } + row.insertCell().textContent = entry.item_artist || 'N/A'; - row.insertCell().textContent = entry.download_type ? entry.download_type.charAt(0).toUpperCase() + entry.download_type.slice(1) : 'N/A'; + + // Type cell - show track status for child tracks + const typeCell = row.insertCell(); + if (entry.parent_task_id && entry.track_status) { + typeCell.textContent = entry.track_status; + typeCell.classList.add(`track-status-${entry.track_status.toLowerCase()}`); + } else { + typeCell.textContent = entry.download_type ? entry.download_type.charAt(0).toUpperCase() + entry.download_type.slice(1) : 'N/A'; + } + row.insertCell().textContent = entry.service_used || 'N/A'; + // Construct Quality display string + const qualityCell = row.insertCell(); let qualityDisplay = entry.quality_profile || 'N/A'; if (entry.convert_to) { qualityDisplay = `${entry.convert_to.toUpperCase()}`; @@ -76,22 +123,47 @@ document.addEventListener('DOMContentLoaded', () => { } else if (entry.bitrate) { // Case where convert_to might not be set, but bitrate is (e.g. for OGG Vorbis quality settings) qualityDisplay = `${entry.bitrate}k (${entry.quality_profile || 'Profile'})`; } - row.insertCell().textContent = qualityDisplay; + qualityCell.textContent = qualityDisplay; const statusCell = row.insertCell(); statusCell.textContent = entry.status_final || 'N/A'; - statusCell.className = `status-${entry.status_final}`; + statusCell.className = `status-${entry.status_final?.toLowerCase() || 'unknown'}`; row.insertCell().textContent = entry.timestamp_added ? new Date(entry.timestamp_added * 1000).toLocaleString() : 'N/A'; row.insertCell().textContent = entry.timestamp_completed ? new Date(entry.timestamp_completed * 1000).toLocaleString() : 'N/A'; - const detailsCell = row.insertCell(); + const actionsCell = row.insertCell(); + + // Add details button const detailsButton = document.createElement('button'); detailsButton.innerHTML = `Details`; detailsButton.className = 'details-btn btn-icon'; detailsButton.title = 'Show Details'; detailsButton.onclick = () => showDetailsModal(entry); - detailsCell.appendChild(detailsButton); + actionsCell.appendChild(detailsButton); + + // Add view tracks button for album/playlist entries with child tracks + if (!entry.parent_task_id && (entry.download_type === 'album' || entry.download_type === 'playlist') && + (entry.total_successful > 0 || entry.total_skipped > 0 || entry.total_failed > 0)) { + const viewTracksButton = document.createElement('button'); + viewTracksButton.innerHTML = `Tracks`; + viewTracksButton.className = 'tracks-btn btn-icon'; + viewTracksButton.title = 'View Tracks'; + viewTracksButton.setAttribute('data-task-id', entry.task_id); + viewTracksButton.onclick = () => viewTracksForParent(entry.task_id); + actionsCell.appendChild(viewTracksButton); + + // Add track counts display + const trackCountsSpan = document.createElement('span'); + trackCountsSpan.className = 'track-counts'; + trackCountsSpan.title = `Successful: ${entry.total_successful || 0}, Skipped: ${entry.total_skipped || 0}, Failed: ${entry.total_failed || 0}`; + trackCountsSpan.innerHTML = ` + ${entry.total_successful || 0} / + ${entry.total_skipped || 0} / + ${entry.total_failed || 0} + `; + actionsCell.appendChild(trackCountsSpan); + } if (entry.status_final === 'ERROR' && entry.error_message) { const errorSpan = document.createElement('span'); @@ -105,10 +177,8 @@ document.addEventListener('DOMContentLoaded', () => { errorDetailsDiv = document.createElement('div'); errorDetailsDiv.className = 'error-details'; const newCell = row.insertCell(); // This will append to the end of the row - newCell.colSpan = 9; // Span across all columns + newCell.colSpan = 10; // Span across all columns newCell.appendChild(errorDetailsDiv); - // Visually, this new cell will be after the 'Details' button cell. - // To make it appear as part of the status cell or below the row, more complex DOM manipulation or CSS would be needed. } errorDetailsDiv.textContent = entry.error_message; // Toggle display by directly manipulating the style of the details div @@ -127,27 +197,92 @@ document.addEventListener('DOMContentLoaded', () => { prevButton.disabled = currentPage === 1; nextButton.disabled = currentPage === totalPages; } + + function updatePageTitle() { + const titleElement = document.getElementById('history-title'); + if (!titleElement) return; + + if (currentParentTaskId) { + titleElement.textContent = 'Download History - Viewing Tracks'; + + // Add back button + if (!document.getElementById('back-to-history')) { + const backButton = document.createElement('button'); + backButton.id = 'back-to-history'; + backButton.className = 'btn btn-secondary'; + backButton.innerHTML = '← Back to All History'; + backButton.onclick = () => { + currentParentTaskId = null; + updatePageTitle(); + fetchHistory(1); + }; + titleElement.parentNode?.insertBefore(backButton, titleElement); + } + } else { + titleElement.textContent = 'Download History'; + + // Remove back button if it exists + const backButton = document.getElementById('back-to-history'); + if (backButton) { + backButton.remove(); + } + } + } function showDetailsModal(entry: any) { - const details = `Task ID: ${entry.task_id}\n` + - `Type: ${entry.download_type}\n` + - `Name: ${entry.item_name}\n` + - `Artist: ${entry.item_artist}\n` + - `Album: ${entry.item_album || 'N/A'}\n` + - `URL: ${entry.item_url}\n` + - `Spotify ID: ${entry.spotify_id || 'N/A'}\n` + - `Service Used: ${entry.service_used || 'N/A'}\n` + - `Quality Profile (Original): ${entry.quality_profile || 'N/A'}\n` + - `ConvertTo: ${entry.convert_to || 'N/A'}\n` + - `Bitrate: ${entry.bitrate ? entry.bitrate + 'k' : 'N/A'}\n` + - `Status: ${entry.status_final}\n` + - `Error: ${entry.error_message || 'None'}\n` + - `Added: ${new Date(entry.timestamp_added * 1000).toLocaleString()}\n` + - `Completed/Ended: ${new Date(entry.timestamp_completed * 1000).toLocaleString()}\n\n` + - `Original Request: ${JSON.stringify(JSON.parse(entry.original_request_json || '{}'), null, 2)}\n\n` + - `Last Status Object: ${JSON.stringify(JSON.parse(entry.last_status_obj_json || '{}'), null, 2)}`; + // Create more detailed modal content with new fields + let details = `Task ID: ${entry.task_id}\n` + + `Type: ${entry.download_type}\n` + + `Name: ${entry.item_name}\n` + + `Artist: ${entry.item_artist}\n` + + `Album: ${entry.item_album || 'N/A'}\n` + + `URL: ${entry.item_url || 'N/A'}\n` + + `Spotify ID: ${entry.spotify_id || 'N/A'}\n` + + `Service Used: ${entry.service_used || 'N/A'}\n` + + `Quality Profile (Original): ${entry.quality_profile || 'N/A'}\n` + + `ConvertTo: ${entry.convert_to || 'N/A'}\n` + + `Bitrate: ${entry.bitrate ? entry.bitrate + 'k' : 'N/A'}\n` + + `Status: ${entry.status_final}\n` + + `Error: ${entry.error_message || 'None'}\n` + + `Added: ${new Date(entry.timestamp_added * 1000).toLocaleString()}\n` + + `Completed/Ended: ${new Date(entry.timestamp_completed * 1000).toLocaleString()}\n`; + + // Add track-specific details if this is a track + if (entry.parent_task_id) { + details += `Parent Task ID: ${entry.parent_task_id}\n` + + `Track Status: ${entry.track_status || 'N/A'}\n`; + } + + // Add summary details if this is a parent task + if (entry.total_successful !== null || entry.total_skipped !== null || entry.total_failed !== null) { + details += `\nTrack Summary:\n` + + `Successful: ${entry.total_successful || 0}\n` + + `Skipped: ${entry.total_skipped || 0}\n` + + `Failed: ${entry.total_failed || 0}\n`; + } + + details += `\nOriginal Request: ${JSON.stringify(JSON.parse(entry.original_request_json || '{}'), null, 2)}\n\n` + + `Last Status Object: ${JSON.stringify(JSON.parse(entry.last_status_obj_json || '{}'), null, 2)}`; + + // Try to parse and display summary if available + if (entry.summary_json) { + try { + const summary = JSON.parse(entry.summary_json); + details += `\nSummary: ${JSON.stringify(summary, null, 2)}`; + } catch (e) { + console.error('Error parsing summary JSON:', e); + } + } + alert(details); } + + // Function to view tracks for a parent task + async function viewTracksForParent(taskId: string) { + currentParentTaskId = taskId; + currentPage = 1; + fetchHistory(1); + } document.querySelectorAll('th[data-sort]').forEach(headerCell => { headerCell.addEventListener('click', () => { @@ -174,6 +309,7 @@ document.addEventListener('DOMContentLoaded', () => { }); } + // Event listeners for pagination and filters prevButton?.addEventListener('click', () => fetchHistory(currentPage - 1)); nextButton?.addEventListener('click', () => fetchHistory(currentPage + 1)); limitSelect?.addEventListener('change', (e) => { @@ -182,6 +318,8 @@ document.addEventListener('DOMContentLoaded', () => { }); statusFilter?.addEventListener('change', () => fetchHistory(1)); typeFilter?.addEventListener('change', () => fetchHistory(1)); + trackFilter?.addEventListener('change', () => fetchHistory(1)); + hideChildTracksCheckbox?.addEventListener('change', () => fetchHistory(1)); // Initial fetch fetchHistory(); diff --git a/src/js/queue.ts b/src/js/queue.ts index 8fbc310..03fd167 100644 --- a/src/js/queue.ts +++ b/src/js/queue.ts @@ -46,35 +46,49 @@ interface ParentInfo { } interface StatusData { - type?: string; - status?: string; - name?: string; - song?: string; - music?: string; - title?: string; - artist?: string; - artist_name?: string; - album?: string; - owner?: string; - total_tracks?: number | string; - current_track?: number | string; - parsed_current_track?: string; // Make sure these are handled if they are strings - parsed_total_tracks?: string; // Make sure these are handled if they are strings - progress?: number | string; // Can be string initially - percentage?: number | string; // Can be string initially - percent?: number | string; // Can be string initially - time_elapsed?: number; - error?: string; - can_retry?: boolean; - retry_count?: number; - max_retries?: number; // from config potentially - seconds_left?: number; - task_id?: string; + type?: 'track' | 'album' | 'playlist' | 'episode' | string; + status?: 'initializing' | 'skipped' | 'retrying' | 'real-time' | 'error' | 'done' | 'processing' | 'queued' | 'progress' | 'track_progress' | 'complete' | 'cancelled' | 'cancel' | 'interrupted' | string; + + // --- Standardized Fields --- url?: string; - reason?: string; // for skipped + convert_to?: string; + bitrate?: string; + + // Item metadata + song?: string; + artist?: string; + album?: string; + title?: string; // for album + name?: string; // for playlist/track + owner?: string; // for playlist parent?: ParentInfo; + + // Progress indicators + current_track?: number | string; + total_tracks?: number | string; + progress?: number | string; // 0-100 + time_elapsed?: number; // ms + + // Status-specific details + reason?: string; // for 'skipped' + error?: string; // for 'error', 'retrying' + retry_count?: number; + seconds_left?: number; + summary?: { + successful_tracks?: string[]; + skipped_tracks?: string[]; + failed_tracks?: { track: string; reason: string }[]; + total_successful?: number; + total_skipped?: number; + total_failed?: number; + }; + + // --- Fields for internal FE logic or from API wrapper --- + task_id?: string; + can_retry?: boolean; + max_retries?: number; // from config original_url?: string; - position?: number; // For queued items + position?: number; original_request?: { url?: string; retry_url?: string; @@ -87,11 +101,12 @@ interface StatusData { display_type?: string; display_artist?: string; service?: string; - [key: string]: any; // For other potential original_request params + [key: string]: any; }; - event?: string; // from SSE + event?: string; overall_progress?: number; - display_type?: string; // from PRG data + display_type?: string; + [key: string]: any; // Allow other properties } @@ -229,26 +244,20 @@ export class DownloadQueue { // Load initial config from the server. await this.loadConfig(); - // Override the server value with locally persisted queue visibility (if present). + // Use localStorage for queue visibility const storedVisible = localStorage.getItem("downloadQueueVisible"); - if (storedVisible !== null) { - // Ensure config is not null before assigning - if (this.config) { - this.config.downloadQueueVisible = storedVisible === "true"; - } - } + const isVisible = storedVisible === "true"; const queueSidebar = document.getElementById('downloadQueue'); - // Ensure config is not null and queueSidebar exists - if (this.config && queueSidebar) { - queueSidebar.hidden = !this.config.downloadQueueVisible; - queueSidebar.classList.toggle('active', !!this.config.downloadQueueVisible); + if (queueSidebar) { + queueSidebar.hidden = !isVisible; + queueSidebar.classList.toggle('active', isVisible); } // Initialize the queue icon based on sidebar visibility const queueIcon = document.getElementById('queueIcon'); - if (queueIcon && this.config) { - if (this.config.downloadQueueVisible) { + if (queueIcon) { + if (isVisible) { queueIcon.innerHTML = 'Close queue'; queueIcon.setAttribute('aria-expanded', 'true'); queueIcon.classList.add('queue-icon-active'); // Add red tint class @@ -326,7 +335,7 @@ export class DownloadQueue { // Update the queue icon to show X when visible or queue icon when hidden const queueIcon = document.getElementById('queueIcon'); - if (queueIcon && this.config) { + if (queueIcon) { if (isVisible) { // Replace the image with an X and add red tint queueIcon.innerHTML = 'Close queue'; @@ -340,34 +349,9 @@ export class DownloadQueue { } } - // Persist the state locally so it survives refreshes. + // Only persist the state in localStorage, not on the server localStorage.setItem("downloadQueueVisible", String(isVisible)); - - try { - await this.loadConfig(); - const updatedConfig = { ...this.config, downloadQueueVisible: isVisible }; - await this.saveConfig(updatedConfig); - this.dispatchEvent('queueVisibilityChanged', { visible: isVisible }); - } catch (error) { - console.error('Failed to save queue visibility:', error); - // Revert UI if save failed. - queueSidebar.classList.toggle('active', !isVisible); - queueSidebar.hidden = isVisible; - // Also revert the icon back - if (queueIcon && this.config) { - if (!isVisible) { - queueIcon.innerHTML = 'Close queue'; - queueIcon.setAttribute('aria-expanded', 'true'); - queueIcon.classList.add('queue-icon-active'); // Add red tint class - } else { - queueIcon.innerHTML = 'Close queue'; - queueIcon.setAttribute('aria-expanded', 'true'); - queueIcon.classList.add('queue-icon-active'); // Add red tint class - } - } - this.dispatchEvent('queueVisibilityChanged', { visible: !isVisible }); - this.showError('Failed to save queue visibility'); - } + this.dispatchEvent('queueVisibilityChanged', { visible: isVisible }); if (isVisible) { // If the queue is now visible, ensure all visible items are being polled. @@ -644,7 +628,7 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): const defaultMessage = (type === 'playlist') ? 'Reading track list' : 'Initializing download...'; // Use display values if available, or fall back to standard fields - const displayTitle = item.name || item.music || item.song || 'Unknown'; + const displayTitle = item.name || item.song || 'Unknown'; const displayArtist = item.artist || ''; const displayType = type.charAt(0).toUpperCase() + type.slice(1); @@ -1037,9 +1021,9 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): } // Extract common fields - const trackName = data.song || data.music || data.name || data.title || + const trackName = data.song || data.name || data.title || (queueItem?.item?.name) || 'Unknown'; - const artist = data.artist || data.artist_name || + const artist = data.artist || (queueItem?.item?.artist) || ''; const albumTitle = data.title || data.album || data.parent?.title || data.name || (queueItem?.item?.name) || ''; @@ -1047,18 +1031,14 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): (queueItem?.item?.name) || ''; const playlistOwner = data.owner || data.parent?.owner || (queueItem?.item?.owner) || ''; // Add type check if item.owner is object - const currentTrack = data.current_track || data.parsed_current_track || ''; - const totalTracks = data.total_tracks || data.parsed_total_tracks || data.parent?.total_tracks || + const currentTrack = data.current_track || ''; + const totalTracks = data.total_tracks || data.parent?.total_tracks || (queueItem?.item?.total_tracks) || ''; // Format percentage for display when available let formattedPercentage = '0'; if (data.progress !== undefined) { - formattedPercentage = parseFloat(data.progress as string).toFixed(1); // Cast to string - } else if (data.percentage) { - formattedPercentage = (parseFloat(data.percentage as string) * 100).toFixed(1); // Cast to string - } else if (data.percent) { - formattedPercentage = (parseFloat(data.percent as string) * 100).toFixed(1); // Cast to string + formattedPercentage = Number(data.progress).toFixed(1); } // Helper for constructing info about the parent item @@ -1204,11 +1184,37 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): case 'done': case 'complete': - if (data.type === 'track') { - return `Downloaded "${trackName}"${artist ? ` by ${artist}` : ''} successfully${getParentInfo()}`; - } else if (data.type === 'album') { + // Final summary for album/playlist + if (data.summary && (data.type === 'album' || data.type === 'playlist')) { + const { total_successful = 0, total_skipped = 0, total_failed = 0, failed_tracks = [] } = data.summary; + const name = data.type === 'album' ? (data.title || albumTitle) : (data.name || playlistName); + return `Finished ${data.type} "${name}". Success: ${total_successful}, Skipped: ${total_skipped}, Failed: ${total_failed}.`; + } + + // Final status for a single track (without a parent) + if (data.type === 'track' && !data.parent) { + return `Downloaded "${trackName}"${artist ? ` by ${artist}` : ''} successfully`; + } + + // A 'done' status for a track *within* a parent collection is just an intermediate step. + if (data.type === 'track' && data.parent) { + const parentType = data.parent.type === 'album' ? 'album' : 'playlist'; + const parentName = data.parent.type === 'album' ? (data.parent.title || '') : (data.parent.name || ''); + const nextTrack = Number(data.current_track || 0) + 1; + const totalTracks = Number(data.total_tracks || 0); + + if (nextTrack > totalTracks) { + return `Finalizing ${parentType} "${parentName}"... (${data.current_track}/${totalTracks} tracks completed)`; + } else { + return `Completed track ${data.current_track}/${totalTracks}: "${trackName}" by ${artist}. Preparing next track...`; + } + } + + // Fallback for album/playlist without summary + if (data.type === 'album') { return `Downloaded album "${albumTitle}"${artist ? ` by ${artist}` : ''} successfully (${totalTracks} tracks)`; - } else if (data.type === 'playlist') { + } + if (data.type === 'playlist') { return `Downloaded playlist "${playlistName}"${playlistOwner ? ` by ${playlistOwner}` : ''} successfully (${totalTracks} tracks)`; } return `Downloaded ${data.type} successfully`; @@ -1276,6 +1282,12 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): /* New Methods to Handle Terminal State, Inactivity and Auto-Retry */ handleDownloadCompletion(entry: QueueEntry, queueId: string, progress: StatusData | number) { // Add types + // SAFETY CHECK: Never mark a track with a parent as completed + if (typeof progress !== 'number' && progress.type === 'track' && progress.parent) { + console.log(`Prevented completion of track ${progress.song} that is part of ${progress.parent.type}`); + return; // Exit early and don't mark as complete + } + // Mark the entry as ended entry.hasEnded = true; @@ -1292,10 +1304,11 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): // Stop polling this.clearPollingInterval(queueId); - // Use 3 seconds cleanup delay for completed, 10 seconds for other terminal states like errors + // Use 3 seconds cleanup delay for completed, 10 seconds for errors, and 20 seconds for cancelled/skipped const cleanupDelay = (progress && typeof progress !== 'number' && (progress.status === 'complete' || progress.status === 'done')) ? 3000 : + (progress && typeof progress !== 'number' && progress.status === 'error') ? 10000 : (progress && typeof progress !== 'number' && (progress.status === 'cancelled' || progress.status === 'cancel' || progress.status === 'skipped')) ? 20000 : - 10000; // Default for other errors if not caught by the more specific error handler delay + 10000; // Default for other cases if not caught by the more specific conditions // Clean up after the appropriate delay setTimeout(() => { @@ -1655,7 +1668,7 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): if (this.queueCache[taskId]) { delete this.queueCache[taskId]; } - continue; + continue; // Skip adding terminal tasks to UI if not already there } let itemType = taskData.type || originalRequest.type || 'unknown'; @@ -1753,7 +1766,6 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): } catch (error) { console.error('Error loading config:', error); this.config = { // Initialize with a default structure on error - downloadQueueVisible: false, maxRetries: 3, retryDelaySeconds: 5, retry_delay_increase: 5, @@ -1762,21 +1774,6 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): } } - async saveConfig(updatedConfig: AppConfig) { // Add type - try { - const response = await fetch('/api/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updatedConfig) - }); - if (!response.ok) throw new Error('Failed to save config'); - this.config = await response.json(); - } catch (error) { - console.error('Error saving config:', error); - throw error; - } - } - // Add a method to check if explicit filter is enabled isExplicitFilterEnabled(): boolean { // Add return type return !!this.config.explicitFilter; @@ -1889,6 +1886,15 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): // Handle terminal states if (data.last_line && ['complete', 'error', 'cancelled', 'done'].includes(data.last_line.status || '')) { // Add null check console.log(`Terminal state detected: ${data.last_line.status} for ${queueId}`); + + // SAFETY CHECK: Don't mark track as ended if it has a parent + if (data.last_line.type === 'track' && data.last_line.parent) { + console.log(`Not marking track ${data.last_line.song} as ended because it has a parent ${data.last_line.parent.type}`); + // Still update the UI + this.handleStatusUpdate(queueId, data); + return; + } + entry.hasEnded = true; // For cancelled downloads, clean up immediately @@ -1953,22 +1959,42 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): // Extract the actual status data from the API response const statusData: StatusData = data.last_line || {}; // Add type - // Special handling for track status updates that are part of an album/playlist - // We want to keep these for showing the track-by-track progress - if (statusData.type === 'track' && statusData.parent) { - // If this is a track that's part of our album/playlist, keep it - if ((entry.type === 'album' && statusData.parent.type === 'album') || - (entry.type === 'playlist' && statusData.parent.type === 'playlist')) { - console.log(`Processing track status update for ${entry.type}: ${statusData.song}`); - } + // --- Normalize statusData to conform to expected types --- + const numericFields = ['current_track', 'total_tracks', 'progress', 'retry_count', 'seconds_left', 'time_elapsed']; + for (const field of numericFields) { + if (statusData[field] !== undefined && typeof statusData[field] === 'string') { + statusData[field] = parseFloat(statusData[field] as string); + } } - // Only skip updates where type doesn't match AND there's no relevant parent relationship - else if (statusData.type && entry.type && statusData.type !== entry.type && - (!statusData.parent || statusData.parent.type !== entry.type)) { - console.log(`Skipping mismatched type: update=${statusData.type}, entry=${entry.type}`); - return; + + const entryType = entry.type; + const updateType = statusData.type; + + if (!updateType) { + console.warn("Status update received without a 'type'. Ignoring.", statusData); + return; } + // --- Filtering logic based on download type --- + // A status update is relevant if its type matches the queue entry's type, + // OR if it's a 'track' update that belongs to an 'album' or 'playlist' entry. + let isRelevantUpdate = false; + if (updateType === entryType) { + isRelevantUpdate = true; + } else if (updateType === 'track' && statusData.parent) { + if (entryType === 'album' && statusData.parent.type === 'album') { + isRelevantUpdate = true; + } else if (entryType === 'playlist' && statusData.parent.type === 'playlist') { + isRelevantUpdate = true; + } + } + + if (!isRelevantUpdate) { + console.log(`Skipping status update with type '${updateType}' for entry of type '${entryType}'.`, statusData); + return; + } + + // Get primary status let status = statusData.status || data.event || 'unknown'; // Define status *before* potential modification @@ -2062,6 +2088,32 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): // Apply appropriate status classes this.applyStatusClasses(entry, statusData); // Pass statusData instead of status string + if (status === 'done' || status === 'complete') { + if (statusData.summary && (entry.type === 'album' || entry.type === 'playlist')) { + const { total_successful = 0, total_skipped = 0, total_failed = 0, failed_tracks = [] } = statusData.summary; + const summaryDiv = document.createElement('div'); + summaryDiv.className = 'download-summary'; + + let summaryHTML = ` +
+ Finished: + Success ${total_successful} + Skipped ${total_skipped} + Failed ${total_failed} +
+ `; + + // Remove the individual failed tracks list + // The user only wants to see the count, not the names + + summaryDiv.innerHTML = summaryHTML; + if (logElement) { + logElement.innerHTML = ''; // Clear previous message + logElement.appendChild(summaryDiv); + } + } + } + // Special handling for error status based on new API response format if (status === 'error') { entry.hasEnded = true; @@ -2146,9 +2198,23 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): } // Handle terminal states for non-error cases - if (['complete', 'cancel', 'cancelled', 'done', 'skipped'].includes(status)) { - entry.hasEnded = true; - this.handleDownloadCompletion(entry, queueId, statusData); + if (['complete', 'done', 'skipped', 'cancelled', 'cancel'].includes(status)) { + // Only mark as ended if the update type matches the entry type. + // e.g., an album download is only 'done' when an 'album' status says so, + // not when an individual 'track' within it is 'done'. + if (statusData.type === entry.type) { + entry.hasEnded = true; + this.handleDownloadCompletion(entry, queueId, statusData); + } + // IMPORTANT: Never mark a track as ended if it has a parent + else if (statusData.type === 'track' && statusData.parent) { + console.log(`Track ${statusData.song} in ${statusData.parent.type} has completed, but not ending the parent download.`); + // Update UI but don't trigger completion + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; + if (logElement) { + logElement.textContent = this.getStatusMessage(statusData); + } + } } // Cache the status for potential page reloads @@ -2211,7 +2277,7 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): if (trackProgressBar && statusData.progress !== undefined) { // Update track progress bar - const progress = parseFloat(statusData.progress as string); // Cast to string + const progress = Number(statusData.progress); trackProgressBar.style.width = `${progress}%`; trackProgressBar.setAttribute('aria-valuenow', progress.toString()); // Use string @@ -2321,11 +2387,7 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): // Real-time progress for direct track download if (statusData.status === 'real-time' && statusData.progress !== undefined) { - progress = parseFloat(statusData.progress as string); // Cast to string - } else if (statusData.percent !== undefined) { - progress = parseFloat(statusData.percent as string) * 100; // Cast to string - } else if (statusData.percentage !== undefined) { - progress = parseFloat(statusData.percentage as string) * 100; // Cast to string + progress = Number(statusData.progress); } else if (statusData.status === 'done' || statusData.status === 'complete') { progress = 100; } else if (statusData.current_track && statusData.total_tracks) { @@ -2372,6 +2434,44 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): let totalTracks = 0; let trackProgress = 0; + // SPECIAL CASE: If this is the final 'done' status for the entire album/playlist (not a track) + if ((statusData.status === 'done' || statusData.status === 'complete') && + (statusData.type === 'album' || statusData.type === 'playlist') && + statusData.type === entry.type && + statusData.total_tracks) { + + console.log('Final album/playlist completion. Setting progress to 100%'); + + // Extract total tracks + totalTracks = parseInt(String(statusData.total_tracks), 10); + // Force current track to equal total tracks for completion + currentTrack = totalTracks; + + // Update counter to show n/n + if (progressCounter) { + progressCounter.textContent = `${totalTracks}/${totalTracks}`; + } + + // Set progress bar to 100% + if (overallProgressBar) { + overallProgressBar.style.width = '100%'; + overallProgressBar.setAttribute('aria-valuenow', '100'); + overallProgressBar.classList.add('complete'); + } + + // Hide track progress or set to complete + if (trackProgressBar) { + const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.taskId) as HTMLElement | null; + if (trackProgressContainer) { + trackProgressContainer.style.display = 'none'; // Optionally hide or set to 100% + } + } + + // Store for later use + entry.progress = 100; + return; + } + // Handle track-level updates for album/playlist downloads if (statusData.type === 'track' && statusData.parent && (entry.type === 'album' || entry.type === 'playlist')) { @@ -2399,6 +2499,12 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): // Get current track and total tracks from the status data if (statusData.current_track !== undefined) { currentTrack = parseInt(String(statusData.current_track), 10); + + // For completed tracks, use the track number rather than one less + if (statusData.status === 'done' || statusData.status === 'complete') { + // The current track is the one that just completed + currentTrack = parseInt(String(statusData.current_track), 10); + } // Get total tracks - try from statusData first, then from parent if (statusData.total_tracks !== undefined) { @@ -2412,7 +2518,10 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): // Get track progress for real-time updates if (statusData.status === 'real-time' && statusData.progress !== undefined) { - trackProgress = parseFloat(statusData.progress as string); // Cast to string + trackProgress = Number(statusData.progress); // Cast to number + } else if (statusData.status === 'done' || statusData.status === 'complete') { + // For a completed track, set trackProgress to 100% + trackProgress = 100; } // Update the track progress counter display @@ -2424,7 +2533,9 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): if (logElement && statusData.song && statusData.artist) { let progressInfo = ''; if (statusData.status === 'real-time' && trackProgress > 0) { - progressInfo = ` - ${trackProgress.toFixed(1)}%`; + progressInfo = ` - ${trackProgress}%`; + } else if (statusData.status === 'done' || statusData.status === 'complete') { + progressInfo = ' - Complete'; } logElement.textContent = `Currently downloading: ${statusData.song} by ${statusData.artist} (${currentTrack}/${totalTracks}${progressInfo})`; } @@ -2432,16 +2543,23 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): // Calculate and update the overall progress bar if (totalTracks > 0) { let overallProgress = 0; - // Always compute overall based on trackProgress if available, using album/playlist real-time formula - if (trackProgress !== undefined) { + + // For completed tracks, use completed/total + if (statusData.status === 'done' || statusData.status === 'complete') { + // For completed tracks, this track is fully complete + overallProgress = (currentTrack / totalTracks) * 100; + } + // For in-progress tracks, use the real-time formula + else if (trackProgress !== undefined) { const completedTracksProgress = (currentTrack - 1) / totalTracks; const currentTrackContribution = (1 / totalTracks) * (trackProgress / 100); overallProgress = (completedTracksProgress + currentTrackContribution) * 100; - console.log(`Overall progress: ${overallProgress.toFixed(2)}% (Track ${currentTrack}/${totalTracks}, Progress: ${trackProgress}%)`); } else { + // Fallback to track count method overallProgress = (currentTrack / totalTracks) * 100; - console.log(`Overall progress (non-real-time): ${overallProgress.toFixed(2)}% (Track ${currentTrack}/${totalTracks})`); } + + console.log(`Overall progress: ${overallProgress.toFixed(2)}% (Track ${currentTrack}/${totalTracks}, Progress: ${trackProgress}%)`); // Update the progress bar if (overallProgressBar) { @@ -2464,23 +2582,36 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): trackProgressContainer.style.display = 'block'; } - if (statusData.status === 'real-time') { - // Real-time progress for the current track - const safeTrackProgress = Math.max(0, Math.min(100, trackProgress)); - trackProgressBar.style.width = `${safeTrackProgress}%`; - trackProgressBar.setAttribute('aria-valuenow', safeTrackProgress.toString()); // Use string + if (statusData.status === 'real-time' || statusData.status === 'real_time') { + // For real-time updates, use the track progress for the small green progress bar + // This shows download progress for the current track only + const safeProgress = isNaN(trackProgress) ? 0 : Math.max(0, Math.min(100, trackProgress)); + trackProgressBar.style.width = `${safeProgress}%`; + trackProgressBar.setAttribute('aria-valuenow', String(safeProgress)); trackProgressBar.classList.add('real-time'); - if (safeTrackProgress >= 100) { + if (safeProgress >= 100) { trackProgressBar.classList.add('complete'); } else { trackProgressBar.classList.remove('complete'); } - } else { - // Indeterminate progress animation for non-real-time updates + } else if (statusData.status === 'done' || statusData.status === 'complete') { + // For completed tracks, show 100% + trackProgressBar.style.width = '100%'; + trackProgressBar.setAttribute('aria-valuenow', '100'); + trackProgressBar.classList.add('complete'); + } else if (['progress', 'processing'].includes(statusData.status || '')) { + // For non-real-time progress updates, show an indeterminate-style progress + // by using a pulsing animation via CSS trackProgressBar.classList.add('progress-pulse'); trackProgressBar.style.width = '100%'; - trackProgressBar.setAttribute('aria-valuenow', "50"); // Use string + trackProgressBar.setAttribute('aria-valuenow', String(50)); // indicate in-progress + } else { + // For other status updates, use current track position + trackProgressBar.classList.remove('progress-pulse'); + const trackPositionPercent = currentTrack > 0 ? 100 : 0; + trackProgressBar.style.width = `${trackPositionPercent}%`; + trackProgressBar.setAttribute('aria-valuenow', String(trackPositionPercent)); } } @@ -2523,18 +2654,21 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): totalTracks = parseInt(parts[1], 10); } + // For completed albums/playlists, ensure current track equals total tracks + if ((statusData.status === 'done' || statusData.status === 'complete') && + (statusData.type === 'album' || statusData.type === 'playlist') && + statusData.type === entry.type && + totalTracks > 0) { + currentTrack = totalTracks; + } + // Get track progress for real-time downloads if (statusData.status === 'real-time' && statusData.progress !== undefined) { // For real-time downloads, progress comes as a percentage value (0-100) - trackProgress = parseFloat(statusData.progress as string); // Cast to string - } else if (statusData.percent !== undefined) { - // Handle percent values (0-1) - trackProgress = parseFloat(statusData.percent as string) * 100; // Cast to string - } else if (statusData.percentage !== undefined) { - // Handle percentage values (0-1) - trackProgress = parseFloat(statusData.percentage as string) * 100; // Cast to string + trackProgress = Number(statusData.progress); // Cast to number } else if (statusData.status === 'done' || statusData.status === 'complete') { progress = 100; + trackProgress = 100; // Also set trackProgress to 100% for completed status } else if (statusData.current_track && statusData.total_tracks) { // If we don't have real-time progress but do have track position progress = (parseInt(statusData.current_track as string, 10) / parseInt(statusData.total_tracks as string, 10)) * 100; // Cast to string @@ -2730,6 +2864,18 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): const serverEquivalent = serverTasks.find(st => st.task_id === localEntry.taskId); if (serverEquivalent && serverEquivalent.last_status_obj && terminalStates.includes(serverEquivalent.last_status_obj.status)) { if (!localEntry.hasEnded) { + // Don't clean up if this is a track with a parent + if (serverEquivalent.last_status_obj.type === 'track' && serverEquivalent.last_status_obj.parent) { + console.log(`Periodic sync: Not cleaning up track ${serverEquivalent.last_status_obj.song} with parent ${serverEquivalent.last_status_obj.parent.type}`); + continue; + } + + // Only clean up if the types match (e.g., don't clean up an album when a track is done) + if (serverEquivalent.last_status_obj.type !== localEntry.type) { + console.log(`Periodic sync: Not cleaning up ${localEntry.type} entry due to ${serverEquivalent.last_status_obj.type} status update`); + continue; + } + console.log(`Periodic sync: Local task ${localEntry.taskId} is now terminal on server (${serverEquivalent.last_status_obj.status}). Cleaning up.`); this.handleDownloadCompletion(localEntry, localEntry.uniqueId, serverEquivalent.last_status_obj); } diff --git a/static/css/history/history.css b/static/css/history/history.css index 5267b84..42c1522 100644 --- a/static/css/history/history.css +++ b/static/css/history/history.css @@ -38,6 +38,71 @@ tr:nth-child(even) { background-color: #222; } +/* Parent and child track styling */ +.parent-task-row { + background-color: #282828 !important; + font-weight: bold; +} + +.child-track-row { + background-color: #1a1a1a !important; + font-size: 0.9em; +} + +.child-track-indent { + color: #1DB954; + margin-right: 5px; +} + +/* Track status styling */ +.track-status-successful { + color: #1DB954; + font-weight: bold; +} + +.track-status-skipped { + color: #FFD700; + font-weight: bold; +} + +.track-status-failed { + color: #FF4136; + font-weight: bold; +} + +/* Track counts display */ +.track-counts { + margin-left: 10px; + font-size: 0.85em; +} + +.track-count.success { + color: #1DB954; +} + +.track-count.skipped { + color: #FFD700; +} + +.track-count.failed { + color: #FF4136; +} + +/* Back button */ +#back-to-history { + margin-right: 15px; + padding: 5px 10px; + background-color: #333; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +#back-to-history:hover { + background-color: #444; +} + .pagination { margin-top: 20px; text-align: center; @@ -63,6 +128,7 @@ tr:nth-child(even) { display: flex; gap: 15px; align-items: center; + flex-wrap: wrap; } .filters label, .filters select, .filters input { @@ -77,9 +143,16 @@ tr:nth-child(even) { border-radius: 4px; } +.checkbox-filter { + display: flex; + align-items: center; + gap: 5px; +} + .status-COMPLETED { color: #1DB954; font-weight: bold; } .status-ERROR { color: #FF4136; font-weight: bold; } .status-CANCELLED { color: #AAAAAA; } +.status-skipped { color: #FFD700; font-weight: bold; } .error-message-toggle { cursor: pointer; @@ -97,8 +170,8 @@ tr:nth-child(even) { font-size: 0.9em; } -/* Styling for the Details icon button in the table */ -.details-btn { +/* Styling for the buttons in the table */ +.btn-icon { background-color: transparent; /* Or a subtle color like #282828 */ border: none; border-radius: 50%; /* Make it circular */ @@ -108,14 +181,23 @@ tr:nth-child(even) { align-items: center; justify-content: center; transition: background-color 0.2s ease; + margin-right: 5px; } -.details-btn img { +.btn-icon img { width: 16px; /* Icon size */ height: 16px; filter: invert(1); /* Make icon white if it's dark, adjust if needed */ } -.details-btn:hover { +.btn-icon:hover { background-color: #333; /* Darker on hover */ +} + +.details-btn:hover img { + filter: invert(0.8) sepia(1) saturate(5) hue-rotate(175deg); /* Make icon blue on hover */ +} + +.tracks-btn:hover img { + filter: invert(0.8) sepia(1) saturate(5) hue-rotate(90deg); /* Make icon green on hover */ } \ No newline at end of file diff --git a/static/css/queue/queue.css b/static/css/queue/queue.css index f52c882..7765c27 100644 --- a/static/css/queue/queue.css +++ b/static/css/queue/queue.css @@ -573,6 +573,85 @@ margin-top: 8px; } +/* ----------------------------- */ +/* DOWNLOAD SUMMARY ICONS */ +/* ----------------------------- */ + +/* Base styles for all summary icons */ +.summary-icon { + width: 14px; + height: 14px; + vertical-align: middle; + margin-right: 4px; + margin-top: -2px; +} + +/* Download summary formatting */ +.download-summary { + background: rgba(255, 255, 255, 0.05); + border-radius: 6px; + padding: 12px; + margin-top: 5px; +} + +.summary-line { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; +} + +.summary-line span { + display: flex; + align-items: center; + padding: 3px 8px; + border-radius: 4px; + font-weight: 500; +} + +/* Specific icon background colors */ +.summary-line span:nth-child(2) { + background: rgba(29, 185, 84, 0.1); /* Success background */ +} + +.summary-line span:nth-child(3) { + background: rgba(230, 126, 34, 0.1); /* Skip background */ +} + +.summary-line span:nth-child(4) { + background: rgba(255, 85, 85, 0.1); /* Failed background */ +} + +/* Failed tracks list styling */ +.failed-tracks-title { + color: #ff5555; + font-weight: 600; + margin: 10px 0 5px; + font-size: 13px; +} + +.failed-tracks-list { + list-style-type: none; + padding-left: 10px; + margin: 0; + font-size: 12px; + color: #b3b3b3; + max-height: 100px; + overflow-y: auto; +} + +.failed-tracks-list li { + padding: 3px 0; + position: relative; +} + +.failed-tracks-list li::before { + content: "•"; + color: #ff5555; + position: absolute; + left: -10px; +} + /* Base styles for error buttons */ .error-buttons button { border: none; diff --git a/static/html/history.html b/static/html/history.html index 044ae57..36001d8 100644 --- a/static/html/history.html +++ b/static/html/history.html @@ -19,7 +19,7 @@
-

Download History

+

Download History

@@ -38,6 +38,19 @@ + + + + +
+ + +
@@ -45,13 +58,13 @@ - + - + diff --git a/static/images/list.svg b/static/images/list.svg new file mode 100644 index 0000000..53fe06c --- /dev/null +++ b/static/images/list.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/static/images/skip.svg b/static/images/skip.svg new file mode 100644 index 0000000..b9a2ae2 --- /dev/null +++ b/static/images/skip.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file
Name ArtistTypeType/Status Service Quality Status Date Added Date Completed/EndedDetailsActions