diff --git a/.dockerignore b/.dockerignore index 617bfc5..790e94d 100755 --- a/.dockerignore +++ b/.dockerignore @@ -24,3 +24,4 @@ logs/ .env .venv data +tests/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 99b7d5b..61179f3 100755 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,22 @@ -# Use an official Python runtime as a parent image -FROM python:3.12-slim +# Stage 1: TypeScript build +FROM node:22.16.0-slim AS typescript-builder + +# Set working directory +WORKDIR /app + +# Copy necessary files for TypeScript build +COPY tsconfig.json ./tsconfig.json +COPY src/js ./src/js + +# Install TypeScript globally +RUN npm install -g typescript + +# Compile TypeScript +RUN tsc + +# Stage 2: Final image +FROM python:3.12-slim AS python-builder +LABEL org.opencontainers.image.source="https://github.com/Xoconoch/spotizerr" # Set the working directory in the container WORKDIR /app @@ -10,8 +27,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ gosu \ git \ ffmpeg \ - nodejs \ - npm \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* @@ -22,6 +37,7 @@ RUN npm install -g pnpm # Copy only the requirements file to leverage Docker cache COPY requirements.txt . # Install Python dependencies +COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # --- Frontend Node.js Dependencies --- diff --git a/app.py b/app.py index 13cdedf..2d26b3a 100755 --- a/app.py +++ b/app.py @@ -46,7 +46,7 @@ def setup_logging(): # Log formatting log_format = logging.Formatter( - "%(asctime)s [%(processName)s:%(threadName)s] [%(name)s] [%(levelname)s] - %(message)s", + "%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) diff --git a/requirements.txt b/requirements.txt index 65b8527..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.7.0 +deezspot-spotizerr==1.10.0 \ No newline at end of file diff --git a/routes/__init__.py b/routes/__init__.py index 9f959dc..4d89a25 100755 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -4,7 +4,7 @@ import atexit # Configure basic logging for the application if not already configured # This is a good place for it if routes are a central part of your app structure. logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" + level=logging.INFO, format="%(message)s" ) logger = logging.getLogger(__name__) diff --git a/routes/album.py b/routes/album.py index 98f6d6d..6b24c4a 100755 --- a/routes/album.py +++ b/routes/album.py @@ -111,25 +111,25 @@ def handle_download(album_id): ) return Response( - json.dumps({"prg_file": task_id}), status=202, mimetype="application/json" + json.dumps({"task_id": task_id}), status=202, mimetype="application/json" ) @album_bp.route("/download/cancel", methods=["GET"]) def cancel_download(): """ - Cancel a running download process by its prg file name. + Cancel a running download process by its task id. """ - prg_file = request.args.get("prg_file") - if not prg_file: + task_id = request.args.get("task_id") + if not task_id: return Response( - json.dumps({"error": "Missing process id (prg_file) parameter"}), + json.dumps({"error": "Missing process id (task_id) parameter"}), status=400, mimetype="application/json", ) # Use the queue manager's cancellation method. - result = download_queue_manager.cancel_task(prg_file) + result = download_queue_manager.cancel_task(task_id) status_code = 200 if result.get("status") == "cancelled" else 404 return Response(json.dumps(result), status=status_code, mimetype="application/json") 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/playlist.py b/routes/playlist.py index 268b772..a17a98f 100755 --- a/routes/playlist.py +++ b/routes/playlist.py @@ -133,7 +133,7 @@ def handle_download(playlist_id): ) return Response( - json.dumps({"prg_file": task_id}), # prg_file is the old name for task_id + json.dumps({"task_id": task_id}), status=202, mimetype="application/json", ) @@ -142,18 +142,18 @@ def handle_download(playlist_id): @playlist_bp.route("/download/cancel", methods=["GET"]) def cancel_download(): """ - Cancel a running playlist download process by its prg file name. + Cancel a running playlist download process by its task id. """ - prg_file = request.args.get("prg_file") - if not prg_file: + task_id = request.args.get("task_id") + if not task_id: return Response( - json.dumps({"error": "Missing process id (prg_file) parameter"}), + json.dumps({"error": "Missing task id (task_id) parameter"}), status=400, mimetype="application/json", ) # Use the queue manager's cancellation method. - result = download_queue_manager.cancel_task(prg_file) + result = download_queue_manager.cancel_task(task_id) status_code = 200 if result.get("status") == "cancelled" else 404 return Response(json.dumps(result), status=status_code, mimetype="application/json") diff --git a/routes/prgs.py b/routes/prgs.py index 5795ee8..23ae233 100755 --- a/routes/prgs.py +++ b/routes/prgs.py @@ -21,16 +21,15 @@ prgs_bp = Blueprint("prgs", __name__, url_prefix="/api/prgs") @prgs_bp.route("/", methods=["GET"]) -def get_prg_file(task_id): +def get_task_details(task_id): """ Return a JSON object with the resource type, its name (title), the last progress update, and, if available, the original request parameters. - This function works with both the old PRG file system (for backward compatibility) - and the new task ID based system. + This function works with the new task ID based system. Args: - task_id: Either a task UUID from Celery or a PRG filename from the old system + task_id: A task UUID from Celery """ # Only support new task IDs task_info = get_task_info(task_id) @@ -77,24 +76,31 @@ def get_prg_file(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) @prgs_bp.route("/delete/", methods=["DELETE"]) -def delete_prg_file(task_id): +def delete_task(task_id): """ Delete a task's information and history. - Works with both the old PRG file system and the new task ID based system. Args: - task_id: Either a task UUID from Celery or a PRG filename from the old system + task_id: A task UUID from Celery """ # Only support new task IDs task_info = get_task_info(task_id) @@ -107,7 +113,7 @@ def delete_prg_file(task_id): @prgs_bp.route("/list", methods=["GET"]) -def list_prg_files(): +def list_tasks(): """ Retrieve a list of all tasks in the system. Returns a detailed list of task objects including status and metadata. @@ -124,33 +130,34 @@ def list_prg_files(): 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/track.py b/routes/track.py index 01406c9..3f86828 100755 --- a/routes/track.py +++ b/routes/track.py @@ -127,7 +127,7 @@ def handle_download(track_id): ) return Response( - json.dumps({"prg_file": task_id}), # prg_file is the old name for task_id + json.dumps({"task_id": task_id}), status=202, mimetype="application/json", ) @@ -136,18 +136,18 @@ def handle_download(track_id): @track_bp.route("/download/cancel", methods=["GET"]) def cancel_download(): """ - Cancel a running track download process by its process id (prg file name). + Cancel a running track download process by its task id. """ - prg_file = request.args.get("prg_file") - if not prg_file: + task_id = request.args.get("task_id") + if not task_id: return Response( - json.dumps({"error": "Missing process id (prg_file) parameter"}), + json.dumps({"error": "Missing task id (task_id) parameter"}), status=400, mimetype="application/json", ) # Use the queue manager's cancellation method. - result = download_queue_manager.cancel_task(prg_file) + result = download_queue_manager.cancel_task(task_id) status_code = 200 if result.get("status") == "cancelled" else 404 return Response(json.dumps(result), status=status_code, mimetype="application/json") diff --git a/routes/utils/celery_manager.py b/routes/utils/celery_manager.py index f2fcf40..1fc0504 100644 --- a/routes/utils/celery_manager.py +++ b/routes/utils/celery_manager.py @@ -69,8 +69,16 @@ class CeleryManager: try: for line in iter(stream.readline, ""): if line: - log_method = logger.error if error else logger.info - log_method(f"{log_prefix}: {line.strip()}") + line_stripped = line.strip() + log_method = logger.info # Default log method + + if error: # This is a stderr stream + if " - ERROR - " in line_stripped or " - CRITICAL - " in line_stripped: + log_method = logger.error + elif " - WARNING - " in line_stripped: + log_method = logger.warning + + log_method(f"{log_prefix}: {line_stripped}") elif ( self.stop_event.is_set() ): # If empty line and stop is set, likely EOF @@ -359,7 +367,7 @@ celery_manager = CeleryManager() if __name__ == "__main__": logging.basicConfig( level=logging.INFO, - format="%(asctime)s [%(levelname)s] [%(threadName)s] [%(name)s] - %(message)s", + format="%(message)s", ) logger.info("Starting Celery Manager example...") celery_manager.start() 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 155ba30..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 --- @@ -366,8 +388,8 @@ def retry_task(task_id): # Update service settings if service == "spotify": if fallback_enabled: - task_info["main"] = config_params.get("deezer", "") - task_info["fallback"] = config_params.get("spotify", "") + task_info["main"] = config_params.get("spotify", "") + task_info["fallback"] = config_params.get("deezer", "") task_info["quality"] = config_params.get("deezerQuality", "MP3_128") task_info["fall_quality"] = config_params.get( "spotifyQuality", "NORMAL" @@ -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')}" ) @@ -1335,8 +1366,8 @@ def download_track(self, **task_data): # Determine service parameters if service == "spotify": if fallback_enabled: - main = config_params.get("deezer", "") - fallback = config_params.get("spotify", "") + main = config_params.get("spotify", "") + fallback = config_params.get("deezer", "") quality = config_params.get("deezerQuality", "MP3_128") fall_quality = config_params.get("spotifyQuality", "NORMAL") else: @@ -1421,8 +1452,8 @@ def download_album(self, **task_data): # Determine service parameters if service == "spotify": if fallback_enabled: - main = config_params.get("deezer", "") - fallback = config_params.get("spotify", "") + main = config_params.get("spotify", "") + fallback = config_params.get("deezer", "") quality = config_params.get("deezerQuality", "MP3_128") fall_quality = config_params.get("spotifyQuality", "NORMAL") else: @@ -1507,8 +1538,8 @@ def download_playlist(self, **task_data): # Determine service parameters if service == "spotify": if fallback_enabled: - main = config_params.get("deezer", "") - fallback = config_params.get("spotify", "") + main = config_params.get("spotify", "") + fallback = config_params.get("deezer", "") quality = config_params.get("deezerQuality", "MP3_128") fall_quality = config_params.get("spotifyQuality", "NORMAL") else: diff --git a/routes/utils/credentials.py b/routes/utils/credentials.py index 23a5cef..3b9e953 100755 --- a/routes/utils/credentials.py +++ b/routes/utils/credentials.py @@ -403,6 +403,9 @@ def get_credential(service, name): "name": data.get("name"), "region": data.get("region"), "blob_content": data.get("blob_content"), + "blob_file_path": data.get( + "blob_file_path" + ), # Ensure blob_file_path is returned } return cleaned_data 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/spotizerr-ui/public/list.svg b/spotizerr-ui/public/list.svg new file mode 100644 index 0000000..53fe06c --- /dev/null +++ b/spotizerr-ui/public/list.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/spotizerr-ui/public/skip.svg b/spotizerr-ui/public/skip.svg new file mode 100644 index 0000000..8fce572 --- /dev/null +++ b/spotizerr-ui/public/skip.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/js/history.ts b/src/js/history.ts new file mode 100644 index 0000000..07d1540 --- /dev/null +++ b/src/js/history.ts @@ -0,0 +1,330 @@ +document.addEventListener('DOMContentLoaded', () => { + const historyTableBody = document.getElementById('history-table-body') as HTMLTableSectionElement | null; + const prevButton = document.getElementById('prev-page') as HTMLButtonElement | null; + const nextButton = document.getElementById('next-page') as HTMLButtonElement | null; + const pageInfo = document.getElementById('page-info') as HTMLSpanElement | null; + 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) { + console.error('One or more critical UI elements are missing for history page.'); + return; + } + + const offset = (page - 1) * limit; + let apiUrl = `/api/history?limit=${limit}&offset=${offset}&sort_by=${currentSortBy}&sort_order=${currentSortOrder}`; + + const statusVal = statusFilter.value; + if (statusVal) { + apiUrl += `&status_final=${statusVal}`; + } + const typeVal = typeFilter.value; + 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); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + renderHistory(data.entries); + totalEntries = data.total_count; + 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.'; + } + } + } + + function renderHistory(entries: any[]) { + if (!historyTableBody) return; + + historyTableBody.innerHTML = ''; // Clear existing rows + if (!entries || entries.length === 0) { + historyTableBody.innerHTML = 'No history entries found.'; + return; + } + + entries.forEach(entry => { + const row = historyTableBody.insertRow(); + + // 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'; + + // 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'; + + // Check if convert_to exists and is not "None" + if (entry.convert_to && entry.convert_to !== "None") { + qualityDisplay = `${entry.convert_to.toUpperCase()}`; + // Check if bitrate exists and is not "None" + if (entry.bitrate && entry.bitrate !== "None") { + qualityDisplay += ` ${entry.bitrate}k`; + } + qualityDisplay += ` (${entry.quality_profile || 'Original'})`; + } else if (entry.bitrate && entry.bitrate !== "None") { // 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'})`; + } + // If both are "None" or null, it will just use the quality_profile value set above + qualityCell.textContent = qualityDisplay; + + const statusCell = row.insertCell(); + statusCell.textContent = entry.status_final || 'N/A'; + 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 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); + 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'); + errorSpan.textContent = ' (Show Error)'; + errorSpan.className = 'error-message-toggle'; + errorSpan.style.marginLeft = '5px'; + errorSpan.onclick = (e) => { + e.stopPropagation(); // Prevent click on row if any + let errorDetailsDiv = row.querySelector('.error-details') as HTMLElement | null; + if (!errorDetailsDiv) { + errorDetailsDiv = document.createElement('div'); + errorDetailsDiv.className = 'error-details'; + const newCell = row.insertCell(); // This will append to the end of the row + newCell.colSpan = 10; // Span across all columns + newCell.appendChild(errorDetailsDiv); + } + errorDetailsDiv.textContent = entry.error_message; + // Toggle display by directly manipulating the style of the details div + errorDetailsDiv.style.display = errorDetailsDiv.style.display === 'none' ? 'block' : 'none'; + }; + statusCell.appendChild(errorSpan); + } + }); + } + + function updatePagination() { + if (!pageInfo || !prevButton || !nextButton) return; + + const totalPages = Math.ceil(totalEntries / limit) || 1; + pageInfo.textContent = `Page ${currentPage} of ${totalPages}`; + 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) { + // 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', () => { + const sortField = (headerCell as HTMLElement).dataset.sort; + if (!sortField) return; + + if (currentSortBy === sortField) { + currentSortOrder = currentSortOrder === 'ASC' ? 'DESC' : 'ASC'; + } else { + currentSortBy = sortField; + currentSortOrder = 'DESC'; + } + fetchHistory(1); + }); + }); + + function updateSortIndicators() { + document.querySelectorAll('th[data-sort]').forEach(headerCell => { + const th = headerCell as HTMLElement; + th.classList.remove('sort-asc', 'sort-desc'); + if (th.dataset.sort === currentSortBy) { + th.classList.add(currentSortOrder === 'ASC' ? 'sort-asc' : 'sort-desc'); + } + }); + } + + // Event listeners for pagination and filters + prevButton?.addEventListener('click', () => fetchHistory(currentPage - 1)); + nextButton?.addEventListener('click', () => fetchHistory(currentPage + 1)); + limitSelect?.addEventListener('change', (e) => { + limit = parseInt((e.target as HTMLSelectElement).value, 10); + fetchHistory(1); + }); + statusFilter?.addEventListener('change', () => fetchHistory(1)); + typeFilter?.addEventListener('change', () => fetchHistory(1)); + trackFilter?.addEventListener('change', () => fetchHistory(1)); + hideChildTracksCheckbox?.addEventListener('change', () => fetchHistory(1)); + + // Initial fetch + fetchHistory(); +}); \ No newline at end of file diff --git a/src/js/queue.ts b/src/js/queue.ts new file mode 100644 index 0000000..8e7414a --- /dev/null +++ b/src/js/queue.ts @@ -0,0 +1,2895 @@ +class CustomURLSearchParams { + params: Record; + constructor() { + this.params = {}; + } + append(key: string, value: string): void { + this.params[key] = value; + } + toString(): string { + return Object.entries(this.params) + .map(([key, value]: [string, string]) => `${key}=${value}`) + .join('&'); + } +} + +// Interfaces for complex objects +interface QueueItem { + name?: string; + music?: string; + song?: string; + artist?: string; + artists?: { name: string }[]; + album?: { name: string }; + owner?: string | { display_name?: string }; + total_tracks?: number; + url?: string; + type?: string; // Added for artist downloads + parent?: ParentInfo; // For tracks within albums/playlists + // For PRG file loading + display_title?: string; + display_artist?: string; + endpoint?: string; + download_type?: string; + [key: string]: any; // Allow other properties +} + +interface ParentInfo { + type: 'album' | 'playlist'; + title?: string; // for album + artist?: string; // for album + name?: string; // for playlist + owner?: string; // for playlist + total_tracks?: number; + url?: string; + [key: string]: any; // Allow other properties +} + +interface StatusData { + 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; + 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; + original_request?: { + url?: string; + retry_url?: string; + name?: string; + artist?: string; + type?: string; + endpoint?: string; + download_type?: string; + display_title?: string; + display_type?: string; + display_artist?: string; + service?: string; + [key: string]: any; + }; + event?: string; + overall_progress?: number; + display_type?: string; + + [key: string]: any; // Allow other properties +} + +interface QueueEntry { + item: QueueItem; + type: string; + taskId: string; + requestUrl: string | null; + element: HTMLElement; + lastStatus: StatusData; + lastUpdated: number; + hasEnded: boolean; + intervalId: number | null; // NodeJS.Timeout for setInterval/clearInterval + uniqueId: string; + retryCount: number; + autoRetryInterval: number | null; + isNew: boolean; + status: string; + lastMessage: string; + parentInfo: ParentInfo | null; + isRetrying?: boolean; + progress?: number; // for multi-track overall progress + realTimeStallDetector: { count: number; lastStatusJson: string }; + [key: string]: any; // Allow other properties +} + +interface AppConfig { + downloadQueueVisible?: boolean; + maxRetries?: number; + retryDelaySeconds?: number; + retry_delay_increase?: number; + explicitFilter?: boolean; + [key: string]: any; // Allow other config properties +} + +// Ensure DOM elements are queryable +declare global { + interface Document { + getElementById(elementId: string): HTMLElement | null; + } +} + +export class DownloadQueue { + // Constants read from the server config + MAX_RETRIES: number = 3; // Default max retries + RETRY_DELAY: number = 5; // Default retry delay in seconds + RETRY_DELAY_INCREASE: number = 5; // Default retry delay increase in seconds + + // Cache for queue items + queueCache: Record = {}; + + // Queue entry objects + queueEntries: Record = {}; + + // Polling intervals for progress tracking + pollingIntervals: Record = {}; // NodeJS.Timeout for setInterval + + // DOM elements cache (Consider if this is still needed or how it's used) + elements: Record = {}; // Example type, adjust as needed + + // Event handlers (Consider if this is still needed or how it's used) + eventHandlers: Record = {}; // Example type, adjust as needed + + // Configuration + config: AppConfig = {}; // Initialize with an empty object or a default config structure + + // Load the saved visible count (or default to 10) + visibleCount: number; + + constructor() { + const storedVisibleCount = localStorage.getItem("downloadQueueVisibleCount"); + this.visibleCount = storedVisibleCount ? parseInt(storedVisibleCount, 10) : 10; + + this.queueCache = JSON.parse(localStorage.getItem("downloadQueueCache") || "{}"); + + // Constants read from the server config + this.MAX_RETRIES = 3; // Default max retries + this.RETRY_DELAY = 5; // Default retry delay in seconds + this.RETRY_DELAY_INCREASE = 5; // Default retry delay increase in seconds + + // Cache for queue items + // this.queueCache = {}; // Already initialized above + + // Queue entry objects + this.queueEntries = {}; + + // Polling intervals for progress tracking + this.pollingIntervals = {}; + + // DOM elements cache + this.elements = {}; + + // Event handlers + this.eventHandlers = {}; + + // Configuration + this.config = {}; // Initialize config + + // Load the saved visible count (or default to 10) - This block is redundant + // const storedVisibleCount = localStorage.getItem("downloadQueueVisibleCount"); + // this.visibleCount = storedVisibleCount ? parseInt(storedVisibleCount, 10) : 10; + + // Load the cached status info (object keyed by taskId) - This is also redundant + // this.queueCache = JSON.parse(localStorage.getItem("downloadQueueCache") || "{}"); + + // Wait for initDOM to complete before setting up event listeners and loading existing PRG files. + this.initDOM().then(() => { + this.initEventListeners(); + this.loadExistingTasks(); + // Start periodic sync + setInterval(() => this.periodicSyncWithServer(), 10000); // Sync every 10 seconds + }); + } + + /* DOM Management */ + async initDOM() { + // New HTML structure for the download queue. + const queueHTML = ` + + `; + document.body.insertAdjacentHTML('beforeend', queueHTML); + + // Load initial config from the server. + await this.loadConfig(); + + // Use localStorage for queue visibility + const storedVisible = localStorage.getItem("downloadQueueVisible"); + const isVisible = storedVisible === "true"; + + const queueSidebar = document.getElementById('downloadQueue'); + 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) { + if (isVisible) { + queueIcon.innerHTML = 'Close queue'; + queueIcon.setAttribute('aria-expanded', 'true'); + queueIcon.classList.add('queue-icon-active'); // Add red tint class + } else { + queueIcon.innerHTML = 'Queue Icon'; + queueIcon.setAttribute('aria-expanded', 'false'); + queueIcon.classList.remove('queue-icon-active'); // Remove red tint class + } + } + } + + /* Event Handling */ + initEventListeners() { + // Toggle queue visibility via Escape key. + document.addEventListener('keydown', async (e: KeyboardEvent) => { + const queueSidebar = document.getElementById('downloadQueue'); + if (e.key === 'Escape' && queueSidebar?.classList.contains('active')) { + await this.toggleVisibility(); + } + }); + + // "Cancel all" button. + const cancelAllBtn = document.getElementById('cancelAllBtn'); + if (cancelAllBtn) { + cancelAllBtn.addEventListener('click', () => { + for (const queueId in this.queueEntries) { + const entry = this.queueEntries[queueId]; + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; + if (entry && !entry.hasEnded && entry.taskId) { + // Mark as cancelling visually + if (entry.element) { + entry.element.classList.add('cancelling'); + } + if (logElement) { + logElement.textContent = "Cancelling..."; + } + + // Cancel each active download + fetch(`/api/${entry.type}/download/cancel?task_id=${entry.taskId}`) + .then(response => response.json()) + .then(data => { + // API returns status 'cancelled' when cancellation succeeds + if (data.status === "cancelled" || data.status === "cancel") { + entry.hasEnded = true; + if (entry.intervalId) { + clearInterval(entry.intervalId as number); // Cast to number for clearInterval + entry.intervalId = null; + } + // Remove the entry as soon as the API confirms cancellation + this.cleanupEntry(queueId); + } + }) + .catch(error => console.error('Cancel error:', error)); + } + } + this.clearAllPollingIntervals(); + }); + } + + // Close all SSE connections when the page is about to unload + window.addEventListener('beforeunload', () => { + this.clearAllPollingIntervals(); + }); + } + + /* Public API */ + async toggleVisibility(force?: boolean) { + const queueSidebar = document.getElementById('downloadQueue'); + if (!queueSidebar) return; // Guard against null + // If force is provided, use that value, otherwise toggle the current state + const isVisible = force !== undefined ? force : !queueSidebar.classList.contains('active'); + + queueSidebar.classList.toggle('active', isVisible); + queueSidebar.hidden = !isVisible; + + // Update the queue icon to show X when visible or queue icon when hidden + const queueIcon = document.getElementById('queueIcon'); + if (queueIcon) { + if (isVisible) { + // Replace the image with an X and add red tint + queueIcon.innerHTML = 'Close queue'; + queueIcon.setAttribute('aria-expanded', 'true'); + queueIcon.classList.add('queue-icon-active'); // Add red tint class + } else { + // Restore the original queue icon and remove red tint + queueIcon.innerHTML = 'Queue Icon'; + queueIcon.setAttribute('aria-expanded', 'false'); + queueIcon.classList.remove('queue-icon-active'); // Remove red tint class + } + } + + // Only persist the state in localStorage, not on the server + localStorage.setItem("downloadQueueVisible", String(isVisible)); + this.dispatchEvent('queueVisibilityChanged', { visible: isVisible }); + + if (isVisible) { + // If the queue is now visible, ensure all visible items are being polled. + this.startMonitoringActiveEntries(); + } + } + + showError(message: string) { + const errorDiv = document.createElement('div'); + errorDiv.className = 'queue-error'; + errorDiv.textContent = message; + document.getElementById('queueItems')?.prepend(errorDiv); // Optional chaining + setTimeout(() => errorDiv.remove(), 3000); + } + + /** + * Adds a new download entry. + */ + addDownload(item: QueueItem, type: string, taskId: string, requestUrl: string | null = null, startMonitoring: boolean = false): string { + const queueId = this.generateQueueId(); + const entry = this.createQueueEntry(item, type, taskId, queueId, requestUrl); + this.queueEntries[queueId] = entry; + // Re-render and update which entries are processed. + this.updateQueueOrder(); + + // Start monitoring if explicitly requested, regardless of visibility + if (startMonitoring) { + this.startDownloadStatusMonitoring(queueId); + } + + this.dispatchEvent('downloadAdded', { queueId, item, type }); + return queueId; // Return the queueId so callers can reference it + } + + /* Start processing the entry. Removed visibility check to ensure all entries are monitored. */ + async startDownloadStatusMonitoring(queueId: string) { + const entry = this.queueEntries[queueId]; + if (!entry || entry.hasEnded) return; + + // Don't restart monitoring if polling interval already exists + if (this.pollingIntervals[queueId]) return; + + // Ensure entry has data containers for parent info + entry.parentInfo = entry.parentInfo || null; + + // Show a preparing message for new entries + if (entry.isNew) { + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; + if (logElement) { + logElement.textContent = "Initializing download..."; + } + } + + console.log(`Starting monitoring for ${entry.type} with task ID: ${entry.taskId}`); + + // For backward compatibility, first try to get initial status from the REST API + try { + const response = await fetch(`/api/prgs/${entry.taskId}`); + if (response.ok) { + const data: StatusData = await response.json(); // Add type to data + + // Update entry type if available + if (data.type) { + entry.type = data.type; + + // Update type display if element exists + const typeElement = entry.element.querySelector('.type') as HTMLElement | null; + if (typeElement) { + typeElement.textContent = data.type.charAt(0).toUpperCase() + data.type.slice(1); + typeElement.className = `type ${data.type}`; + } + } + + // Update request URL if available + if (!entry.requestUrl && data.original_request) { + const params = new CustomURLSearchParams(); + for (const key in data.original_request) { + params.append(key, data.original_request[key]); + } + entry.requestUrl = `/api/${entry.type}/download?${params.toString()}`; + } + + // Override requestUrl with server original_url if provided + if (data.original_url) { + entry.requestUrl = data.original_url; + } + + // Process the initial status + if (data.last_line) { + entry.lastStatus = data.last_line; + entry.lastUpdated = Date.now(); + entry.status = data.last_line.status || 'unknown'; // Ensure status is not undefined + + // Update status message without recreating the element + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; + if (logElement) { + const statusMessage = this.getStatusMessage(data.last_line); + logElement.textContent = statusMessage; + } + + // Apply appropriate CSS classes based on status + this.applyStatusClasses(entry, data.last_line); + + // Save updated status to cache, ensuring we preserve parent data + this.queueCache[entry.taskId] = { + ...data.last_line, + // Ensure parent data is preserved + parent: data.last_line.parent || entry.lastStatus?.parent + }; + + // If this is a track with a parent, update the display elements to match the parent + if (data.last_line.type === 'track' && data.last_line.parent) { + const parent = data.last_line.parent; + entry.parentInfo = parent; + + // Update type and UI to reflect the parent type + if (parent.type === 'album' || parent.type === 'playlist') { + // Only change type if it's not already set to the parent type + if (entry.type !== parent.type) { + entry.type = parent.type; + + // Update the type indicator + const typeEl = entry.element.querySelector('.type') as HTMLElement | null; + if (typeEl) { + const displayType = parent.type.charAt(0).toUpperCase() + parent.type.slice(1); + typeEl.textContent = displayType; + typeEl.className = `type ${parent.type}`; + } + + // Update the title and subtitle based on parent type + const titleEl = entry.element.querySelector('.title') as HTMLElement | null; + const artistEl = entry.element.querySelector('.artist') as HTMLElement | null; + + if (parent.type === 'album') { + if (titleEl) titleEl.textContent = parent.title || 'Unknown album'; + if (artistEl) artistEl.textContent = parent.artist || 'Unknown artist'; + } else if (parent.type === 'playlist') { + if (titleEl) titleEl.textContent = parent.name || 'Unknown playlist'; + if (artistEl) artistEl.textContent = parent.owner || 'Unknown creator'; + } + } + } + } + + localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); + + // If the entry is already in a terminal state, don't set up polling + if (['error', 'complete', 'cancel', 'cancelled', 'done'].includes(data.last_line.status || '')) { // Add null check for status + entry.hasEnded = true; + this.handleDownloadCompletion(entry, queueId, data.last_line); + return; + } + } + } + } catch (error) { + console.error('Initial status check failed:', error); + } + + // Set up polling interval for real-time updates + this.setupPollingInterval(queueId); + } + + /* Helper Methods */ + generateQueueId() { + return Date.now().toString() + Math.random().toString(36).substr(2, 9); + } + + /** + * Creates a new queue entry. It checks localStorage for any cached info. + */ + createQueueEntry(item: QueueItem, type: string, taskId: string, queueId: string, requestUrl: string | null): QueueEntry { + console.log(`Creating queue entry with initial type: ${type}`); + + // Get cached data if it exists + const cachedData: StatusData | undefined = this.queueCache[taskId]; // Add type + + // If we have cached data, use it to determine the true type and item properties + if (cachedData) { + // If this is a track with a parent, update type and item to match the parent + if (cachedData.type === 'track' && cachedData.parent) { + if (cachedData.parent.type === 'album') { + type = 'album'; + item = { + name: cachedData.parent.title, + artist: cachedData.parent.artist, + total_tracks: cachedData.parent.total_tracks, + url: cachedData.parent.url + }; + } else if (cachedData.parent.type === 'playlist') { + type = 'playlist'; + item = { + name: cachedData.parent.name, + owner: cachedData.parent.owner, + total_tracks: cachedData.parent.total_tracks, + url: cachedData.parent.url + }; + } + } + // If we're reconstructing an album or playlist directly + else if (cachedData.type === 'album') { + item = { + name: cachedData.title || cachedData.album || 'Unknown album', + artist: cachedData.artist || 'Unknown artist', + total_tracks: typeof cachedData.total_tracks === 'string' ? parseInt(cachedData.total_tracks, 10) : cachedData.total_tracks || 0 + }; + } else if (cachedData.type === 'playlist') { + item = { + name: cachedData.name || 'Unknown playlist', + owner: cachedData.owner || 'Unknown creator', + total_tracks: typeof cachedData.total_tracks === 'string' ? parseInt(cachedData.total_tracks, 10) : cachedData.total_tracks || 0 + }; + } + } + + // Build the basic entry with possibly updated type and item + const entry: QueueEntry = { // Add type to entry + item, + type, + taskId, + requestUrl, // for potential retry + element: this.createQueueItem(item, type, taskId, queueId), + lastStatus: { + // Initialize with basic item metadata for immediate display + type, + status: 'initializing', + name: item.name || 'Unknown', + artist: item.artist || item.artists?.[0]?.name || '', + album: item.album?.name || '', + title: item.name || '', + owner: typeof item.owner === 'string' ? item.owner : item.owner?.display_name || '', + total_tracks: item.total_tracks || 0 + }, + lastUpdated: Date.now(), + hasEnded: false, + intervalId: null, + uniqueId: queueId, + retryCount: 0, + autoRetryInterval: null, + isNew: true, // Add flag to track if this is a new entry + status: 'initializing', + lastMessage: `Initializing ${type} download...`, + parentInfo: null, // Will store parent data for tracks that are part of albums/playlists + realTimeStallDetector: { count: 0, lastStatusJson: '' } // For detecting stalled real_time downloads + }; + + // If cached info exists for this task, use it. + if (cachedData) { + entry.lastStatus = cachedData; + const logEl = entry.element.querySelector('.log') as HTMLElement | null; + + // Store parent information if available + if (cachedData.parent) { + entry.parentInfo = cachedData.parent; + } + + // Render status message for cached data + if (logEl) { // Check if logEl is not null + logEl.textContent = this.getStatusMessage(entry.lastStatus); + } + } + + // Store it in our queue object + this.queueEntries[queueId] = entry; + + return entry; + } + + /** + * Returns an HTML element for the queue entry with modern UI styling. + */ +createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): HTMLElement { + // Track whether this is a multi-track item (album or playlist) + const isMultiTrack = type === 'album' || type === 'playlist'; + 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.song || 'Unknown'; + const displayArtist = item.artist || ''; + const displayType = type.charAt(0).toUpperCase() + type.slice(1); + + const div = document.createElement('article') as HTMLElement; // Cast to HTMLElement + div.className = 'queue-item queue-item-new'; // Add the animation class + div.setAttribute('aria-live', 'polite'); + div.setAttribute('aria-atomic', 'true'); + div.setAttribute('data-type', type); + + // Create modern HTML structure with better visual hierarchy + let innerHtml = ` +
+
+
${displayTitle}
+ ${displayArtist ? `
${displayArtist}
` : ''} +
${displayType}
+
+ +
+ +
+
${defaultMessage}
+ + + + +
+ +
+
+
+ + +
+
+
`; + + // For albums and playlists, add an overall progress container + if (isMultiTrack) { + innerHtml += ` +
+
+ Overall Progress + 0/0 +
+
+
+
+
`; + } + + div.innerHTML = innerHtml; + + (div.querySelector('.cancel-btn') as HTMLButtonElement | null)?.addEventListener('click', (e: MouseEvent) => this.handleCancelDownload(e)); // Add types and optional chaining + + // Remove the animation class after animation completes + setTimeout(() => { + div.classList.remove('queue-item-new'); + }, 300); // Match the animation duration + + return div; +} + + // Add a helper method to apply the right CSS classes based on status + applyStatusClasses(entry: QueueEntry, statusData: StatusData) { // Add types for statusData + // If no element, nothing to do + if (!entry.element) return; + + // Remove all status classes first + entry.element.classList.remove( + 'queued', 'initializing', 'downloading', 'processing', + 'error', 'complete', 'cancelled', 'progress' + ); + + // Handle various status types + switch (statusData.status) { // Use statusData.status + case 'queued': + entry.element.classList.add('queued'); + break; + case 'initializing': + entry.element.classList.add('initializing'); + break; + case 'processing': + case 'downloading': + entry.element.classList.add('processing'); + break; + case 'progress': + case 'track_progress': + case 'real_time': + entry.element.classList.add('progress'); + break; + case 'error': + entry.element.classList.add('error'); + // Hide error-details to prevent duplicate error display + const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; + if (errorDetailsContainer) { + errorDetailsContainer.style.display = 'none'; + } + break; + case 'complete': + case 'done': + entry.element.classList.add('complete'); + // Hide error details if present + if (entry.element) { + const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; + if (errorDetailsContainer) { + errorDetailsContainer.style.display = 'none'; + } + } + break; + case 'cancelled': + entry.element.classList.add('cancelled'); + // Hide error details if present + if (entry.element) { + const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; + if (errorDetailsContainer) { + errorDetailsContainer.style.display = 'none'; + } + } + break; + } + } + + async handleCancelDownload(e: MouseEvent) { // Add type for e + const btn = (e.target as HTMLElement).closest('button') as HTMLButtonElement | null; // Add types and null check + if (!btn) return; // Guard clause + btn.style.display = 'none'; + const { taskid, type, queueid } = btn.dataset; + if (!taskid || !type || !queueid) return; // Guard against undefined dataset properties + + try { + // Get the queue item element + const entry = this.queueEntries[queueid]; + if (entry && entry.element) { + // Add a visual indication that it's being cancelled + entry.element.classList.add('cancelling'); + } + + // Show cancellation in progress + const logElement = document.getElementById(`log-${queueid}-${taskid}`) as HTMLElement | null; + if (logElement) { + logElement.textContent = "Cancelling..."; + } + + // First cancel the download + const response = await fetch(`/api/${type}/download/cancel?task_id=${taskid}`); + const data = await response.json(); + // API returns status 'cancelled' when cancellation succeeds + if (data.status === "cancelled" || data.status === "cancel") { + if (entry) { + entry.hasEnded = true; + + // Close any active connections + this.clearPollingInterval(queueid); + + if (entry.intervalId) { + clearInterval(entry.intervalId as number); // Cast to number + entry.intervalId = null; + } + + // Mark as cancelled in the cache to prevent re-loading on page refresh + entry.status = "cancelled"; + this.queueCache[taskid] = { status: "cancelled" }; + localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); + + // Immediately remove the item from the UI + this.cleanupEntry(queueid); + } + } + } catch (error) { + console.error('Cancel error:', error); + } + } + + /* Reorders the queue display, updates the total count, and handles "Show more" */ + updateQueueOrder() { + const container = document.getElementById('queueItems'); + const footer = document.getElementById('queueFooter'); + if (!container || !footer) return; // Guard against null + const entries = Object.values(this.queueEntries); + + // Sorting: errors/canceled first (group 0), ongoing next (group 1), queued last (group 2, sorted by position). + entries.sort((a: QueueEntry, b: QueueEntry) => { + const getGroup = (entry: QueueEntry) => { // Add type + if (entry.lastStatus && (entry.lastStatus.status === "error" || entry.lastStatus.status === "cancel")) { + return 0; + } else if (entry.lastStatus && entry.lastStatus.status === "queued") { + return 2; + } else { + return 1; + } + }; + const groupA = getGroup(a); + const groupB = getGroup(b); + if (groupA !== groupB) { + return groupA - groupB; + } else { + if (groupA === 2) { + const posA = a.lastStatus && a.lastStatus.position ? a.lastStatus.position : Infinity; + const posB = b.lastStatus && b.lastStatus.position ? b.lastStatus.position : Infinity; + return posA - posB; + } + return a.lastUpdated - b.lastUpdated; + } + }); + + // Update the header with just the total count + const queueTotalCountEl = document.getElementById('queueTotalCount') as HTMLElement | null; + if (queueTotalCountEl) { + queueTotalCountEl.textContent = entries.length.toString(); + } + + // Remove subtitle with detailed stats if it exists + const subtitleEl = document.getElementById('queueSubtitle'); + if (subtitleEl) { + subtitleEl.remove(); + } + + // Only recreate the container content if really needed + const visibleEntries = entries.slice(0, this.visibleCount); + + // Handle empty state + if (entries.length === 0) { + container.innerHTML = ` +
+ Empty queue +

Your download queue is empty

+
+ `; + } else { + // Get currently visible items + const visibleItems = Array.from(container.children).filter(el => el.classList.contains('queue-item')); + + // Update container more efficiently + if (visibleItems.length === 0) { + // No items in container, append all visible entries + container.innerHTML = ''; // Clear any empty state + visibleEntries.forEach((entry: QueueEntry) => { + // We no longer automatically start monitoring here + // Monitoring is now explicitly started by the methods that create downloads + container.appendChild(entry.element); + }); + } else { + // Container already has items, update more efficiently + + // Create a map of current DOM elements by queue ID + const existingElementMap: { [key: string]: HTMLElement } = {}; + visibleItems.forEach(el => { + const queueId = (el.querySelector('.cancel-btn') as HTMLElement | null)?.dataset.queueid; // Optional chaining + if (queueId) existingElementMap[queueId] = el as HTMLElement; // Cast to HTMLElement + }); + + // Clear container to re-add in correct order + container.innerHTML = ''; + + // Add visible entries in correct order + visibleEntries.forEach((entry: QueueEntry) => { + // We no longer automatically start monitoring here + container.appendChild(entry.element); + + // Mark the entry as not new anymore + entry.isNew = false; + }); + } + } + + // We no longer start or stop monitoring based on visibility changes here + // This allows the explicit monitoring control from the download methods + + // Ensure all currently visible and active entries are being polled + // This is important for items that become visible after "Show More" or other UI changes + Object.values(this.queueEntries).forEach(entry => { + if (this.isEntryVisible(entry.uniqueId) && !entry.hasEnded && !this.pollingIntervals[entry.uniqueId]) { + console.log(`updateQueueOrder: Ensuring polling for visible/active entry ${entry.uniqueId} (${entry.taskId})`); + this.setupPollingInterval(entry.uniqueId); + } + }); + + // Update footer + footer.innerHTML = ''; + if (entries.length > this.visibleCount) { + const remaining = entries.length - this.visibleCount; + const showMoreBtn = document.createElement('button'); + showMoreBtn.textContent = `Show ${remaining} more`; + showMoreBtn.addEventListener('click', () => { + this.visibleCount += 10; + localStorage.setItem("downloadQueueVisibleCount", this.visibleCount.toString()); // toString + this.updateQueueOrder(); + }); + footer.appendChild(showMoreBtn); + } + } + + /* Checks if an entry is visible in the queue display. */ + isEntryVisible(queueId: string): boolean { // Add return type + const entries = Object.values(this.queueEntries); + entries.sort((a: QueueEntry, b: QueueEntry) => { + const getGroup = (entry: QueueEntry) => { // Add type + if (entry.lastStatus && (entry.lastStatus.status === "error" || entry.lastStatus.status === "cancel")) { + return 0; + } else if (entry.lastStatus && entry.lastStatus.status === "queued") { + return 2; + } else { + return 1; + } + }; + const groupA = getGroup(a); + const groupB = getGroup(b); + if (groupA !== groupB) { + return groupA - groupB; + } else { + if (groupA === 2) { + const posA = a.lastStatus && a.lastStatus.position ? a.lastStatus.position : Infinity; + const posB = b.lastStatus && b.lastStatus.position ? b.lastStatus.position : Infinity; + return posA - posB; + } + return a.lastUpdated - b.lastUpdated; + } + }); + const index = entries.findIndex((e: QueueEntry) => e.uniqueId === queueId); + return index >= 0 && index < this.visibleCount; + } + + async cleanupEntry(queueId: string) { + const entry = this.queueEntries[queueId]; + if (entry) { + // Close any polling interval + this.clearPollingInterval(queueId); + + // Clean up any intervals + if (entry.intervalId) { + clearInterval(entry.intervalId as number); // Cast to number + } + if (entry.autoRetryInterval) { + clearInterval(entry.autoRetryInterval as number); // Cast to number + } + + // Remove from the DOM + entry.element.remove(); + + // Delete from in-memory queue + delete this.queueEntries[queueId]; + + // Remove the cached info + if (this.queueCache[entry.taskId]) { + delete this.queueCache[entry.taskId]; + localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); + } + + // Update the queue display + this.updateQueueOrder(); + } + } + + /* Event Dispatching */ + dispatchEvent(name: string, detail: any) { // Add type for name + document.dispatchEvent(new CustomEvent(name, { detail })); + } + + /* Status Message Handling */ + getStatusMessage(data: StatusData): string { // Add types + // Determine the true display type - if this is a track with a parent, we may want to + // show it as part of the parent's download process + let displayType = data.type || 'unknown'; + let isChildTrack = false; + + // If this is a track that's part of an album/playlist, note that + if (data.type === 'track' && data.parent) { + isChildTrack = true; + // We'll still use track-specific info but note it's part of a parent + } + + // Find the queue item this status belongs to + let queueItem: QueueEntry | null = null; + const taskId = data.task_id || Object.keys(this.queueCache).find(key => + this.queueCache[key].status === data.status && this.queueCache[key].type === data.type + ); + + if (taskId) { + const queueId = Object.keys(this.queueEntries).find(id => + this.queueEntries[id].taskId === taskId + ); + if (queueId) { + queueItem = this.queueEntries[queueId]; + } + } + + // Extract common fields + const trackName = data.song || data.name || data.title || + (queueItem?.item?.name) || 'Unknown'; + const artist = data.artist || + (queueItem?.item?.artist) || ''; + const albumTitle = data.title || data.album || data.parent?.title || data.name || + (queueItem?.item?.name) || ''; + const playlistName = data.name || data.parent?.name || + (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 || ''; + 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 = Number(data.progress).toFixed(1); + } + + // Helper for constructing info about the parent item + const getParentInfo = (): string => { // Add return type + if (!data.parent) return ''; + + if (data.parent.type === 'album') { + return ` from album "${data.parent.title}"`; + } else if (data.parent.type === 'playlist') { + return ` from playlist "${data.parent.name}" by ${data.parent.owner}`; + } + return ''; + }; + + // Status-based message generation + switch (data.status) { + case 'queued': + if (data.type === 'track') { + return `Queued track "${trackName}"${artist ? ` by ${artist}` : ''}${getParentInfo()}`; + } else if (data.type === 'album') { + return `Queued album "${albumTitle}"${artist ? ` by ${artist}` : ''} (${totalTracks || '?'} tracks)`; + } else if (data.type === 'playlist') { + return `Queued playlist "${playlistName}"${playlistOwner ? ` by ${playlistOwner}` : ''} (${totalTracks || '?'} tracks)`; + } + return `Queued ${data.type}`; + + case 'initializing': + return `Preparing to download...`; + + case 'processing': + // Special case: If this is a track that's part of an album/playlist + if (data.type === 'track' && data.parent) { + if (data.parent.type === 'album') { + return `Processing track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist} (from album "${data.parent.title}")`; + } else if (data.parent.type === 'playlist') { + return `Processing track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist} (from playlist "${data.parent.name}")`; + } + } + + // Regular standalone track + if (data.type === 'track') { + return `Processing track "${trackName}"${artist ? ` by ${artist}` : ''}${getParentInfo()}`; + } + // Album download + else if (data.type === 'album') { + // For albums, show current track info if available + if (trackName && artist && currentTrack && totalTracks) { + return `Processing track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist}`; + } else if (currentTrack && totalTracks) { + // If we have track numbers but not names + return `Processing track ${currentTrack} of ${totalTracks} from album "${albumTitle}"`; + } else if (totalTracks) { + return `Processing album "${albumTitle}" (${totalTracks} tracks)`; + } + return `Processing album "${albumTitle}"...`; + } + // Playlist download + else if (data.type === 'playlist') { + // For playlists, show current track info if available + if (trackName && artist && currentTrack && totalTracks) { + return `Processing track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist}`; + } else if (currentTrack && totalTracks) { + // If we have track numbers but not names + return `Processing track ${currentTrack} of ${totalTracks} from playlist "${playlistName}"`; + } else if (totalTracks) { + return `Processing playlist "${playlistName}" (${totalTracks} tracks)`; + } + return `Processing playlist "${playlistName}"...`; + } + return `Processing ${data.type}...`; + + case 'progress': + // Special case: If this is a track that's part of an album/playlist + if (data.type === 'track' && data.parent) { + if (data.parent.type === 'album') { + return `Downloading track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist} (from album "${data.parent.title}")`; + } else if (data.parent.type === 'playlist') { + return `Downloading track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist} (from playlist "${data.parent.name}")`; + } + } + + // Regular standalone track + if (data.type === 'track') { + return `Downloading track "${trackName}"${artist ? ` by ${artist}` : ''}${getParentInfo()}`; + } + // Album download + else if (data.type === 'album') { + // For albums, show current track info if available + if (trackName && artist && currentTrack && totalTracks) { + return `Downloading track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist}`; + } else if (currentTrack && totalTracks) { + // If we have track numbers but not names + return `Downloading track ${currentTrack} of ${totalTracks} from album "${albumTitle}"`; + } else if (totalTracks) { + return `Downloading album "${albumTitle}" (${totalTracks} tracks)`; + } + return `Downloading album "${albumTitle}"...`; + } + // Playlist download + else if (data.type === 'playlist') { + // For playlists, show current track info if available + if (trackName && artist && currentTrack && totalTracks) { + return `Downloading track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist}`; + } else if (currentTrack && totalTracks) { + // If we have track numbers but not names + return `Downloading track ${currentTrack} of ${totalTracks} from playlist "${playlistName}"`; + } else if (totalTracks) { + return `Downloading playlist "${playlistName}" (${totalTracks} tracks)`; + } + return `Downloading playlist "${playlistName}"...`; + } + return `Downloading ${data.type}...`; + + case 'real-time': + case 'real_time': + // Special case: If this is a track that's part of an album/playlist + if (data.type === 'track' && data.parent) { + if (data.parent.type === 'album') { + return `Downloading track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist} - ${formattedPercentage}% (from album "${data.parent.title}")`; + } else if (data.parent.type === 'playlist') { + return `Downloading track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist} - ${formattedPercentage}% (from playlist "${data.parent.name}")`; + } + } + + // Regular standalone track + if (data.type === 'track') { + return `Downloading "${trackName}" - ${formattedPercentage}%${getParentInfo()}`; + } + // Album with track info + else if (data.type === 'album' && trackName && artist) { + return `Downloading ${currentTrack}/${totalTracks}: "${trackName}" by ${artist} - ${formattedPercentage}%`; + } + // Playlist with track info + else if (data.type === 'playlist' && trackName && artist) { + return `Downloading ${currentTrack}/${totalTracks}: "${trackName}" by ${artist} - ${formattedPercentage}%`; + } + // Generic with percentage + else { + const itemName = data.type === 'album' ? albumTitle : + (data.type === 'playlist' ? playlistName : data.type); + return `Downloading ${data.type} "${itemName}" - ${formattedPercentage}%`; + } + + case 'done': + case 'complete': + // 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)`; + } + if (data.type === 'playlist') { + return `Downloaded playlist "${playlistName}"${playlistOwner ? ` by ${playlistOwner}` : ''} successfully (${totalTracks} tracks)`; + } + return `Downloaded ${data.type} successfully`; + + case 'skipped': + return `${trackName}${artist ? ` by ${artist}` : ''} was skipped: ${data.reason || 'Unknown reason'}`; + + case 'error': + // Enhanced error message handling using the new format + let errorMsg = `Error: ${data.error}`; + + // Add position information for tracks in collections + if (data.current_track && data.total_tracks) { + errorMsg = `Error on track ${data.current_track}/${data.total_tracks}: ${data.error}`; + } + + // Add retry information if available + if (data.retry_count !== undefined) { + errorMsg += ` (Attempt ${data.retry_count}/${this.MAX_RETRIES})`; + } else if (data.can_retry !== undefined) { + if (data.can_retry) { + errorMsg += ` (Can be retried)`; + } else { + errorMsg += ` (Max retries reached)`; + } + } + + // Add parent information if this is a track with a parent + if (data.type === 'track' && data.parent) { + if (data.parent.type === 'album') { + errorMsg += `\nFrom album: "${data.parent.title}" by ${data.parent.artist || 'Unknown artist'}`; + } else if (data.parent.type === 'playlist') { + errorMsg += `\nFrom playlist: "${data.parent.name}" by ${data.parent.owner || 'Unknown creator'}`; + } + } + + // Add URL for troubleshooting if available + if (data.url) { + errorMsg += `\nSource: ${data.url}`; + } + + return errorMsg; + + case 'retrying': + let retryMsg = 'Retrying'; + if (data.retry_count) { + retryMsg += ` (${data.retry_count}/${this.MAX_RETRIES})`; + } + if (data.seconds_left) { + retryMsg += ` in ${data.seconds_left}s`; + } + if (data.error) { + retryMsg += `: ${data.error}`; + } + return retryMsg; + + case 'cancelled': + case 'cancel': + return 'Cancelling...'; + + default: + return data.status || 'Unknown status'; + } + } + + /* 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; + + // Update progress bar if available + if (typeof progress === 'number') { + const progressBar = entry.element.querySelector('.progress-bar') as HTMLElement | null; + if (progressBar) { + progressBar.style.width = '100%'; + progressBar.setAttribute('aria-valuenow', "100"); // Use string for aria-valuenow + progressBar.classList.add('bg-success'); + } + } + + // Stop polling + this.clearPollingInterval(queueId); + + // 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 cases if not caught by the more specific conditions + + // Clean up after the appropriate delay + setTimeout(() => { + this.cleanupEntry(queueId); + }, cleanupDelay); + } + + handleInactivity(entry: QueueEntry, queueId: string, logElement: HTMLElement | null) { // Add types + if (entry.lastStatus && entry.lastStatus.status === 'queued') { + if (logElement) { + logElement.textContent = this.getStatusMessage(entry.lastStatus); + } + return; + } + const now = Date.now(); + if (now - entry.lastUpdated > 300000) { + const progressData: StatusData = { status: 'error', error: 'Inactivity timeout' }; // Use error property + this.handleDownloadCompletion(entry, queueId, progressData); // Pass StatusData + } else { + if (logElement) { + logElement.textContent = this.getStatusMessage(entry.lastStatus); + } + } + } + + async retryDownload(queueId: string, logElement: HTMLElement | null) { // Add type + const entry = this.queueEntries[queueId]; + if (!entry) { + console.warn(`Retry called for non-existent queueId: ${queueId}`); + return; + } + + // The retry button is already showing "Retrying..." and is disabled by the click handler. + // We will update the error message div within logElement if retry fails. + const errorMessageDiv = logElement?.querySelector('.error-message') as HTMLElement | null; + const retryBtn = logElement?.querySelector('.retry-btn') as HTMLButtonElement | null; + + entry.isRetrying = true; // Mark the original entry as being retried. + + // Determine if we should use parent information for retry (existing logic) + let useParent = false; + let parentType: string | null = null; // Add type + let parentUrl: string | null = null; // Add type + if (entry.lastStatus && entry.lastStatus.parent) { + const parent = entry.lastStatus.parent; + if (parent.type && parent.url) { + useParent = true; + parentType = parent.type; + parentUrl = parent.url; + console.log(`Using parent info for retry: ${parentType} with URL: ${parentUrl}`); + } + } + + const getRetryUrl = (): string | null => { // Add return type + if (entry.lastStatus && entry.lastStatus.original_url) return entry.lastStatus.original_url; + if (useParent && parentUrl) return parentUrl; + if (entry.requestUrl) return entry.requestUrl; + if (entry.lastStatus && entry.lastStatus.original_request) { + if (entry.lastStatus.original_request.retry_url) return entry.lastStatus.original_request.retry_url; + if (entry.lastStatus.original_request.url) return entry.lastStatus.original_request.url; + } + if (entry.lastStatus && entry.lastStatus.url) return entry.lastStatus.url; + return null; + }; + + const retryUrl = getRetryUrl(); + + if (!retryUrl) { + if (errorMessageDiv) errorMessageDiv.textContent = 'Retry not available: missing URL information.'; + entry.isRetrying = false; + if (retryBtn) { + retryBtn.disabled = false; + retryBtn.innerHTML = 'Retry'; // Reset button text + } + return; + } + + // Store details needed for the new entry BEFORE any async operations + const originalItem: QueueItem = { ...entry.item }; // Shallow copy, add type + const apiTypeForNewEntry = useParent && parentType ? parentType : entry.type; // Ensure parentType is not null + console.log(`Retrying download using type: ${apiTypeForNewEntry} with base URL: ${retryUrl}`); + + let fullRetryUrl; + if (retryUrl.startsWith('http') || retryUrl.startsWith('/api/')) { // if it's already a full URL or an API path + fullRetryUrl = retryUrl; + } else { + // Construct full URL if retryUrl is just a resource identifier + fullRetryUrl = `/api/${apiTypeForNewEntry}/download?url=${encodeURIComponent(retryUrl)}`; + // Append metadata if retryUrl is raw resource URL + if (originalItem && originalItem.name) { + fullRetryUrl += `&name=${encodeURIComponent(originalItem.name)}`; + } + if (originalItem && originalItem.artist) { + fullRetryUrl += `&artist=${encodeURIComponent(originalItem.artist)}`; + } + } + const requestUrlForNewEntry = fullRetryUrl; + + try { + // Clear polling for the old entry before making the request + this.clearPollingInterval(queueId); + + const retryResponse = await fetch(fullRetryUrl); + if (!retryResponse.ok) { + const errorText = await retryResponse.text(); + throw new Error(`Server returned ${retryResponse.status}${errorText ? (': ' + errorText) : ''}`); + } + + const retryData: StatusData = await retryResponse.json(); // Add type + + if (retryData.task_id) { + const newTaskId = retryData.task_id; + + // Clean up the old entry from UI, memory, cache, and server (task file) + // logElement and retryBtn are part of the old entry's DOM structure and will be removed. + await this.cleanupEntry(queueId); + + // Add the new download entry. This will create a new element, start monitoring, etc. + this.addDownload(originalItem, apiTypeForNewEntry, newTaskId, requestUrlForNewEntry, true); + + // The old setTimeout block for deleting old task file is no longer needed as cleanupEntry handles it. + } else { + if (errorMessageDiv) errorMessageDiv.textContent = 'Retry failed: invalid response from server.'; + const currentEntry = this.queueEntries[queueId]; // Check if old entry still exists + if (currentEntry) { + currentEntry.isRetrying = false; + } + if (retryBtn) { + retryBtn.disabled = false; + retryBtn.innerHTML = 'Retry'; + } + } + } catch (error) { + console.error('Retry error:', error); + // The old entry might still be in the DOM if cleanupEntry wasn't called or failed. + const stillExistingEntry = this.queueEntries[queueId]; + if (stillExistingEntry && stillExistingEntry.element) { + // logElement might be stale if the element was re-rendered, so query again if possible. + const currentLogOnFailedEntry = stillExistingEntry.element.querySelector('.log') as HTMLElement | null; + const errorDivOnFailedEntry = currentLogOnFailedEntry?.querySelector('.error-message') as HTMLElement | null || errorMessageDiv; + const retryButtonOnFailedEntry = currentLogOnFailedEntry?.querySelector('.retry-btn') as HTMLButtonElement | null || retryBtn; + + if (errorDivOnFailedEntry) errorDivOnFailedEntry.textContent = 'Retry failed: ' + (error as Error).message; // Cast error to Error + stillExistingEntry.isRetrying = false; + if (retryButtonOnFailedEntry) { + retryButtonOnFailedEntry.disabled = false; + retryButtonOnFailedEntry.innerHTML = 'Retry'; + } + } else if (errorMessageDiv) { + // Fallback if entry is gone from queue but original logElement's parts are somehow still accessible + errorMessageDiv.textContent = 'Retry failed: ' + (error as Error).message; + if (retryBtn) { + retryBtn.disabled = false; + retryBtn.innerHTML = 'Retry'; + } + } + } + } + + /** + * Start monitoring for all active entries in the queue that are visible + */ + startMonitoringActiveEntries() { + for (const queueId in this.queueEntries) { + const entry = this.queueEntries[queueId]; + // Only start monitoring if the entry is not in a terminal state and is visible + if (!entry.hasEnded && this.isEntryVisible(queueId) && !this.pollingIntervals[queueId]) { + this.setupPollingInterval(queueId); + } + } + } + + /** + * Centralized download method for all content types. + * This method replaces the individual startTrackDownload, startAlbumDownload, etc. methods. + * It will be called by all the other JS files. + */ + async download(itemId: string, type: string, item: QueueItem, albumType: string | null = null): Promise { // Add types and return type + if (!itemId) { + throw new Error('Missing ID for download'); + } + + await this.loadConfig(); + + // Construct the API URL in the new format /api/{type}/download/{itemId} + let apiUrl = `/api/${type}/download/${itemId}`; + + // Prepare query parameters + const queryParams = new URLSearchParams(); + // item.name and item.artist are no longer sent as query parameters + // if (item.name && item.name.trim() !== '') queryParams.append('name', item.name); + // if (item.artist && item.artist.trim() !== '') queryParams.append('artist', item.artist); + + // For artist downloads, include album_type as it may still be needed + if (type === 'artist' && albumType) { + queryParams.append('album_type', albumType); + } + + const queryString = queryParams.toString(); + if (queryString) { + apiUrl += `?${queryString}`; + } + + console.log(`Constructed API URL for download: ${apiUrl}`); // Log the constructed URL + + try { + // Show a loading indicator + const queueIcon = document.getElementById('queueIcon'); // No direct classList manipulation + if (queueIcon) { + queueIcon.classList.add('queue-icon-active'); + } + + const response = await fetch(apiUrl); + if (!response.ok) { + throw new Error(`Server returned ${response.status}`); + } + + const data: StatusData | { task_ids?: string[], album_prg_files?: string[] } = await response.json(); // Add type for data + + // Handle artist downloads which return multiple album tasks + if (type === 'artist') { + // Check for new API response format + if ('task_ids' in data && data.task_ids && Array.isArray(data.task_ids)) { // Type guard + console.log(`Queued artist discography with ${data.task_ids.length} albums`); + + // Make queue visible to show progress + this.toggleVisibility(true); + + // Create entries directly from task IDs and start monitoring them + const queueIds: string[] = []; // Add type + for (const taskId of data.task_ids) { + console.log(`Adding album task with ID: ${taskId}`); + // Create an album item with better display information + const albumItem: QueueItem = { // Add type + name: `${item.name || 'Artist'} - Album (loading...)`, + artist: item.name || 'Unknown artist', + type: 'album' + }; + // Use improved addDownload with forced monitoring + const queueId = this.addDownload(albumItem, 'album', taskId, apiUrl, true); + queueIds.push(queueId); + } + + return queueIds; + } + // Check for older API response format + else if ('album_prg_files' in data && data.album_prg_files && Array.isArray(data.album_prg_files)) { // Type guard + console.log(`Queued artist discography with ${data.album_prg_files.length} albums (old format)`); + + // Make queue visible to show progress + this.toggleVisibility(true); + + // Add each album to the download queue separately with forced monitoring + const queueIds: string[] = []; // Add type + data.album_prg_files.forEach(prgFile => { + console.log(`Adding album with PRG file: ${prgFile}`); + // Create an album item with better display information + const albumItem: QueueItem = { // Add type + name: `${item.name || 'Artist'} - Album (loading...)`, + artist: item.name || 'Unknown artist', + type: 'album' + }; + // Use improved addDownload with forced monitoring + const queueId = this.addDownload(albumItem, 'album', prgFile, apiUrl, true); + queueIds.push(queueId); + }); + + return queueIds; + } + // Handle any other response format for artist downloads + else { + console.log(`Queued artist discography with unknown format:`, data); + + // Make queue visible + this.toggleVisibility(true); + + // Just load existing task files as a fallback + await this.loadExistingTasks(); + + // Force start monitoring for all loaded entries + for (const queueId in this.queueEntries) { + const entry = this.queueEntries[queueId]; + if (!entry.hasEnded) { + this.startDownloadStatusMonitoring(queueId); + } + } + + return data; + } + } + + // Handle single-file downloads (tracks, albums, playlists) + if ('task_id' in data && data.task_id) { // Type guard + console.log(`Adding ${type} task with ID: ${data.task_id}`); + + // Store the initial metadata in the cache so it's available + // even before the first status update + this.queueCache[data.task_id] = { + type, + status: 'initializing', + name: item.name || 'Unknown', + title: item.name || 'Unknown', + artist: item.artist || (item.artists && item.artists.length > 0 ? item.artists[0].name : ''), + owner: typeof item.owner === 'string' ? item.owner : item.owner?.display_name || '', + total_tracks: item.total_tracks || 0 + }; + + // Use direct monitoring for all downloads for consistency + const queueId = this.addDownload(item, type, data.task_id, apiUrl, true); + + // Make queue visible to show progress if not already visible + if (this.config && !this.config.downloadQueueVisible) { // Add null check for config + this.toggleVisibility(true); + } + + return queueId; + } else { + throw new Error('Invalid response format from server'); + } + } catch (error) { + this.dispatchEvent('downloadError', { error, item }); + throw error; + } + } + + /** + * Loads existing task files from the /api/prgs/list endpoint and adds them as queue entries. + */ + async loadExistingTasks() { + try { + // Clear existing queue entries first to avoid duplicates when refreshing + for (const queueId in this.queueEntries) { + const entry = this.queueEntries[queueId]; + this.clearPollingInterval(queueId); + delete this.queueEntries[queueId]; + } + + // Fetch detailed task list from the new endpoint + const response = await fetch('/api/prgs/list'); + if (!response.ok) { + console.error("Failed to load existing tasks:", response.status, await response.text()); + return; + } + const existingTasks: any[] = await response.json(); // We expect an array of detailed task objects + + const terminalStates = ['complete', 'done', 'cancelled', 'ERROR_AUTO_CLEANED', 'ERROR_RETRIED', 'cancel', 'interrupted', 'error']; + + for (const taskData of existingTasks) { + const taskId = taskData.task_id; // Use task_id as taskId identifier + const lastStatus = taskData.last_status_obj; + const originalRequest = taskData.original_request || {}; + + // Skip adding to UI if the task is already in a terminal state + if (lastStatus && terminalStates.includes(lastStatus.status)) { + console.log(`Skipping UI addition for terminal task ${taskId}, status: ${lastStatus.status}`); + // Also ensure it's cleaned from local cache if it was there + if (this.queueCache[taskId]) { + delete this.queueCache[taskId]; + } + continue; // Skip adding terminal tasks to UI if not already there + } + + let itemType = taskData.type || originalRequest.type || 'unknown'; + let dummyItem: QueueItem = { + name: taskData.name || originalRequest.name || taskId, + artist: taskData.artist || originalRequest.artist || '', + type: itemType, + url: originalRequest.url || lastStatus?.url || '', + endpoint: originalRequest.endpoint || '', + download_type: taskData.download_type || originalRequest.download_type || '', + total_tracks: lastStatus?.total_tracks || originalRequest.total_tracks, + current_track: lastStatus?.current_track, + }; + + // If this is a track with a parent from the last_status, adjust item and type + if (lastStatus && lastStatus.type === 'track' && lastStatus.parent) { + const parent = lastStatus.parent; + if (parent.type === 'album') { + itemType = 'album'; + dummyItem = { + name: parent.title || 'Unknown Album', + artist: parent.artist || 'Unknown Artist', + type: 'album', url: parent.url || '', + total_tracks: parent.total_tracks || lastStatus.total_tracks, + parent: parent }; + } else if (parent.type === 'playlist') { + itemType = 'playlist'; + dummyItem = { + name: parent.name || 'Unknown Playlist', + owner: parent.owner || 'Unknown Creator', + type: 'playlist', url: parent.url || '', + total_tracks: parent.total_tracks || lastStatus.total_tracks, + parent: parent }; + } + } + + let retryCount = 0; + if (lastStatus && lastStatus.retry_count) { + retryCount = lastStatus.retry_count; + } else if (taskId.includes('_retry')) { + const retryMatch = taskId.match(/_retry(\d+)/); + if (retryMatch && retryMatch[1]) { + retryCount = parseInt(retryMatch[1], 10); + } + } + + const requestUrl = originalRequest.url ? `/api/${itemType}/download/${originalRequest.url.split('/').pop()}?name=${encodeURIComponent(dummyItem.name || '')}&artist=${encodeURIComponent(dummyItem.artist || '')}` : null; + + const queueId = this.generateQueueId(); + const entry = this.createQueueEntry(dummyItem, itemType, taskId, queueId, requestUrl); + entry.retryCount = retryCount; + + if (lastStatus) { + entry.lastStatus = lastStatus; + if (lastStatus.parent) { + entry.parentInfo = lastStatus.parent; + } + this.queueCache[taskId] = lastStatus; // Cache the last known status + this.applyStatusClasses(entry, lastStatus); + + const logElement = entry.element.querySelector('.log') as HTMLElement | null; + if (logElement) { + logElement.textContent = this.getStatusMessage(lastStatus); + } + } + this.queueEntries[queueId] = entry; + } + + localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); + this.updateQueueOrder(); + this.startMonitoringActiveEntries(); + } catch (error) { + console.error("Error loading existing task files:", error); + } + } + + async loadConfig() { + try { + const response = await fetch('/api/config'); + if (!response.ok) throw new Error('Failed to fetch config'); + this.config = await response.json(); + + // Update our retry constants from the server config + if (this.config.maxRetries !== undefined) { + this.MAX_RETRIES = this.config.maxRetries; + } + if (this.config.retryDelaySeconds !== undefined) { + this.RETRY_DELAY = this.config.retryDelaySeconds; + } + if (this.config.retry_delay_increase !== undefined) { + this.RETRY_DELAY_INCREASE = this.config.retry_delay_increase; + } + + console.log(`Loaded retry settings from config: max=${this.MAX_RETRIES}, delay=${this.RETRY_DELAY}, increase=${this.RETRY_DELAY_INCREASE}`); + } catch (error) { + console.error('Error loading config:', error); + this.config = { // Initialize with a default structure on error + maxRetries: 3, + retryDelaySeconds: 5, + retry_delay_increase: 5, + explicitFilter: false + }; + } + } + + // Add a method to check if explicit filter is enabled + isExplicitFilterEnabled(): boolean { // Add return type + return !!this.config.explicitFilter; + } + + /* Sets up a polling interval for real-time status updates */ + setupPollingInterval(queueId: string) { // Add type + console.log(`Setting up polling for ${queueId}`); + const entry = this.queueEntries[queueId]; + if (!entry || !entry.taskId) { + console.warn(`No entry or taskId for ${queueId}`); + return; + } + + // Close any existing connection + this.clearPollingInterval(queueId); + + try { + // Immediately fetch initial data + this.fetchDownloadStatus(queueId); + + // Create a polling interval of 500ms for more responsive UI updates + const intervalId = setInterval(() => { + this.fetchDownloadStatus(queueId); + }, 500); + + // Store the interval ID for later cleanup + this.pollingIntervals[queueId] = intervalId as unknown as number; // Cast to number via unknown + } catch (error) { + console.error(`Error creating polling for ${queueId}:`, error); + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; + if (logElement) { + logElement.textContent = `Error with download: ${(error as Error).message}`; // Cast to Error + entry.element.classList.add('error'); + } + } + } + + async fetchDownloadStatus(queueId: string) { // Add type + const entry = this.queueEntries[queueId]; + if (!entry || !entry.taskId) { + console.warn(`No entry or taskId for ${queueId}`); + return; + } + + try { + const response = await fetch(`/api/prgs/${entry.taskId}`); + if (!response.ok) { + throw new Error(`HTTP error: ${response.status}`); + } + + const data: StatusData = await response.json(); // Add type + + // If the last_line doesn't have name/artist/title info, add it from our stored item data + if (data.last_line && entry.item) { + if (!data.last_line.name && entry.item.name) { + data.last_line.name = entry.item.name; + } + if (!data.last_line.title && entry.item.name) { + data.last_line.title = entry.item.name; + } + if (!data.last_line.artist && entry.item.artist) { + data.last_line.artist = entry.item.artist; + } else if (!data.last_line.artist && entry.item.artists && entry.item.artists.length > 0) { + data.last_line.artist = entry.item.artists[0].name; + } + if (!data.last_line.owner && entry.item.owner) { + data.last_line.owner = typeof entry.item.owner === 'string' ? entry.item.owner : entry.item.owner?.display_name ; + } + if (!data.last_line.total_tracks && entry.item.total_tracks) { + data.last_line.total_tracks = entry.item.total_tracks; + } + } + + // Initialize the download type if needed + if (data.type && !entry.type) { + console.log(`Setting entry type to: ${data.type}`); + entry.type = data.type; + + // Update type display if element exists + const typeElement = entry.element.querySelector('.type') as HTMLElement | null; + if (typeElement) { + typeElement.textContent = data.type.charAt(0).toUpperCase() + data.type.slice(1); + // Update type class without triggering animation + typeElement.className = `type ${data.type}`; + } + } + + // Special handling for track updates that are part of an album/playlist + // Don't filter these out as they contain important track progress info + if (data.last_line && data.last_line.type === 'track' && data.last_line.parent) { + // This is a track update that's part of our album/playlist - keep it + if ((entry.type === 'album' && data.last_line.parent.type === 'album') || + (entry.type === 'playlist' && data.last_line.parent.type === 'playlist')) { + console.log(`Processing track update for ${entry.type} download: ${data.last_line.song}`); + // Continue processing - don't return + } + } + // Only filter out updates that don't match entry type AND don't have a relevant parent + else if (data.last_line && data.last_line.type && entry.type && + data.last_line.type !== entry.type && + (!data.last_line.parent || data.last_line.parent.type !== entry.type)) { + console.log(`Skipping status update with type '${data.last_line.type}' for entry with type '${entry.type}'`); + return; + } + + // Process the update + this.handleStatusUpdate(queueId, data); + + // 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 + if (data.last_line.status === 'cancelled' || data.last_line.status === 'cancel') { + console.log('Cleaning up cancelled download immediately'); + this.clearPollingInterval(queueId); + this.cleanupEntry(queueId); + return; // No need to process further + } + + // Only set up cleanup if this is not an error that we're in the process of retrying + // If status is 'error' but the status message contains 'Retrying', don't clean up + const isRetrying = entry.isRetrying || + (data.last_line.status === 'error' && + entry.element.querySelector('.log')?.textContent?.includes('Retry')); + + if (!isRetrying) { + setTimeout(() => { + // Double-check the entry still exists and has not been retried before cleaning up + const currentEntry = this.queueEntries[queueId]; // Get current entry + if (currentEntry && // Check if currentEntry exists + !currentEntry.isRetrying && + currentEntry.hasEnded) { + this.clearPollingInterval(queueId); + this.cleanupEntry(queueId); + } + }, data.last_line.status === 'complete' || data.last_line.status === 'done' ? 3000 : 5000); // 3s for complete/done, 5s for others + } + } + + } catch (error) { + console.error(`Error fetching status for ${queueId}:`, error); + + // Show error in log + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; + if (logElement) { + logElement.textContent = `Error updating status: ${(error as Error).message}`; // Cast to Error + } + } + } + + clearPollingInterval(queueId: string) { // Add type + if (this.pollingIntervals[queueId]) { + console.log(`Stopping polling for ${queueId}`); + try { + clearInterval(this.pollingIntervals[queueId] as number); // Cast to number + } catch (error) { + console.error(`Error stopping polling for ${queueId}:`, error); + } + delete this.pollingIntervals[queueId]; + } + } + + /* Handle status updates from the progress API */ + handleStatusUpdate(queueId: string, data: StatusData) { // Add types + const entry = this.queueEntries[queueId]; + if (!entry) { + console.warn(`No entry for ${queueId}`); + return; + } + + // Extract the actual status data from the API response + const statusData: StatusData = data.last_line || {}; // Add type + + // --- 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); + } + } + + 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 + + // Stall detection for 'real_time' status + if (status === 'real_time') { + entry.realTimeStallDetector = entry.realTimeStallDetector || { count: 0, lastStatusJson: '' }; + const detector = entry.realTimeStallDetector; + + const currentMetrics = { + progress: statusData.progress, + time_elapsed: statusData.time_elapsed, + // For multi-track items, current_track is a key indicator of activity + current_track: (entry.type === 'album' || entry.type === 'playlist') ? statusData.current_track : undefined, + // Include other relevant fields if they signify activity, e.g., speed, eta + // For example, if statusData.song changes for an album, that's progress. + song: statusData.song + }; + const currentMetricsJson = JSON.stringify(currentMetrics); + + // Check if significant metrics are present and static + if (detector.lastStatusJson === currentMetricsJson && + (currentMetrics.progress !== undefined || currentMetrics.time_elapsed !== undefined || currentMetrics.current_track !== undefined || currentMetrics.song !== undefined)) { + // Metrics are present and haven't changed + detector.count++; + } else { + // Metrics changed, or this is the first time seeing them, or no metrics to compare (e.g. empty object from server) + detector.count = 0; + // Only update lastStatusJson if currentMetricsJson represents actual data, not an empty object if that's possible + if (currentMetricsJson !== '{}' || detector.lastStatusJson === '') { // Avoid replacing actual old data with '{}' if new data is sparse + detector.lastStatusJson = currentMetricsJson; + } + } + + const STALL_THRESHOLD = 600; // Approx 5 minutes (600 polls * 0.5s/poll) + if (detector.count >= STALL_THRESHOLD) { + console.warn(`Download ${queueId} (${entry.taskId}) appears stalled in real_time state. Metrics: ${detector.lastStatusJson}. Stall count: ${detector.count}. Forcing error.`); + statusData.status = 'error'; + statusData.error = 'Download stalled (no progress updates for 5 minutes)'; + statusData.can_retry = true; // Allow manual retry for stalled items + status = 'error'; // Update local status variable for current execution scope + + // Reset detector for this entry in case of retry + detector.count = 0; + detector.lastStatusJson = ''; + } + } + + // Store the status data for potential retries + entry.lastStatus = statusData; // This now stores the potentially modified statusData (e.g., status changed to 'error') + entry.lastUpdated = Date.now(); + + // Update type if needed - could be more specific now (e.g., from 'album' to 'compilation') + if (statusData.type && statusData.type !== entry.type) { + entry.type = statusData.type; + const typeEl = entry.element.querySelector('.type') as HTMLElement | null; + if (typeEl) { + const displayType = entry.type.charAt(0).toUpperCase() + entry.type.slice(1); + typeEl.textContent = displayType; + typeEl.className = `type ${entry.type}`; + } + } + + // Update the title and artist with better information if available + this.updateItemMetadata(entry, statusData, data); + + // Generate appropriate user-friendly message + const message = this.getStatusMessage(statusData); + + // Update log message - but only if we're not handling a track update for an album/playlist + // That case is handled separately in updateItemMetadata to ensure we show the right track info + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; + if (logElement && status !== 'error' && !(statusData.type === 'track' && statusData.parent && + (entry.type === 'album' || entry.type === 'playlist'))) { + logElement.textContent = message; + } + + // Handle real-time progress data for single track downloads + if (status === 'real-time') { + this.updateRealTimeProgress(entry, statusData); + } + + // Handle overall progress for albums and playlists + const isMultiTrack = entry.type === 'album' || entry.type === 'playlist'; + if (isMultiTrack) { + this.updateMultiTrackProgress(entry, statusData); + } else { + // For single tracks, update the track progress + this.updateSingleTrackProgress(entry, statusData); + } + + // 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; + // Hide cancel button + const cancelBtn = entry.element.querySelector('.cancel-btn') as HTMLButtonElement | null; + if (cancelBtn) cancelBtn.style.display = 'none'; + + // Hide progress bars for errored items + const trackProgressContainer = entry.element.querySelector(`#track-progress-container-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; + if (trackProgressContainer) trackProgressContainer.style.display = 'none'; + const overallProgressContainer = entry.element.querySelector('.overall-progress-container') as HTMLElement | null; + if (overallProgressContainer) overallProgressContainer.style.display = 'none'; + // Hide time elapsed for errored items + const timeElapsedContainer = entry.element.querySelector(`#time-elapsed-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; + if (timeElapsedContainer) timeElapsedContainer.style.display = 'none'; + + // Extract error details + const errMsg = statusData.error || 'An unknown error occurred.'; // Ensure errMsg is a string + // const canRetry = Boolean(statusData.can_retry) && statusData.retry_count < statusData.max_retries; // This logic is implicitly handled by retry button availability + const retryUrl = data.original_url || data.original_request?.url || entry.requestUrl || null; + if (retryUrl) { + entry.requestUrl = retryUrl; // Store for retry logic + } + + console.log(`Error for ${entry.type} download. Can retry: ${!!entry.requestUrl}. Retry URL: ${entry.requestUrl}`); + + const errorLogElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; // Use a different variable name + if (errorLogElement) { // Check errorLogElement + let errorMessageElement = errorLogElement.querySelector('.error-message') as HTMLElement | null; + + if (!errorMessageElement) { // If error UI (message and buttons) is not built yet + // Build error UI with manual retry always available + errorLogElement.innerHTML = ` +
${errMsg}
+
+ + +
+ `; + errorMessageElement = errorLogElement.querySelector('.error-message') as HTMLElement | null; // Re-select after innerHTML change + + // Attach listeners ONLY when creating the buttons + const closeErrorBtn = errorLogElement.querySelector('.close-error-btn') as HTMLButtonElement | null; + if (closeErrorBtn) { + closeErrorBtn.addEventListener('click', () => { + this.cleanupEntry(queueId); + }); + } + + const retryBtnElem = errorLogElement.querySelector('.retry-btn') as HTMLButtonElement | null; + if (retryBtnElem) { + retryBtnElem.addEventListener('click', (e: MouseEvent) => { // Add type for e + e.preventDefault(); + e.stopPropagation(); + if (retryBtnElem) { // Check if retryBtnElem is not null + retryBtnElem.disabled = true; + retryBtnElem.innerHTML = ' Retrying...'; + } + this.retryDownload(queueId, errorLogElement); // Pass errorLogElement + }); + } + + // Auto cleanup after 15s - only set this timeout once when error UI is first built + setTimeout(() => { + const currentEntryForCleanup = this.queueEntries[queueId]; + if (currentEntryForCleanup && + currentEntryForCleanup.hasEnded && + currentEntryForCleanup.lastStatus?.status === 'error' && + !currentEntryForCleanup.isRetrying) { + this.cleanupEntry(queueId); + } + }, 20000); // Changed from 15000 to 20000 + + } else { // Error UI already exists, just update the message text if it's different + if (errorMessageElement.textContent !== errMsg) { + errorMessageElement.textContent = errMsg; + } + } + } + } + + // Handle terminal states for non-error cases + 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 + this.queueCache[entry.taskId] = statusData; + localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); + } + + // Update item metadata (title, artist, etc.) + updateItemMetadata(entry: QueueEntry, statusData: StatusData, data: StatusData) { // Add types + const titleEl = entry.element.querySelector('.title') as HTMLElement | null; + const artistEl = entry.element.querySelector('.artist') as HTMLElement | null; + + if (titleEl) { + // Check various data sources for a better title + let betterTitle: string | null | undefined = null; + + // First check the statusData + if (statusData.song) { + betterTitle = statusData.song; + } else if (statusData.album) { + betterTitle = statusData.album; + } else if (statusData.name) { + betterTitle = statusData.name; + } + // Then check if data has original_request with name + else if (data.original_request && data.original_request.name) { + betterTitle = data.original_request.name; + } + // Then check display_title from various sources + else if (statusData.display_title) { + betterTitle = statusData.display_title; + } else if (data.display_title) { + betterTitle = data.display_title; + } + + // Update title if we found a better one + if (betterTitle && betterTitle !== titleEl.textContent) { + titleEl.textContent = betterTitle; + // Also update the item's name for future reference + entry.item.name = betterTitle; + } + } + + // Update artist if available + if (artistEl) { + let artist = statusData.artist || data.display_artist || ''; + if (artist && (!artistEl.textContent || artistEl.textContent !== artist)) { + artistEl.textContent = artist; + // Update item data + entry.item.artist = artist; + } + } + } + + // Update real-time progress for track downloads + updateRealTimeProgress(entry: QueueEntry, statusData: StatusData) { // Add types + // Get track progress bar + const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.taskId) as HTMLElement | null; + const timeElapsedEl = entry.element.querySelector('#time-elapsed-' + entry.uniqueId + '-' + entry.taskId) as HTMLElement | null; + + if (trackProgressBar && statusData.progress !== undefined) { + // Update track progress bar + const progress = Number(statusData.progress); + trackProgressBar.style.width = `${progress}%`; + trackProgressBar.setAttribute('aria-valuenow', progress.toString()); // Use string + + // Add success class when complete + if (progress >= 100) { + trackProgressBar.classList.add('complete'); + } else { + trackProgressBar.classList.remove('complete'); + } + } + + // Display time elapsed if available + if (timeElapsedEl && statusData.time_elapsed !== undefined) { + const seconds = Math.floor(statusData.time_elapsed / 1000); + const formattedTime = seconds < 60 + ? `${seconds}s` + : `${Math.floor(seconds / 60)}m ${seconds % 60}s`; + timeElapsedEl.textContent = formattedTime; + } + } + + // Update progress for single track downloads + updateSingleTrackProgress(entry: QueueEntry, statusData: StatusData) { // Add types + // Get track progress bar and other UI elements + const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.taskId) as HTMLElement | null; + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; + const titleElement = entry.element.querySelector('.title') as HTMLElement | null; + const artistElement = entry.element.querySelector('.artist') as HTMLElement | null; + let progress = 0; // Declare progress here + + // If this track has a parent, this is actually part of an album/playlist + // We should update the entry type and handle it as a multi-track download + if (statusData.parent && (statusData.parent.type === 'album' || statusData.parent.type === 'playlist')) { + // Store parent info + entry.parentInfo = statusData.parent; + + // Update entry type to match parent type + entry.type = statusData.parent.type; + + // Update UI to reflect the parent type + const typeEl = entry.element.querySelector('.type') as HTMLElement | null; + if (typeEl) { + const displayType = entry.type.charAt(0).toUpperCase() + entry.type.slice(1); + typeEl.textContent = displayType; + // Update type class without triggering animation + typeEl.className = `type ${entry.type}`; + } + + // Update title and subtitle based on parent type + if (statusData.parent.type === 'album') { + if (titleElement) titleElement.textContent = statusData.parent.title || 'Unknown album'; + if (artistElement) artistElement.textContent = statusData.parent.artist || 'Unknown artist'; + } else if (statusData.parent.type === 'playlist') { + if (titleElement) titleElement.textContent = statusData.parent.name || 'Unknown playlist'; + if (artistElement) artistElement.textContent = statusData.parent.owner || 'Unknown creator'; + } + + // Now delegate to the multi-track progress updater + this.updateMultiTrackProgress(entry, statusData); + return; + } + + // For standalone tracks (without parent), update title and subtitle + if (!statusData.parent && statusData.song && titleElement) { + titleElement.textContent = statusData.song; + } + + if (!statusData.parent && statusData.artist && artistElement) { + artistElement.textContent = statusData.artist; + } + + // For individual track downloads, show the parent context if available + if (!['done', 'complete', 'error', 'skipped'].includes(statusData.status || '')) { // Add null check + // First check if we have parent data in the current status update + if (statusData.parent && logElement) { + // Store parent info in the entry for persistence across refreshes + entry.parentInfo = statusData.parent; + + let infoText = ''; + if (statusData.parent.type === 'album') { + infoText = `From album: "${statusData.parent.title}"`; + } else if (statusData.parent.type === 'playlist') { + infoText = `From playlist: "${statusData.parent.name}" by ${statusData.parent.owner}`; + } + + if (infoText) { + logElement.textContent = infoText; + } + } + // If no parent in current update, use stored parent info if available + else if (entry.parentInfo && logElement) { + let infoText = ''; + if (entry.parentInfo.type === 'album') { + infoText = `From album: "${entry.parentInfo.title}"`; + } else if (entry.parentInfo.type === 'playlist') { + infoText = `From playlist: "${entry.parentInfo.name}" by ${entry.parentInfo.owner}`; + } + + if (infoText) { + logElement.textContent = infoText; + } + } + } + + // Calculate progress based on available data + progress = 0; + + // Real-time progress for direct track download + if (statusData.status === 'real-time' && statusData.progress !== undefined) { + progress = Number(statusData.progress); + } else if (statusData.status === 'done' || statusData.status === 'complete') { + progress = 100; + } 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 + } + + // Update track progress bar if available + if (trackProgressBar) { + // Ensure numeric progress and prevent NaN + const safeProgress = isNaN(progress) ? 0 : Math.max(0, Math.min(100, progress)); + + trackProgressBar.style.width = `${safeProgress}%`; + trackProgressBar.setAttribute('aria-valuenow', safeProgress.toString()); // Use string + + // Make sure progress bar is visible + const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.taskId) as HTMLElement | null; + if (trackProgressContainer) { + trackProgressContainer.style.display = 'block'; + } + + // Add success class when complete + if (safeProgress >= 100) { + trackProgressBar.classList.add('complete'); + } else { + trackProgressBar.classList.remove('complete'); + } + } + } + + // Update progress for multi-track downloads (albums and playlists) + updateMultiTrackProgress(entry: QueueEntry, statusData: StatusData) { // Add types + // Get progress elements + const progressCounter = document.getElementById(`progress-count-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; + const overallProgressBar = document.getElementById(`overall-bar-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; + const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.taskId) as HTMLElement | null; + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; + const titleElement = entry.element.querySelector('.title') as HTMLElement | null; + const artistElement = entry.element.querySelector('.artist') as HTMLElement | null; + let progress = 0; // Declare progress here for this function's scope + + // Initialize track progress variables + let currentTrack = 0; + 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')) { + console.log('Processing track update for multi-track download:', statusData); + + // Update parent title/artist for album + if (entry.type === 'album' && statusData.parent.type === 'album') { + if (titleElement && statusData.parent.title) { + titleElement.textContent = statusData.parent.title; + } + if (artistElement && statusData.parent.artist) { + artistElement.textContent = statusData.parent.artist; + } + } + // Update parent title/owner for playlist + else if (entry.type === 'playlist' && statusData.parent.type === 'playlist') { + if (titleElement && statusData.parent.name) { + titleElement.textContent = statusData.parent.name; + } + if (artistElement && statusData.parent.owner) { + artistElement.textContent = statusData.parent.owner; + } + } + + // 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) { + totalTracks = parseInt(String(statusData.total_tracks), 10); + } else if (statusData.parent && statusData.parent.total_tracks !== undefined) { + totalTracks = parseInt(String(statusData.parent.total_tracks), 10); + } + + console.log(`Track info: ${currentTrack}/${totalTracks}`); + } + + // Get track progress for real-time updates + if (statusData.status === 'real-time' && statusData.progress !== undefined) { + 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 + if (progressCounter && totalTracks > 0) { + progressCounter.textContent = `${currentTrack}/${totalTracks}`; + } + + // Update the status message to show current track + if (logElement && statusData.song && statusData.artist) { + let progressInfo = ''; + if (statusData.status === 'real-time' && trackProgress > 0) { + progressInfo = ` - ${trackProgress}%`; + } else if (statusData.status === 'done' || statusData.status === 'complete') { + progressInfo = ' - Complete'; + } + logElement.textContent = `Currently downloading: ${statusData.song} by ${statusData.artist} (${currentTrack}/${totalTracks}${progressInfo})`; + } + + // Calculate and update the overall progress bar + if (totalTracks > 0) { + let overallProgress = 0; + + // 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; + } else { + // Fallback to track count method + overallProgress = (currentTrack / totalTracks) * 100; + } + + console.log(`Overall progress: ${overallProgress.toFixed(2)}% (Track ${currentTrack}/${totalTracks}, Progress: ${trackProgress}%)`); + + // Update the progress bar + if (overallProgressBar) { + const safeProgress = Math.max(0, Math.min(100, overallProgress)); + overallProgressBar.style.width = `${safeProgress}%`; + overallProgressBar.setAttribute('aria-valuenow', safeProgress.toString()); // Use string + + if (safeProgress >= 100) { + overallProgressBar.classList.add('complete'); + } else { + overallProgressBar.classList.remove('complete'); + } + } + + // Update the track-level progress bar + if (trackProgressBar) { + // Make sure progress bar container is visible + const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.taskId) as HTMLElement | null; + if (trackProgressContainer) { + trackProgressContainer.style.display = 'block'; + } + + 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 (safeProgress >= 100) { + trackProgressBar.classList.add('complete'); + } else { + trackProgressBar.classList.remove('complete'); + } + } 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', 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)); + } + } + + // Store progress for potential later use + entry.progress = overallProgress; + } + + return; // Skip the standard handling below + } + + // Standard handling for album/playlist direct updates (not track-level): + // Update title and subtitle based on item type + if (entry.type === 'album') { + if (statusData.title && titleElement) { + titleElement.textContent = statusData.title; + } + if (statusData.artist && artistElement) { + artistElement.textContent = statusData.artist; + } + } else if (entry.type === 'playlist') { + if (statusData.name && titleElement) { + titleElement.textContent = statusData.name; + } + if (statusData.owner && artistElement) { + artistElement.textContent = statusData.owner; + } + } + + // Extract track counting data from status data + if (statusData.current_track && statusData.total_tracks) { + currentTrack = parseInt(statusData.current_track as string, 10); // Cast to string + totalTracks = parseInt(statusData.total_tracks as string, 10); // Cast to string + } else if (statusData.parsed_current_track && statusData.parsed_total_tracks) { + currentTrack = parseInt(statusData.parsed_current_track as string, 10); // Cast to string + totalTracks = parseInt(statusData.parsed_total_tracks as string, 10); // Cast to string + } else if (statusData.current_track && typeof statusData.current_track === 'string' && /^\d+\/\d+$/.test(statusData.current_track)) { // Add type check + // Parse formats like "1/12" + const parts = statusData.current_track.split('/'); + currentTrack = parseInt(parts[0], 10); + 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 = 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 + } + + // Update progress counter if available + if (progressCounter && totalTracks > 0) { + progressCounter.textContent = `${currentTrack}/${totalTracks}`; + } + + // Calculate overall progress + let overallProgress = 0; + if (totalTracks > 0) { + // Use explicit overall_progress if provided + if (statusData.overall_progress !== undefined) { + overallProgress = statusData.overall_progress; // overall_progress is number + } else if (trackProgress !== undefined) { + // For both real-time and standard multi-track downloads, use same formula + const completedTracksProgress = (currentTrack - 1) / totalTracks; + const currentTrackContribution = (1 / totalTracks) * (trackProgress / 100); + overallProgress = (completedTracksProgress + currentTrackContribution) * 100; + console.log(`Progress: Track ${currentTrack}/${totalTracks}, Track progress: ${trackProgress}%, Overall: ${overallProgress.toFixed(2)}%`); + } else { + overallProgress = 0; + } + + // Update overall progress bar + if (overallProgressBar) { + // Ensure progress is between 0-100 + const safeProgress = Math.max(0, Math.min(100, overallProgress)); + overallProgressBar.style.width = `${safeProgress}%`; + overallProgressBar.setAttribute('aria-valuenow', String(safeProgress)); + + // Add success class when complete + if (safeProgress >= 100) { + overallProgressBar.classList.add('complete'); + } else { + overallProgressBar.classList.remove('complete'); + } + } + + // Update track progress bar for current track in multi-track items + if (trackProgressBar) { + // Make sure progress bar container is visible + const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; + if (trackProgressContainer) { + trackProgressContainer.style.display = 'block'; + } + + 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 (safeProgress >= 100) { + trackProgressBar.classList.add('complete'); + } else { + trackProgressBar.classList.remove('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', 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)); + } + } + + // Store the progress in the entry for potential later use + entry.progress = overallProgress; + } + } + + /* Close all active polling intervals */ + clearAllPollingIntervals() { + for (const queueId in this.pollingIntervals) { + this.clearPollingInterval(queueId); + } + } + + /* New method for periodic server sync */ + async periodicSyncWithServer() { + console.log("Performing periodic sync with server..."); + try { + const response = await fetch('/api/prgs/list'); + if (!response.ok) { + console.error("Periodic sync: Failed to fetch task list from server", response.status); + return; + } + const serverTasks: any[] = await response.json(); + + const localTaskPrgFiles = new Set(Object.values(this.queueEntries).map(entry => entry.taskId)); + const serverTaskPrgFiles = new Set(serverTasks.map(task => task.task_id)); + + const terminalStates = ['complete', 'done', 'cancelled', 'ERROR_AUTO_CLEANED', 'ERROR_RETRIED', 'cancel', 'interrupted', 'error']; + + // 1. Add new tasks from server not known locally or update existing ones + for (const serverTask of serverTasks) { + const taskId = serverTask.task_id; // This is the prgFile + const lastStatus = serverTask.last_status_obj; + const originalRequest = serverTask.original_request || {}; + + if (terminalStates.includes(lastStatus?.status)) { + // If server says it's terminal, and we have it locally, ensure it's cleaned up + const localEntry = Object.values(this.queueEntries).find(e => e.taskId === taskId); + if (localEntry && !localEntry.hasEnded) { + console.log(`Periodic sync: Server task ${taskId} is terminal (${lastStatus.status}), cleaning up local entry.`); + // Use a status object for handleDownloadCompletion + this.handleDownloadCompletion(localEntry, localEntry.uniqueId, lastStatus); + } + continue; // Skip adding terminal tasks to UI if not already there + } + + if (!localTaskPrgFiles.has(taskId)) { + console.log(`Periodic sync: Found new non-terminal task ${taskId} on server. Adding to queue.`); + let itemType = serverTask.type || originalRequest.type || 'unknown'; + let dummyItem: QueueItem = { + name: serverTask.name || originalRequest.name || taskId, + artist: serverTask.artist || originalRequest.artist || '', + type: itemType, + url: originalRequest.url || lastStatus?.url || '', + endpoint: originalRequest.endpoint || '', + download_type: serverTask.download_type || originalRequest.download_type || '', + total_tracks: lastStatus?.total_tracks || originalRequest.total_tracks, + current_track: lastStatus?.current_track, + }; + + if (lastStatus && lastStatus.type === 'track' && lastStatus.parent) { + const parent = lastStatus.parent; + if (parent.type === 'album') { + itemType = 'album'; + dummyItem = { + name: parent.title || 'Unknown Album', + artist: parent.artist || 'Unknown Artist', + type: 'album', url: parent.url || '', + total_tracks: parent.total_tracks || lastStatus.total_tracks, + parent: parent }; + } else if (parent.type === 'playlist') { + itemType = 'playlist'; + dummyItem = { + name: parent.name || 'Unknown Playlist', + owner: parent.owner || 'Unknown Creator', + type: 'playlist', url: parent.url || '', + total_tracks: parent.total_tracks || lastStatus.total_tracks, + parent: parent }; + } + } + const requestUrl = originalRequest.url ? `/api/${itemType}/download/${originalRequest.url.split('/').pop()}?name=${encodeURIComponent(dummyItem.name || '')}&artist=${encodeURIComponent(dummyItem.artist || '')}` : null; + // Add with startMonitoring = true + const queueId = this.addDownload(dummyItem, itemType, taskId, requestUrl, true); + const newEntry = this.queueEntries[queueId]; + if (newEntry && lastStatus) { + // Manually set lastStatus and update UI as addDownload might not have full server info yet + newEntry.lastStatus = lastStatus; + if(lastStatus.parent) newEntry.parentInfo = lastStatus.parent; + this.applyStatusClasses(newEntry, lastStatus); + const logEl = newEntry.element.querySelector('.log') as HTMLElement | null; + if(logEl) logEl.textContent = this.getStatusMessage(lastStatus); + // Ensure polling is active for this newly added item + this.setupPollingInterval(newEntry.uniqueId); + } + } else { + // Task exists locally, check if status needs update from server list + const localEntry = Object.values(this.queueEntries).find(e => e.taskId === taskId); + if (localEntry && lastStatus && JSON.stringify(localEntry.lastStatus) !== JSON.stringify(lastStatus)) { + if (!localEntry.hasEnded) { + console.log(`Periodic sync: Updating status for existing task ${taskId} from ${localEntry.lastStatus?.status} to ${lastStatus.status}`); + // Create a data object that handleStatusUpdate expects + const updateData: StatusData = { ...serverTask, last_line: lastStatus }; + this.handleStatusUpdate(localEntry.uniqueId, updateData); + } + } + } + } + + // 2. Remove local tasks that are no longer on the server or are now terminal on server + for (const localEntry of Object.values(this.queueEntries)) { + if (!serverTaskPrgFiles.has(localEntry.taskId)) { + if (!localEntry.hasEnded) { + console.log(`Periodic sync: Local task ${localEntry.taskId} not found on server. Assuming completed/cleaned. Removing.`); + this.cleanupEntry(localEntry.uniqueId); + } + } else { + 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); + } + } + } + } + + this.updateQueueOrder(); + + } catch (error) { + console.error("Error during periodic sync with server:", error); + } + } +} + +// Singleton instance +export const downloadQueue = new DownloadQueue(); \ No newline at end of file diff --git a/static/css/history/history.css b/static/css/history/history.css new file mode 100644 index 0000000..42c1522 --- /dev/null +++ b/static/css/history/history.css @@ -0,0 +1,203 @@ +body { + font-family: sans-serif; + margin: 0; + background-color: #121212; + color: #e0e0e0; +} + +.container { + padding: 20px; + max-width: 1200px; + margin: auto; +} + +h1 { + color: #1DB954; /* Spotify Green */ + text-align: center; +} + +table { + width: 100%; + border-collapse: collapse; + margin-top: 20px; + background-color: #1e1e1e; +} + +th, td { + border: 1px solid #333; + padding: 10px 12px; + text-align: left; +} + +th { + background-color: #282828; + cursor: pointer; +} + +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; +} + +.pagination button, .pagination select { + padding: 8px 12px; + margin: 0 5px; + background-color: #1DB954; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.pagination button:disabled { + background-color: #555; + cursor: not-allowed; +} + +.filters { + margin-bottom: 20px; + display: flex; + gap: 15px; + align-items: center; + flex-wrap: wrap; +} + +.filters label, .filters select, .filters input { + margin-right: 5px; +} + +.filters select, .filters input { + padding: 8px; + background-color: #282828; + color: #e0e0e0; + border: 1px solid #333; + 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; + color: #FF4136; /* Red for error indicator */ + text-decoration: underline; +} + +.error-details { + display: none; /* Hidden by default */ + white-space: pre-wrap; /* Preserve formatting */ + background-color: #303030; + padding: 5px; + margin-top: 5px; + border-radius: 3px; + font-size: 0.9em; +} + +/* 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 */ + padding: 5px; /* Adjust padding to control size */ + cursor: pointer; + display: inline-flex; /* Important for aligning the image */ + align-items: center; + justify-content: center; + transition: background-color 0.2s ease; + margin-right: 5px; +} + +.btn-icon img { + width: 16px; /* Icon size */ + height: 16px; + filter: invert(1); /* Make icon white if it's dark, adjust if needed */ +} + +.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 new file mode 100644 index 0000000..7765c27 --- /dev/null +++ b/static/css/queue/queue.css @@ -0,0 +1,825 @@ +/* ---------------------- */ +/* DOWNLOAD QUEUE STYLES */ +/* ---------------------- */ + +/* Container for the download queue sidebar */ +#downloadQueue { + position: fixed; + top: 0; + right: -350px; /* Hidden offscreen by default */ + width: 350px; + height: 100vh; + background: #181818; + padding: 20px; + transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 1001; + /* Remove overflow-y here to delegate scrolling to the queue items container */ + box-shadow: -20px 0 30px rgba(0, 0, 0, 0.4); + + /* Added for flex layout */ + display: flex; + flex-direction: column; +} + +/* When active, the sidebar slides into view */ +#downloadQueue.active { + right: 0; +} + +/* Header inside the queue sidebar */ +.sidebar-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 15px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + margin-bottom: 20px; +} + +.sidebar-header h2 { + font-size: 1.25rem; + font-weight: 600; + color: #fff; + margin: 0; +} + +/* Queue subtitle with statistics */ +.queue-subtitle { + display: flex; + gap: 10px; + margin-top: 5px; + font-size: 0.8rem; + color: #b3b3b3; +} + +.queue-stat { + padding: 2px 6px; + border-radius: 4px; + font-weight: 500; +} + +.queue-stat-active { + color: #4a90e2; + background-color: rgba(74, 144, 226, 0.1); +} + +.queue-stat-completed { + color: #1DB954; + background-color: rgba(29, 185, 84, 0.1); +} + +.queue-stat-error { + color: #ff5555; + background-color: rgba(255, 85, 85, 0.1); +} + +.header-actions { + display: flex; + gap: 10px; + align-items: center; +} + +/* Refresh queue button */ +#refreshQueueBtn { + background: #2a2a2a; + border: none; + color: #fff; + padding: 8px; + border-radius: 4px; + cursor: pointer; + transition: background 0.3s ease, transform 0.2s ease; + display: flex; + align-items: center; + justify-content: center; +} + +#refreshQueueBtn:hover { + background: #333; + transform: translateY(-1px); +} + +#refreshQueueBtn:active { + transform: scale(0.95); +} + +#refreshQueueBtn.refreshing { + animation: spin 1s linear infinite; +} + +/* Artist queue message */ +.queue-artist-message { + background: #2a2a2a; + padding: 15px; + border-radius: 8px; + margin-bottom: 15px; + color: #fff; + text-align: center; + border-left: 4px solid #4a90e2; + animation: pulse 1.5s infinite; + font-weight: 500; +} + +@keyframes pulse { + 0% { opacity: 0.8; } + 50% { opacity: 1; } + 100% { opacity: 0.8; } +} + +/* Cancel all button styling */ +#cancelAllBtn { + background: #8b0000; /* Dark blood red */ + border: none; + color: #fff; + padding: 8px 12px; + border-radius: 4px; + cursor: pointer; + transition: background 0.3s ease, transform 0.2s ease; + font-size: 14px; + font-weight: 600; + display: flex; + align-items: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); +} + +#cancelAllBtn:hover { + background: #a30000; /* Slightly lighter red on hover */ + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4); +} + +#cancelAllBtn:active { + transform: scale(0.98); +} + +/* Close button for the queue sidebar */ +.close-btn { + background: #2a2a2a; + border: none; + border-radius: 50%; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + color: #ffffff; + font-size: 20px; + cursor: pointer; + transition: background-color 0.3s ease, transform 0.2s ease; +} + +.close-btn:hover { + background-color: #333; + transform: scale(1.05); +} + +.close-btn:active { + transform: scale(0.95); +} + +/* Container for all queue items */ +#queueItems { + /* Allow the container to fill all available space in the sidebar */ + flex: 1; + overflow-y: auto; + padding-right: 5px; /* Add slight padding for scrollbar */ + scrollbar-width: thin; + scrollbar-color: #1DB954 rgba(255, 255, 255, 0.1); +} + +/* Custom scrollbar styles */ +#queueItems::-webkit-scrollbar { + width: 6px; +} + +#queueItems::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.1); + border-radius: 10px; +} + +#queueItems::-webkit-scrollbar-thumb { + background-color: #1DB954; + border-radius: 10px; +} + +/* Each download queue item */ +.queue-item { + background: #2a2a2a; + padding: 15px; + border-radius: 8px; + margin-bottom: 15px; + transition: all 0.3s ease; + display: flex; + flex-direction: column; + gap: 6px; + position: relative; + border-left: 4px solid transparent; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); +} + +/* Animation only for newly added items */ +.queue-item-new { + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(5px); } + to { opacity: 1; transform: translateY(0); } +} + +.queue-item:hover { + background-color: #333; + transform: translateY(-5px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +/* Title text in a queue item */ +.queue-item .title { + font-weight: 600; + margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: #fff; + font-size: 14px; +} + +/* Type indicator (e.g. track, album) */ +.queue-item .type { + font-size: 11px; + color: #1DB954; + text-transform: uppercase; + letter-spacing: 0.7px; + font-weight: 600; + background-color: rgba(29, 185, 84, 0.1); + padding: 3px 6px; + border-radius: 4px; + display: inline-block; + width: fit-content; +} + +/* Album type - for better visual distinction */ +.queue-item .type.album { + color: #4a90e2; + background-color: rgba(74, 144, 226, 0.1); +} + +/* Track type */ +.queue-item .type.track { + color: #1DB954; + background-color: rgba(29, 185, 84, 0.1); +} + +/* Playlist type */ +.queue-item .type.playlist { + color: #e67e22; + background-color: rgba(230, 126, 34, 0.1); +} + +/* Log text for status messages */ +.queue-item .log { + font-size: 13px; + color: #b3b3b3; + line-height: 1.4; + font-family: 'SF Mono', Menlo, monospace; + padding: 8px 0; + word-break: break-word; +} + +/* Optional state indicators for each queue item */ +.queue-item--complete, +.queue-item.download-success { + border-left-color: #1DB954; +} + +.queue-item--error { + border-left-color: #ff5555; +} + +.queue-item--processing { + border-left-color: #4a90e2; +} + +/* Progress bar for downloads */ +.status-bar { + height: 3px; + background: #1DB954; + width: 0; + transition: width 0.3s ease; + margin-top: 8px; + border-radius: 2px; +} + +/* Overall progress container for albums and playlists */ +.overall-progress-container { + margin-top: 12px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + position: relative; /* Positioning context for z-index */ + z-index: 2; /* Ensure overall progress appears above track progress */ +} + +.overall-progress-header { + display: flex; + justify-content: space-between; + margin-bottom: 5px; + font-size: 11px; + color: #b3b3b3; +} + +.overall-progress-label { + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.overall-progress-count { + font-weight: 600; + color: #1DB954; +} + +.overall-progress-bar-container { + height: 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + overflow: hidden; +} + +.overall-progress-bar { + height: 100%; + background: linear-gradient(90deg, #4a90e2, #7a67ee); /* Changed to blue-purple gradient */ + width: 0; + border-radius: 3px; + transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; +} + +.overall-progress-bar.complete { + background: #4a90e2; /* Changed to solid blue for completed overall progress */ +} + +/* Track progress bar container */ +.track-progress-bar-container { + height: 4px; + background: rgba(255, 255, 255, 0.1); + border-radius: 2px; + overflow: hidden; + margin-top: 8px; + margin-bottom: 4px; + position: relative; + z-index: 1; /* Ensure it's below the overall progress */ +} + +/* Track progress bar */ +.track-progress-bar { + height: 100%; + background: #1DB954; /* Keep green for track-level progress */ + width: 0; + border-radius: 2px; + transition: width 0.3s ease; + box-shadow: 0 0 3px rgba(29, 185, 84, 0.5); /* Add subtle glow to differentiate */ +} + +/* Complete state for track progress */ +/* Real-time progress style */ +.track-progress-bar.real-time { + background: #1DB954; /* Vivid green for real-time progress */ + background: #1DB954; +} + +/* Pulsing animation for indeterminate progress */ +.track-progress-bar.progress-pulse { + background: linear-gradient(90deg, #1DB954 0%, #2cd267 50%, #1DB954 100%); /* Keep in green family */ + background-size: 200% 100%; + animation: progress-pulse-slide 1.5s ease infinite; +} + +@keyframes progress-pulse-slide { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +/* Progress percentage text */ +.progress-percent { + text-align: right; + font-weight: bold; + font-size: 12px; + color: #1DB954; + margin-top: 4px; +} + +/* Optional status message colors (if using state classes) */ +.log--success { + color: #1DB954 !important; +} + +.log--error { + color: #ff5555 !important; +} + +.log--warning { + color: #ffaa00 !important; +} + +.log--info { + color: #4a90e2 !important; +} + +/* Loader animations for real-time progress */ +@keyframes progress-pulse { + 0% { opacity: 0.5; } + 50% { opacity: 1; } + 100% { opacity: 0.5; } +} + +.progress-indicator { + display: inline-block; + margin-left: 8px; + animation: progress-pulse 1.5s infinite; +} + +/* Loading spinner style */ +.loading-spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top-color: #1DB954; + animation: spin 1s ease-in-out infinite; + margin-right: 6px; + vertical-align: middle; +} + +.loading-spinner.small { + width: 10px; + height: 10px; + border-width: 1px; + margin-right: 4px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Cancel button inside each queue item */ +.cancel-btn { + background: none; + border: none; + cursor: pointer; + padding: 5px; + outline: none; + margin-top: 10px; + /* Optionally constrain the overall size */ + max-width: 24px; + max-height: 24px; + position: absolute; + top: 10px; + right: 10px; + opacity: 0.7; + transition: opacity 0.2s ease, transform 0.2s ease; +} + +.cancel-btn:hover { + opacity: 1; +} + +.cancel-btn img { + width: 16px; + height: 16px; + filter: invert(1); + transition: transform 0.3s ease; +} + +.cancel-btn:hover img { + transform: scale(1.1); +} + +.cancel-btn:active img { + transform: scale(0.9); +} + +/* Group header for multiple albums from same artist */ +.queue-group-header { + font-size: 14px; + color: #b3b3b3; + margin: 15px 0 10px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + justify-content: space-between; +} + +.queue-group-header span { + display: flex; + align-items: center; +} + +.queue-group-header span::before { + content: ''; + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + background-color: #1DB954; + margin-right: 8px; +} + +/* ------------------------------- */ +/* FOOTER & "SHOW MORE" BUTTON */ +/* ------------------------------- */ + +#queueFooter { + text-align: center; + padding-top: 15px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + margin-top: 10px; +} + +#queueFooter button { + background: #1DB954; + border: none; + padding: 10px 18px; + border-radius: 20px; + color: #fff; + cursor: pointer; + transition: all 0.3s ease; + font-size: 14px; + font-weight: 500; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); +} + +#queueFooter button:hover { + background: #17a448; + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +#queueFooter button:active { + transform: scale(0.98); +} + +/* -------------------------- */ +/* ERROR BUTTONS STYLES */ +/* -------------------------- */ + +/* Container for error action buttons */ +.error-buttons { + display: flex; + justify-content: flex-end; + gap: 8px; + 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; + border-radius: 4px; + padding: 6px 12px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +/* Hover state for all error buttons */ +.error-buttons button:hover { + transform: translateY(-2px); +} + +.error-buttons button:active { + transform: translateY(0); +} + +/* Specific styles for the Close (X) error button */ +.close-error-btn { + background-color: #333; + color: #fff; +} + +.close-error-btn:hover { + background-color: #444; +} + +/* Specific styles for the Retry button */ +.retry-btn { + background-color: #ff5555; + color: #fff; + padding: 6px 15px !important; +} + +.retry-btn:hover { + background-color: #ff6b6b; +} + +/* Empty queue state */ +.queue-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 200px; + color: #b3b3b3; + text-align: center; + padding: 20px; +} + +.queue-empty img { + width: 60px; + height: 60px; + margin-bottom: 15px; + opacity: 0.6; +} + +.queue-empty p { + font-size: 14px; + line-height: 1.5; +} + +/* Error notification in queue */ +.queue-error { + background-color: rgba(192, 57, 43, 0.1); + color: #ff5555; + padding: 10px 15px; + border-radius: 8px; + margin-bottom: 15px; + font-size: 14px; + border-left: 3px solid #ff5555; + animation: fadeIn 0.3s ease; +} + +/* Error state styling */ +.queue-item.error { + border-left: 4px solid #ff5555; + background-color: rgba(255, 85, 85, 0.05); + transition: none !important; /* Remove all transitions */ + transform: none !important; /* Prevent any transform */ + position: relative !important; /* Keep normal positioning */ + left: 0 !important; /* Prevent any left movement */ + right: 0 !important; /* Prevent any right movement */ + top: 0 !important; /* Prevent any top movement */ +} + +.queue-item.error:hover { + background-color: rgba(255, 85, 85, 0.1); + transform: none !important; /* Force disable any transform */ + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2) !important; /* Keep original shadow */ + position: relative !important; /* Force normal positioning */ + left: 0 !important; /* Prevent any left movement */ + right: 0 !important; /* Prevent any right movement */ + top: 0 !important; /* Prevent any top movement */ +} + +.error-message { + color: #ff5555; + margin-bottom: 10px; + font-size: 13px; + line-height: 1.4; +} + +/* ------------------------------- */ +/* MOBILE RESPONSIVE ADJUSTMENTS */ +/* ------------------------------- */ +@media (max-width: 600px) { + /* Make the sidebar full width on mobile */ + #downloadQueue { + width: 100%; + right: -100%; /* Off-screen fully */ + padding: 15px; + } + + /* When active, the sidebar slides into view from full width */ + #downloadQueue.active { + right: 0; + } + + /* Adjust header and title for smaller screens */ + .sidebar-header { + flex-direction: row; + align-items: center; + padding-bottom: 12px; + margin-bottom: 15px; + } + + .sidebar-header h2 { + font-size: 1.1rem; + } + + /* Reduce the size of the close buttons */ + .close-btn { + width: 28px; + height: 28px; + font-size: 18px; + } + + /* Adjust queue items padding */ + .queue-item { + padding: 12px; + margin-bottom: 12px; + } + + /* Ensure text remains legible on smaller screens */ + .queue-item .log, + .queue-item .type { + font-size: 12px; + } + + #cancelAllBtn { + padding: 6px 10px; + font-size: 12px; + } + + .error-buttons { + flex-direction: row; + } + + .close-error-btn { + width: 28px; + height: 28px; + } + + .retry-btn { + padding: 6px 12px !important; + } +} diff --git a/static/html/history.html b/static/html/history.html new file mode 100644 index 0000000..4e450f9 --- /dev/null +++ b/static/html/history.html @@ -0,0 +1,98 @@ + + + + + + Download History + + + + + + + + + +
+

Download History

+ +
+ + + + + + + + + +
+ + +
+
+ + + + + + + + + + + + + + + + + + +
NameArtistType/StatusServiceQualityStatusDate AddedDate Completed/EndedActions
+ +
+ + + + Home + + + + + + + + + \ No newline at end of file diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..a64a48e --- /dev/null +++ b/tests/README.md @@ -0,0 +1,44 @@ +# Spotizerr Backend Tests + +This directory contains automated tests for the Spotizerr backend API. + +## Prerequisites + +1. **Running Backend**: Ensure the Spotizerr Flask application is running and accessible at `http://localhost:7171`. You can start it with `python app.py`. + +2. **Python Dependencies**: Install the necessary Python packages for testing. + ```bash + pip install pytest requests python-dotenv + ``` + +3. **Credentials**: These tests require valid Spotify and Deezer credentials. Create a file named `.env` in the root directory of the project (`spotizerr`) and add your credentials to it. The tests will load this file automatically. + + **Example `.env` file:** + ``` + SPOTIFY_API_CLIENT_ID="your_spotify_client_id" + SPOTIFY_API_CLIENT_SECRET="your_spotify_client_secret" + # This should be the full JSON content of your credentials blob as a single line string + SPOTIFY_BLOB_CONTENT='{"username": "your_spotify_username", "password": "your_spotify_password", ...}' + DEEZER_ARL="your_deezer_arl" + ``` + + The tests will automatically use these credentials to create and manage test accounts named `test-spotify-account` and `test-deezer-account`. + +## Running Tests + +To run all tests, navigate to the root directory of the project (`spotizerr`) and run `pytest`: + +```bash +pytest +``` + +To run a specific test file: + +```bash +pytest tests/test_downloads.py +``` + +For more detailed output, use the `-v` (verbose) and `-s` (show print statements) flags: +```bash +pytest -v -s +``` \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ea1c6ec --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,149 @@ +import pytest +import requests +import time +import os +import json +from dotenv import load_dotenv + +# Load environment variables from .env file in the project root +load_dotenv() + +# --- Environment-based secrets for testing --- +SPOTIFY_API_CLIENT_ID = os.environ.get("SPOTIFY_API_CLIENT_ID", "your_spotify_client_id") +SPOTIFY_API_CLIENT_SECRET = os.environ.get("SPOTIFY_API_CLIENT_SECRET", "your_spotify_client_secret") +SPOTIFY_BLOB_CONTENT_STR = os.environ.get("SPOTIFY_BLOB_CONTENT", '{}') +try: + SPOTIFY_BLOB_CONTENT = json.loads(SPOTIFY_BLOB_CONTENT_STR) +except json.JSONDecodeError: + SPOTIFY_BLOB_CONTENT = {} + +DEEZER_ARL = os.environ.get("DEEZER_ARL", "your_deezer_arl") + +# --- Standard names for test accounts --- +SPOTIFY_ACCOUNT_NAME = "test-spotify-account" +DEEZER_ACCOUNT_NAME = "test-deezer-account" + + +@pytest.fixture(scope="session") +def base_url(): + """Provides the base URL for the API tests.""" + return "http://localhost:7171/api" + + +def wait_for_task(base_url, task_id, timeout=600): + """ + Waits for a Celery task to reach a terminal state (complete, error, etc.). + Polls the progress endpoint and prints status updates. + """ + print(f"\n--- Waiting for task {task_id} (timeout: {timeout}s) ---") + start_time = time.time() + while time.time() - start_time < timeout: + try: + response = requests.get(f"{base_url}/prgs/{task_id}") + if response.status_code == 404: + time.sleep(1) + continue + + response.raise_for_status() # Raise an exception for bad status codes + + data = response.json() + if not data or not data.get("last_line"): + time.sleep(1) + continue + + last_status = data["last_line"] + status = last_status.get("status") + + # More verbose logging for debugging during tests + message = last_status.get('message', '') + track = last_status.get('track', '') + progress = last_status.get('overall_progress', '') + print(f"Task {task_id} | Status: {status:<12} | Progress: {progress or 'N/A':>3}% | Track: {track:<30} | Message: {message}") + + if status in ["complete", "ERROR", "cancelled", "ERROR_RETRIED", "ERROR_AUTO_CLEANED"]: + print(f"--- Task {task_id} finished with status: {status} ---") + return last_status + + time.sleep(2) + except requests.exceptions.RequestException as e: + print(f"Warning: Request to fetch task status for {task_id} failed: {e}. Retrying...") + time.sleep(5) + + raise TimeoutError(f"Task {task_id} did not complete within {timeout} seconds.") + + +@pytest.fixture(scope="session") +def task_waiter(base_url): + """Provides a fixture that returns the wait_for_task helper function.""" + def _waiter(task_id, timeout=600): + return wait_for_task(base_url, task_id, timeout) + return _waiter + + +@pytest.fixture(scope="session", autouse=True) +def setup_credentials_for_tests(base_url): + """ + A session-wide, automatic fixture to set up all necessary credentials. + It runs once before any tests, and tears down the credentials after all tests are complete. + """ + print("\n--- Setting up credentials for test session ---") + + print("\n--- DEBUGGING CREDENTIALS ---") + print(f"SPOTIFY_API_CLIENT_ID: {SPOTIFY_API_CLIENT_ID}") + print(f"SPOTIFY_API_CLIENT_SECRET: {SPOTIFY_API_CLIENT_SECRET}") + print(f"DEEZER_ARL: {DEEZER_ARL}") + print(f"SPOTIFY_BLOB_CONTENT {SPOTIFY_BLOB_CONTENT}") + print("--- END DEBUGGING ---\n") + + # Skip all tests if secrets are not provided in the environment + if SPOTIFY_API_CLIENT_ID == "your_spotify_client_id" or \ + SPOTIFY_API_CLIENT_SECRET == "your_spotify_client_secret" or \ + not SPOTIFY_BLOB_CONTENT or \ + DEEZER_ARL == "your_deezer_arl": + pytest.skip("Required credentials not provided in .env file or environment. Skipping credential-dependent tests.") + + # 1. Set global Spotify API creds + data = {"client_id": SPOTIFY_API_CLIENT_ID, "client_secret": SPOTIFY_API_CLIENT_SECRET} + response = requests.put(f"{base_url}/credentials/spotify_api_config", json=data) + if response.status_code != 200: + pytest.fail(f"Failed to set global Spotify API creds: {response.text}") + print("Global Spotify API credentials set.") + + # 2. Delete any pre-existing test credentials to ensure a clean state + requests.delete(f"{base_url}/credentials/spotify/{SPOTIFY_ACCOUNT_NAME}") + requests.delete(f"{base_url}/credentials/deezer/{DEEZER_ACCOUNT_NAME}") + print("Cleaned up any old test credentials.") + + # 3. Create Deezer credential + data = {"name": DEEZER_ACCOUNT_NAME, "arl": DEEZER_ARL, "region": "US"} + response = requests.post(f"{base_url}/credentials/deezer/{DEEZER_ACCOUNT_NAME}", json=data) + if response.status_code != 201: + pytest.fail(f"Failed to create Deezer credential: {response.text}") + print("Deezer test credential created.") + + # 4. Create Spotify credential + data = {"name": SPOTIFY_ACCOUNT_NAME, "blob_content": SPOTIFY_BLOB_CONTENT, "region": "US"} + response = requests.post(f"{base_url}/credentials/spotify/{SPOTIFY_ACCOUNT_NAME}", json=data) + if response.status_code != 201: + pytest.fail(f"Failed to create Spotify credential: {response.text}") + print("Spotify test credential created.") + + # 5. Set main config to use these accounts for downloads + config_payload = { + "spotify": SPOTIFY_ACCOUNT_NAME, + "deezer": DEEZER_ACCOUNT_NAME, + } + response = requests.post(f"{base_url}/config", json=config_payload) + if response.status_code != 200: + pytest.fail(f"Failed to set main config for tests: {response.text}") + print("Main config set to use test credentials.") + + yield # This is where the tests will run + + # --- Teardown --- + print("\n--- Tearing down test credentials ---") + response = requests.delete(f"{base_url}/credentials/spotify/{SPOTIFY_ACCOUNT_NAME}") + assert response.status_code in [200, 404] + response = requests.delete(f"{base_url}/credentials/deezer/{DEEZER_ACCOUNT_NAME}") + assert response.status_code in [200, 404] + print("Test credentials deleted.") \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..914c24f --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,113 @@ +import requests +import pytest + +@pytest.fixture +def reset_config(base_url): + """A fixture to ensure the main config is reset after a test case.""" + response = requests.get(f"{base_url}/config") + assert response.status_code == 200 + original_config = response.json() + yield + response = requests.post(f"{base_url}/config", json=original_config) + assert response.status_code == 200 + +def test_get_main_config(base_url): + """Tests if the main configuration can be retrieved.""" + response = requests.get(f"{base_url}/config") + assert response.status_code == 200 + config = response.json() + assert "service" in config + assert "maxConcurrentDownloads" in config + assert "spotify" in config # Should be set by conftest + assert "deezer" in config # Should be set by conftest + assert "fallback" in config + assert "realTime" in config + assert "maxRetries" in config + +def test_update_main_config(base_url, reset_config): + """Tests updating various fields in the main configuration based on frontend capabilities.""" + new_settings = { + "maxConcurrentDownloads": 5, + "spotifyQuality": "HIGH", + "deezerQuality": "FLAC", + "customDirFormat": "%artist%/%album%", + "customTrackFormat": "%tracknum% %title%", + "save_cover": False, + "fallback": True, + "realTime": False, + "maxRetries": 5, + "retryDelaySeconds": 10, + "retry_delay_increase": 10, + "tracknum_padding": False, + } + + response = requests.post(f"{base_url}/config", json=new_settings) + assert response.status_code == 200 + updated_config = response.json() + + for key, value in new_settings.items(): + assert updated_config[key] == value + +def test_get_watch_config(base_url): + """Tests if the watch-specific configuration can be retrieved.""" + response = requests.get(f"{base_url}/config/watch") + assert response.status_code == 200 + config = response.json() + assert "enabled" in config + assert "watchPollIntervalSeconds" in config + assert "watchedArtistAlbumGroup" in config + +def test_update_watch_config(base_url): + """Tests updating the watch-specific configuration.""" + response = requests.get(f"{base_url}/config/watch") + original_config = response.json() + + new_settings = { + "enabled": False, + "watchPollIntervalSeconds": 7200, + "watchedArtistAlbumGroup": ["album", "single"], + } + + response = requests.post(f"{base_url}/config/watch", json=new_settings) + assert response.status_code == 200 + + # The response for updating watch config is just a success message, + # so we need to GET the config again to verify. + verify_response = requests.get(f"{base_url}/config/watch") + assert verify_response.status_code == 200 + updated_config = verify_response.json() + + for key, value in new_settings.items(): + assert updated_config[key] == value + + # Revert to original + requests.post(f"{base_url}/config/watch", json=original_config) + +def test_update_conversion_config(base_url, reset_config): + """ + Iterates through supported conversion formats and bitrates from the frontend, + updating the config and verifying the changes. + """ + # Formats and bitrates aligned with src/js/config.ts + conversion_formats = ["MP3", "AAC", "OGG", "OPUS", "FLAC", "WAV", "ALAC"] + bitrates = { + "MP3": ["128k", "320k"], + "AAC": ["128k", "256k"], + "OGG": ["128k", "320k"], + "OPUS": ["96k", "256k"], + "FLAC": [None], + "WAV": [None], + "ALAC": [None], + } + + for format_val in conversion_formats: + for br in bitrates.get(format_val, [None]): + print(f"Testing conversion config: format={format_val}, bitrate={br}") + new_settings = {"convertTo": format_val, "bitrate": br} + response = requests.post(f"{base_url}/config", json=new_settings) + + assert response.status_code == 200 + updated_config = response.json() + assert updated_config["convertTo"] == format_val + # The backend might return null for empty bitrate, which is fine + assert updated_config["bitrate"] == br \ No newline at end of file diff --git a/tests/test_downloads.py b/tests/test_downloads.py new file mode 100644 index 0000000..74db406 --- /dev/null +++ b/tests/test_downloads.py @@ -0,0 +1,212 @@ +import requests +import pytest +import os +import shutil + +# URLs for testing +SPOTIFY_TRACK_URL = "https://open.spotify.com/track/1Cts4YV9aOXVAP3bm3Ro6r" +SPOTIFY_ALBUM_URL = "https://open.spotify.com/album/4K0JVP5veNYTVI6IMamlla" +SPOTIFY_PLAYLIST_URL = "https://open.spotify.com/playlist/26CiMxIxdn5WhXyccMCPOB" +SPOTIFY_ARTIST_URL = "https://open.spotify.com/artist/7l6cdPhOLYO7lehz5xfzLV" + +# Corresponding IDs extracted from URLs +TRACK_ID = SPOTIFY_TRACK_URL.split('/')[-1].split('?')[0] +ALBUM_ID = SPOTIFY_ALBUM_URL.split('/')[-1].split('?')[0] +PLAYLIST_ID = SPOTIFY_PLAYLIST_URL.split('/')[-1].split('?')[0] +ARTIST_ID = SPOTIFY_ARTIST_URL.split('/')[-1].split('?')[0] + +DOWNLOAD_DIR = "downloads/" + + +def get_downloaded_files(directory=DOWNLOAD_DIR): + """Walks a directory and returns a list of all file paths.""" + file_paths = [] + if not os.path.isdir(directory): + return file_paths + for root, _, files in os.walk(directory): + for file in files: + # Ignore hidden files like .DS_Store + if not file.startswith('.'): + file_paths.append(os.path.join(root, file)) + return file_paths + + +@pytest.fixture(autouse=True) +def cleanup_downloads_dir(): + """ + Ensures the download directory is removed and recreated, providing a clean + slate before and after each test. + """ + if os.path.exists(DOWNLOAD_DIR): + shutil.rmtree(DOWNLOAD_DIR) + os.makedirs(DOWNLOAD_DIR, exist_ok=True) + yield + if os.path.exists(DOWNLOAD_DIR): + shutil.rmtree(DOWNLOAD_DIR) + + +@pytest.fixture +def reset_config(base_url): + """ + Fixture to get original config, set single concurrent download for test + isolation, and restore the original config after the test. + """ + response = requests.get(f"{base_url}/config") + original_config = response.json() + + # Set max concurrent downloads to 1 for all tests using this fixture. + requests.post(f"{base_url}/config", json={"maxConcurrentDownloads": 1}) + + yield + + # Restore original config + requests.post(f"{base_url}/config", json=original_config) + + +@pytest.mark.parametrize("download_type, item_id, timeout, expected_files_min", [ + ("track", TRACK_ID, 600, 1), + ("album", ALBUM_ID, 900, 14), # "After Hours" has 14 tracks + ("playlist", PLAYLIST_ID, 1200, 4), # Test playlist has 4 tracks +]) +def test_spotify_download_and_verify_files(base_url, task_waiter, reset_config, download_type, item_id, timeout, expected_files_min): + """ + Tests downloading a track, album, or playlist and verifies that the + expected number of files are created on disk. + """ + print(f"\n--- Testing Spotify-only '{download_type}' download and verifying files ---") + config_payload = { + "service": "spotify", + "fallback": False, + "realTime": True, + "spotifyQuality": "NORMAL" + } + requests.post(f"{base_url}/config", json=config_payload) + + response = requests.get(f"{base_url}/{download_type}/download/{item_id}") + assert response.status_code == 202 + task_id = response.json()["task_id"] + + final_status = task_waiter(task_id, timeout=timeout) + assert final_status["status"] == "complete", f"Task failed for {download_type} {item_id}: {final_status.get('error')}" + + # Verify that the correct number of files were downloaded + downloaded_files = get_downloaded_files() + assert len(downloaded_files) >= expected_files_min, ( + f"Expected at least {expected_files_min} file(s) for {download_type} {item_id}, " + f"but found {len(downloaded_files)}." + ) + + +def test_artist_download_and_verify_files(base_url, task_waiter, reset_config): + """ + Tests queuing an artist download and verifies that files are created. + Does not check for exact file count due to the variability of artist discographies. + """ + print("\n--- Testing Spotify-only artist download and verifying files ---") + config_payload = {"service": "spotify", "fallback": False, "realTime": True, "spotifyQuality": "NORMAL"} + requests.post(f"{base_url}/config", json=config_payload) + + response = requests.get(f"{base_url}/artist/download/{ARTIST_ID}?album_type=album,single") + assert response.status_code == 202 + response_data = response.json() + queued_albums = response_data.get("queued_albums", []) + assert len(queued_albums) > 0, "No albums were queued for the artist." + + for album in queued_albums: + task_id = album["task_id"] + print(f"--- Waiting for artist album: {album['name']} ({task_id}) ---") + final_status = task_waiter(task_id, timeout=900) + assert final_status["status"] == "complete", f"Artist album task {album['name']} failed: {final_status.get('error')}" + + # After all tasks complete, verify that at least some files were downloaded. + downloaded_files = get_downloaded_files() + assert len(downloaded_files) > 0, "Artist download ran but no files were found in the download directory." + + +def test_download_with_deezer_fallback_and_verify_files(base_url, task_waiter, reset_config): + """Tests downloading with Deezer fallback and verifies the file exists.""" + print("\n--- Testing track download with Deezer fallback and verifying files ---") + config_payload = { + "service": "spotify", + "fallback": True, + "deezerQuality": "FLAC" # Test with high quality fallback + } + requests.post(f"{base_url}/config", json=config_payload) + + response = requests.get(f"{base_url}/track/download/{TRACK_ID}") + assert response.status_code == 202 + task_id = response.json()["task_id"] + + final_status = task_waiter(task_id) + assert final_status["status"] == "complete", f"Task failed with fallback: {final_status.get('error')}" + + # Verify that at least one file was downloaded. + downloaded_files = get_downloaded_files() + assert len(downloaded_files) >= 1, "Fallback download completed but no file was found." + + +def test_download_without_realtime_and_verify_files(base_url, task_waiter, reset_config): + """Tests a non-realtime download and verifies the file exists.""" + print("\n--- Testing download with realTime: False and verifying files ---") + config_payload = { + "service": "spotify", + "fallback": False, + "realTime": False, + "spotifyQuality": "NORMAL" + } + requests.post(f"{base_url}/config", json=config_payload) + + response = requests.get(f"{base_url}/track/download/{TRACK_ID}") + assert response.status_code == 202 + task_id = response.json()["task_id"] + + final_status = task_waiter(task_id) + assert final_status["status"] == "complete", f"Task failed with realTime=False: {final_status.get('error')}" + + # Verify that at least one file was downloaded. + downloaded_files = get_downloaded_files() + assert len(downloaded_files) >= 1, "Non-realtime download completed but no file was found." + + +# Aligned with formats in src/js/config.ts's CONVERSION_FORMATS +@pytest.mark.parametrize("format_name,bitrate,expected_ext", [ + ("mp3", "320k", ".mp3"), + ("aac", "256k", ".m4a"), # AAC is typically in an M4A container + ("ogg", "320k", ".ogg"), + ("opus", "256k", ".opus"), + ("flac", None, ".flac"), + ("wav", None, ".wav"), + ("alac", None, ".m4a"), # ALAC is also in an M4A container +]) +def test_download_with_conversion_and_verify_format(base_url, task_waiter, reset_config, format_name, bitrate, expected_ext): + """ + Tests downloading a track with various conversion formats and verifies + that the created file has the correct extension. + """ + print(f"\n--- Testing conversion: {format_name.upper()} @ {bitrate or 'default'} ---") + config_payload = { + "service": "spotify", + "fallback": False, + "realTime": True, + "spotifyQuality": "NORMAL", + "convertTo": format_name.upper(), + "bitrate": bitrate + } + requests.post(f"{base_url}/config", json=config_payload) + + response = requests.get(f"{base_url}/track/download/{TRACK_ID}") + assert response.status_code == 202 + task_id = response.json()["task_id"] + + final_status = task_waiter(task_id) + assert final_status["status"] == "complete", f"Download failed for format {format_name} bitrate {bitrate}: {final_status.get('error')}" + + # Verify that a file with the correct extension was created. + downloaded_files = get_downloaded_files() + assert len(downloaded_files) >= 1, "Conversion download completed but no file was found." + + found_correct_format = any(f.lower().endswith(expected_ext) for f in downloaded_files) + assert found_correct_format, ( + f"No file with expected extension '{expected_ext}' found for format '{format_name}'. " + f"Found files: {downloaded_files}" + ) \ No newline at end of file diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 0000000..efcd6fd --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,61 @@ +import requests +import pytest +import time + +TRACK_ID = "1Cts4YV9aOXVAP3bm3Ro6r" # Use a known, short track + +@pytest.fixture +def reset_config(base_url): + """Fixture to reset the main config after a test.""" + response = requests.get(f"{base_url}/config") + original_config = response.json() + yield + requests.post(f"{base_url}/config", json=original_config) + +def test_history_logging_and_filtering(base_url, task_waiter, reset_config): + """ + Tests if a completed download appears in the history and + verifies that history filtering works correctly. + """ + # First, complete a download task to ensure there's a history entry + config_payload = {"service": "spotify", "fallback": False, "realTime": True} + requests.post(f"{base_url}/config", json=config_payload) + response = requests.get(f"{base_url}/track/download/{TRACK_ID}") + assert response.status_code == 200 + task_id = response.json()["task_id"] + task_waiter(task_id) # Wait for the download to complete + + # Give a moment for history to be written if it's asynchronous + time.sleep(2) + + # 1. Get all history and check if our task is present + print("\n--- Verifying task appears in general history ---") + response = requests.get(f"{base_url}/history") + assert response.status_code == 200 + history_data = response.json() + assert "entries" in history_data + assert "total" in history_data + assert history_data["total"] > 0 + + # Find our specific task in the history + history_entry = next((entry for entry in history_data["entries"] if entry['task_id'] == task_id), None) + assert history_entry is not None, f"Task {task_id} not found in download history." + assert history_entry["status_final"] == "COMPLETED" + + # 2. Test filtering for COMPLETED tasks + print("\n--- Verifying history filtering for COMPLETED status ---") + response = requests.get(f"{base_url}/history?filters[status_final]=COMPLETED") + assert response.status_code == 200 + completed_history = response.json() + assert completed_history["total"] > 0 + assert any(entry['task_id'] == task_id for entry in completed_history["entries"]) + assert all(entry['status_final'] == 'COMPLETED' for entry in completed_history["entries"]) + + # 3. Test filtering for an item name + print(f"\n--- Verifying history filtering for item_name: {history_entry['item_name']} ---") + item_name_query = requests.utils.quote(history_entry['item_name']) + response = requests.get(f"{base_url}/history?filters[item_name]={item_name_query}") + assert response.status_code == 200 + named_history = response.json() + assert named_history["total"] > 0 + assert any(entry['task_id'] == task_id for entry in named_history["entries"]) \ No newline at end of file diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 0000000..0d6072f --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,35 @@ +import requests +import pytest + +def test_search_spotify_artist(base_url): + """Tests searching for an artist on Spotify.""" + response = requests.get(f"{base_url}/search?q=Daft+Punk&search_type=artist") + assert response.status_code == 200 + results = response.json() + assert "items" in results + assert len(results["items"]) > 0 + assert "Daft Punk" in results["items"][0]["name"] + +def test_search_spotify_track(base_url): + """Tests searching for a track on Spotify.""" + response = requests.get(f"{base_url}/search?q=Get+Lucky&search_type=track") + assert response.status_code == 200 + results = response.json() + assert "items" in results + assert len(results["items"]) > 0 + +def test_search_deezer_track(base_url): + """Tests searching for a track on Deezer.""" + response = requests.get(f"{base_url}/search?q=Instant+Crush&search_type=track") + assert response.status_code == 200 + results = response.json() + assert "items" in results + assert len(results["items"]) > 0 + +def test_search_deezer_album(base_url): + """Tests searching for an album on Deezer.""" + response = requests.get(f"{base_url}/search?q=Random+Access+Memories&search_type=album") + assert response.status_code == 200 + results = response.json() + assert "items" in results + assert len(results["items"]) > 0 \ No newline at end of file diff --git a/tests/test_watch.py b/tests/test_watch.py new file mode 100644 index 0000000..fba8a31 --- /dev/null +++ b/tests/test_watch.py @@ -0,0 +1,117 @@ +import requests +import pytest +import time + +SPOTIFY_PLAYLIST_ID = "26CiMxIxdn5WhXyccMCPOB" +SPOTIFY_ARTIST_ID = "7l6cdPhOLYO7lehz5xfzLV" + +@pytest.fixture(autouse=True) +def setup_and_cleanup_watch_tests(base_url): + """ + A fixture that enables watch mode, cleans the watchlist before each test, + and then restores original state and cleans up after each test. + """ + # Get original watch config to restore it later + response = requests.get(f"{base_url}/config/watch") + assert response.status_code == 200 + original_config = response.json() + + # Enable watch mode for testing if it's not already + if not original_config.get("enabled"): + response = requests.post(f"{base_url}/config/watch", json={"enabled": True}) + assert response.status_code == 200 + + # Cleanup any existing watched items before the test + requests.delete(f"{base_url}/playlist/watch/{SPOTIFY_PLAYLIST_ID}") + requests.delete(f"{base_url}/artist/watch/{SPOTIFY_ARTIST_ID}") + + yield + + # Cleanup watched items created during the test + requests.delete(f"{base_url}/playlist/watch/{SPOTIFY_PLAYLIST_ID}") + requests.delete(f"{base_url}/artist/watch/{SPOTIFY_ARTIST_ID}") + + # Restore original watch config + response = requests.post(f"{base_url}/config/watch", json=original_config) + assert response.status_code == 200 + +def test_add_and_list_playlist_to_watch(base_url): + """Tests adding a playlist to the watch list and verifying it appears in the list.""" + response = requests.put(f"{base_url}/playlist/watch/{SPOTIFY_PLAYLIST_ID}") + assert response.status_code == 200 + assert "Playlist added to watch list" in response.json()["message"] + + # Verify it's in the watched list + response = requests.get(f"{base_url}/playlist/watch/list") + assert response.status_code == 200 + watched_playlists = response.json() + assert any(p['spotify_id'] == SPOTIFY_PLAYLIST_ID for p in watched_playlists) + +def test_add_and_list_artist_to_watch(base_url): + """Tests adding an artist to the watch list and verifying it appears in the list.""" + response = requests.put(f"{base_url}/artist/watch/{SPOTIFY_ARTIST_ID}") + assert response.status_code == 200 + assert "Artist added to watch list" in response.json()["message"] + + # Verify it's in the watched list + response = requests.get(f"{base_url}/artist/watch/list") + assert response.status_code == 200 + watched_artists = response.json() + assert any(a['spotify_id'] == SPOTIFY_ARTIST_ID for a in watched_artists) + +def test_trigger_playlist_check(base_url): + """Tests the endpoint for manually triggering a check on a watched playlist.""" + # First, add the playlist to the watch list + requests.put(f"{base_url}/playlist/watch/{SPOTIFY_PLAYLIST_ID}") + + # Trigger the check + response = requests.post(f"{base_url}/playlist/watch/trigger_check/{SPOTIFY_PLAYLIST_ID}") + assert response.status_code == 200 + assert "Check triggered for playlist" in response.json()["message"] + + # A full verification would require inspecting the database or new tasks, + # but for an API test, confirming the trigger endpoint responds correctly is the key goal. + print("Playlist check triggered. Note: This does not verify new downloads were queued.") + +def test_trigger_artist_check(base_url): + """Tests the endpoint for manually triggering a check on a watched artist.""" + # First, add the artist to the watch list + requests.put(f"{base_url}/artist/watch/{SPOTIFY_ARTIST_ID}") + + # Trigger the check + response = requests.post(f"{base_url}/artist/watch/trigger_check/{SPOTIFY_ARTIST_ID}") + assert response.status_code == 200 + assert "Check triggered for artist" in response.json()["message"] + print("Artist check triggered. Note: This does not verify new downloads were queued.") + +def test_remove_playlist_from_watch(base_url): + """Tests removing a playlist from the watch list.""" + # Add the playlist first to ensure it exists + requests.put(f"{base_url}/playlist/watch/{SPOTIFY_PLAYLIST_ID}") + + # Now, remove it + response = requests.delete(f"{base_url}/playlist/watch/{SPOTIFY_PLAYLIST_ID}") + assert response.status_code == 200 + assert "Playlist removed from watch list" in response.json()["message"] + + # Verify it's no longer in the list + response = requests.get(f"{base_url}/playlist/watch/list") + assert response.status_code == 200 + watched_playlists = response.json() + assert not any(p['spotify_id'] == SPOTIFY_PLAYLIST_ID for p in watched_playlists) + +def test_remove_artist_from_watch(base_url): + """Tests removing an artist from the watch list.""" + # Add the artist first to ensure it exists + requests.put(f"{base_url}/artist/watch/{SPOTIFY_ARTIST_ID}") + + # Now, remove it + response = requests.delete(f"{base_url}/artist/watch/{SPOTIFY_ARTIST_ID}") + assert response.status_code == 200 + assert "Artist removed from watch list" in response.json()["message"] + + # Verify it's no longer in the list + response = requests.get(f"{base_url}/artist/watch/list") + assert response.status_code == 200 + watched_artists = response.json() + assert not any(a['spotify_id'] == SPOTIFY_ARTIST_ID for a in watched_artists) \ No newline at end of file