From 1a39af37304a016b805ced626c07d6460bafbbc9 Mon Sep 17 00:00:00 2001 From: "cool.gitter.not.me.again.duh" Date: Thu, 29 May 2025 18:10:37 -0600 Subject: [PATCH] I think we good --- app.py | 6 + requirements.txt | 1 + routes/__init__.py | 2 +- routes/album.py | 33 +- routes/artist.py | 6 +- routes/history.py | 42 +++ routes/playlist.py | 36 ++- routes/prgs.py | 84 ++++- routes/track.py | 33 +- routes/utils/celery_manager.py | 74 ++++- routes/utils/celery_tasks.py | 218 +++++++++---- routes/utils/get_info.py | 12 +- routes/utils/history_manager.py | 235 ++++++++++++++ routes/utils/watch/manager.py | 2 +- src/js/history.ts | 160 ++++++++++ src/js/queue.ts | 540 +++++++++++++------------------- static/css/config/config.css | 30 -- static/css/history/history.css | 121 +++++++ static/css/main/base.css | 79 ++--- static/css/main/icons.css | 41 +++ static/html/album.html | 3 + static/html/artist.html | 3 + static/html/config.html | 3 + static/html/history.html | 83 +++++ static/html/main.html | 3 + static/html/playlist.html | 3 + static/html/track.html | 3 + static/html/watch.html | 3 + static/images/history.svg | 4 + static/images/info.svg | 7 + 30 files changed, 1374 insertions(+), 496 deletions(-) create mode 100644 routes/history.py create mode 100644 routes/utils/history_manager.py create mode 100644 src/js/history.ts create mode 100644 static/css/history/history.css create mode 100644 static/html/history.html create mode 100644 static/images/history.svg create mode 100644 static/images/info.svg diff --git a/app.py b/app.py index 3bf61aa..5b3ccaf 100755 --- a/app.py +++ b/app.py @@ -8,6 +8,7 @@ from routes.playlist import playlist_bp from routes.prgs import prgs_bp from routes.config import config_bp from routes.artist import artist_bp +from routes.history import history_bp import logging import logging.handlers import time @@ -149,6 +150,7 @@ def create_app(): app.register_blueprint(playlist_bp, url_prefix='/api/playlist') app.register_blueprint(artist_bp, url_prefix='/api/artist') app.register_blueprint(prgs_bp, url_prefix='/api/prgs') + app.register_blueprint(history_bp, url_prefix='/api/history') # Serve frontend @app.route('/') @@ -186,6 +188,10 @@ def create_app(): # The id parameter is captured, but you can use it as needed. return render_template('artist.html') + @app.route('/history') + def serve_history_page(): + return render_template('history.html') + @app.route('/static/') def serve_static(path): return send_from_directory('static', path) diff --git a/requirements.txt b/requirements.txt index 5cae1d0..88789c3 100755 --- a/requirements.txt +++ b/requirements.txt @@ -42,6 +42,7 @@ six==1.17.0 sniffio==1.3.1 spotipy==2.25.1 spotipy_anon==1.4 +sse-starlette==2.3.5 starlette==0.46.2 tqdm==4.67.1 typing-inspection==0.4.1 diff --git a/routes/__init__.py b/routes/__init__.py index 9bde965..d4013a1 100755 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -3,7 +3,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.DEBUG, +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) diff --git a/routes/album.py b/routes/album.py index e2cf496..8a3df33 100755 --- a/routes/album.py +++ b/routes/album.py @@ -6,18 +6,39 @@ import uuid import time from routes.utils.celery_queue_manager import download_queue_manager from routes.utils.celery_tasks import store_task_info, store_task_status, ProgressState +from routes.utils.get_info import get_spotify_info album_bp = Blueprint('album', __name__) @album_bp.route('/download/', methods=['GET']) def handle_download(album_id): # Retrieve essential parameters from the request. - name = request.args.get('name') - artist = request.args.get('artist') + # name = request.args.get('name') + # artist = request.args.get('artist') # Construct the URL from album_id url = f"https://open.spotify.com/album/{album_id}" + # Fetch metadata from Spotify + try: + album_info = get_spotify_info(album_id, "album") + if not album_info or not album_info.get('name') or not album_info.get('artists'): + return Response( + json.dumps({"error": f"Could not retrieve metadata for album ID: {album_id}"}), + status=404, + mimetype='application/json' + ) + + name_from_spotify = album_info.get('name') + artist_from_spotify = album_info['artists'][0].get('name') if album_info['artists'] else "Unknown Artist" + + except Exception as e: + return Response( + json.dumps({"error": f"Failed to fetch metadata for album {album_id}: {str(e)}"}), + status=500, + mimetype='application/json' + ) + # Validate required parameters if not url: return Response( @@ -35,8 +56,8 @@ def handle_download(album_id): task_id = download_queue_manager.add_task({ "download_type": "album", "url": url, - "name": name, - "artist": artist, + "name": name_from_spotify, + "artist": artist_from_spotify, "orig_request": orig_params }) except Exception as e: @@ -47,8 +68,8 @@ def handle_download(album_id): store_task_info(error_task_id, { "download_type": "album", "url": url, - "name": name, - "artist": artist, + "name": name_from_spotify, + "artist": artist_from_spotify, "original_request": orig_params, "created_at": time.time(), "is_submission_error_task": True diff --git a/routes/artist.py b/routes/artist.py index f811250..d6f0b5d 100644 --- a/routes/artist.py +++ b/routes/artist.py @@ -124,7 +124,7 @@ def get_artist_info(): try: from routes.utils.get_info import get_spotify_info - artist_info = get_spotify_info(spotify_id, "artist") + artist_info = get_spotify_info(spotify_id, "artist_discography") # If artist_info is successfully fetched (it contains album items), # check if the artist is watched and augment album items with is_locally_known status @@ -166,11 +166,11 @@ def add_artist_to_watchlist(artist_spotify_id): return jsonify({"message": f"Artist {artist_spotify_id} is already being watched."}), 200 # This call returns an album list-like structure based on logs - artist_album_list_data = get_spotify_info(artist_spotify_id, "artist") + artist_album_list_data = get_spotify_info(artist_spotify_id, "artist_discography") # Check if we got any data and if it has items if not artist_album_list_data or not isinstance(artist_album_list_data.get('items'), list): - logger.error(f"Could not fetch album list details for artist {artist_spotify_id} from Spotify using get_spotify_info('artist'). Data: {artist_album_list_data}") + logger.error(f"Could not fetch album list details for artist {artist_spotify_id} from Spotify using get_spotify_info('artist_discography'). Data: {artist_album_list_data}") return jsonify({"error": f"Could not fetch sufficient details for artist {artist_spotify_id} to initiate watch."}), 404 # Attempt to extract artist name and verify ID diff --git a/routes/history.py b/routes/history.py new file mode 100644 index 0000000..69ec7f8 --- /dev/null +++ b/routes/history.py @@ -0,0 +1,42 @@ +from flask import Blueprint, jsonify, request +from routes.utils.history_manager import get_history_entries +import logging + +logger = logging.getLogger(__name__) +history_bp = Blueprint('history', __name__, url_prefix='/api/history') + +@history_bp.route('', methods=['GET']) +def get_download_history(): + """API endpoint to retrieve download history with pagination, sorting, and filtering.""" + try: + limit = request.args.get('limit', 25, type=int) + offset = request.args.get('offset', 0, type=int) + 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 + filters = {} + status_filter = request.args.get('status_final') + if status_filter: + filters['status_final'] = status_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 + + entries, total_count = get_history_entries(limit, offset, sort_by, sort_order, filters) + + return jsonify({ + 'entries': entries, + 'total_count': total_count, + 'limit': limit, + 'offset': offset + }) + 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 \ No newline at end of file diff --git a/routes/playlist.py b/routes/playlist.py index 9cb6f09..2f53fb1 100755 --- a/routes/playlist.py +++ b/routes/playlist.py @@ -28,13 +28,35 @@ playlist_bp = Blueprint('playlist', __name__, url_prefix='/api/playlist') @playlist_bp.route('/download/', methods=['GET']) def handle_download(playlist_id): # Retrieve essential parameters from the request. - name = request.args.get('name') - artist = request.args.get('artist') + # name = request.args.get('name') # Removed + # artist = request.args.get('artist') # Removed orig_params = request.args.to_dict() # Construct the URL from playlist_id url = f"https://open.spotify.com/playlist/{playlist_id}" - orig_params["original_url"] = url # Update original_url to the constructed one + orig_params["original_url"] = request.url # Update original_url to the constructed one + + # Fetch metadata from Spotify + try: + playlist_info = get_spotify_info(playlist_id, "playlist") + if not playlist_info or not playlist_info.get('name') or not playlist_info.get('owner'): + return Response( + json.dumps({"error": f"Could not retrieve metadata for playlist ID: {playlist_id}"}), + status=404, + mimetype='application/json' + ) + + name_from_spotify = playlist_info.get('name') + # Use owner's display_name as the 'artist' for playlists + owner_info = playlist_info.get('owner', {}) + artist_from_spotify = owner_info.get('display_name', "Unknown Owner") + + except Exception as e: + return Response( + json.dumps({"error": f"Failed to fetch metadata for playlist {playlist_id}: {str(e)}"}), + status=500, + mimetype='application/json' + ) # Validate required parameters if not url: # This check might be redundant now but kept for safety @@ -48,8 +70,8 @@ def handle_download(playlist_id): task_id = download_queue_manager.add_task({ "download_type": "playlist", "url": url, - "name": name, - "artist": artist, + "name": name_from_spotify, # Use fetched name + "artist": artist_from_spotify, # Use fetched owner name as artist "orig_request": orig_params }) # Removed DuplicateDownloadError handling, add_task now manages this by creating an error task. @@ -59,8 +81,8 @@ def handle_download(playlist_id): store_task_info(error_task_id, { "download_type": "playlist", "url": url, - "name": name, - "artist": artist, + "name": name_from_spotify, # Use fetched name + "artist": artist_from_spotify, # Use fetched owner name as artist "original_request": orig_params, "created_at": time.time(), "is_submission_error_task": True diff --git a/routes/prgs.py b/routes/prgs.py index 6190622..330e0b0 100755 --- a/routes/prgs.py +++ b/routes/prgs.py @@ -1,4 +1,4 @@ -from flask import Blueprint, abort, jsonify, Response, stream_with_context +from flask import Blueprint, abort, jsonify, Response, stream_with_context, request import os import json import logging @@ -38,11 +38,38 @@ def get_prg_file(task_id): task_info = get_task_info(task_id) if not task_info: abort(404, "Task not found") - original_request = task_info.get("original_request", {}) + + # Dynamically construct original_url + dynamic_original_url = "" + download_type = task_info.get("download_type") + # The 'url' field in task_info stores the Spotify/Deezer URL of the item + # e.g., https://open.spotify.com/album/albumId or https://www.deezer.com/track/trackId + item_url = task_info.get("url") + + if download_type and item_url: + try: + # Extract the ID from the item_url (last part of the path) + item_id = item_url.split('/')[-1] + if item_id: # Ensure item_id is not empty + base_url = request.host_url.rstrip('/') + dynamic_original_url = f"{base_url}/api/{download_type}/download/{item_id}" + else: + logger.warning(f"Could not extract item ID from URL: {item_url} for task {task_id}. Falling back for original_url.") + original_request_obj = task_info.get("original_request", {}) + dynamic_original_url = original_request_obj.get("original_url", "") + except Exception as e: + logger.error(f"Error constructing dynamic original_url for task {task_id}: {e}", exc_info=True) + original_request_obj = task_info.get("original_request", {}) + dynamic_original_url = original_request_obj.get("original_url", "") # Fallback on any error + else: + logger.warning(f"Missing download_type ('{download_type}') or item_url ('{item_url}') in task_info for task {task_id}. Falling back for original_url.") + original_request_obj = task_info.get("original_request", {}) + dynamic_original_url = original_request_obj.get("original_url", "") + last_status = get_last_task_status(task_id) status_count = len(get_task_status(task_id)) response = { - "original_url": original_request.get("original_url", ""), + "original_url": dynamic_original_url, "last_line": last_status, "timestamp": time.time(), "task_id": task_id, @@ -75,12 +102,53 @@ def delete_prg_file(task_id): def list_prg_files(): """ Retrieve a list of all tasks in the system. - Combines results from both the old PRG file system and the new task ID based system. + Returns a detailed list of task objects including status and metadata. """ - # List only new system tasks - tasks = get_all_tasks() - task_ids = [task["task_id"] for task in tasks] - return jsonify(task_ids) + try: + tasks = get_all_tasks() # This already gets summary data + detailed_tasks = [] + for task_summary in tasks: + task_id = task_summary.get("task_id") + if not task_id: + continue + + task_info = get_task_info(task_id) + 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)) + }) + elif task_info: # If last_status is somehow missing, still provide some info + detailed_tasks.append({ + "task_id": task_id, + "type": task_info.get("type", "unknown"), + "name": task_info.get("name", "Unknown"), + "artist": task_info.get("artist", ""), + "download_type": task_info.get("download_type", "unknown"), + "status": "unknown", + "last_status_obj": None, + "original_request": task_info.get("original_request", {}), + "created_at": task_info.get("created_at", 0), + "timestamp": task_info.get("created_at", 0) + }) + + # Sort tasks by creation time (newest first, or by timestamp if creation time is missing) + detailed_tasks.sort(key=lambda x: x.get('timestamp', x.get('created_at', 0)), reverse=True) + + return jsonify(detailed_tasks) + except Exception as e: + logger.error(f"Error in /api/prgs/list: {e}", exc_info=True) + return jsonify({"error": "Failed to retrieve task list"}), 500 @prgs_bp.route('/retry/', methods=['POST']) diff --git a/routes/track.py b/routes/track.py index b48be7b..f1902b9 100755 --- a/routes/track.py +++ b/routes/track.py @@ -7,19 +7,40 @@ import time # For timestamps from routes.utils.celery_queue_manager import download_queue_manager from routes.utils.celery_tasks import store_task_info, store_task_status, ProgressState # For error task creation from urllib.parse import urlparse # for URL validation +from routes.utils.get_info import get_spotify_info # Added import track_bp = Blueprint('track', __name__) @track_bp.route('/download/', methods=['GET']) def handle_download(track_id): # Retrieve essential parameters from the request. - name = request.args.get('name') - artist = request.args.get('artist') + # name = request.args.get('name') # Removed + # artist = request.args.get('artist') # Removed orig_params = request.args.to_dict() # Construct the URL from track_id url = f"https://open.spotify.com/track/{track_id}" orig_params["original_url"] = url # Update original_url to the constructed one + + # Fetch metadata from Spotify + try: + track_info = get_spotify_info(track_id, "track") + if not track_info or not track_info.get('name') or not track_info.get('artists'): + return Response( + json.dumps({"error": f"Could not retrieve metadata for track ID: {track_id}"}), + status=404, + mimetype='application/json' + ) + + name_from_spotify = track_info.get('name') + artist_from_spotify = track_info['artists'][0].get('name') if track_info['artists'] else "Unknown Artist" + + except Exception as e: + return Response( + json.dumps({"error": f"Failed to fetch metadata for track {track_id}: {str(e)}"}), + status=500, + mimetype='application/json' + ) # Validate required parameters if not url: @@ -42,8 +63,8 @@ def handle_download(track_id): task_id = download_queue_manager.add_task({ "download_type": "track", "url": url, - "name": name, - "artist": artist, + "name": name_from_spotify, # Use fetched name + "artist": artist_from_spotify, # Use fetched artist "orig_request": orig_params }) # Removed DuplicateDownloadError handling, add_task now manages this by creating an error task. @@ -53,8 +74,8 @@ def handle_download(track_id): store_task_info(error_task_id, { "download_type": "track", "url": url, - "name": name, - "artist": artist, + "name": name_from_spotify, # Use fetched name + "artist": artist_from_spotify, # Use fetched artist "original_request": orig_params, "created_at": time.time(), "is_submission_error_task": True diff --git a/routes/utils/celery_manager.py b/routes/utils/celery_manager.py index 59d5bba..8e3cb7f 100644 --- a/routes/utils/celery_manager.py +++ b/routes/utils/celery_manager.py @@ -18,9 +18,12 @@ from .celery_tasks import ( get_last_task_status, store_task_status, get_all_tasks as get_all_celery_tasks_info, - cleanup_stale_errors + cleanup_stale_errors, + delayed_delete_task_data ) from .celery_config import get_config_params +# Import history manager +from .history_manager import init_history_db # Configure logging logger = logging.getLogger(__name__) @@ -91,11 +94,73 @@ class CeleryManager: } store_task_status(task_id, error_payload) stale_tasks_count += 1 + + # Schedule deletion for this interrupted task + logger.info(f"Task {task_id} was interrupted. Data scheduled for deletion in 30s.") + delayed_delete_task_data.apply_async( + args=[task_id, "Task interrupted by application restart and auto-cleaned."], + countdown=30 + ) if stale_tasks_count > 0: logger.info(f"Marked {stale_tasks_count} stale tasks as 'error'.") else: - logger.info("No stale tasks found that needed cleanup.") + logger.info("No stale tasks found that needed cleanup (active states).") + + # NEW: Check for tasks that are already terminal but might have missed their cleanup + logger.info("Checking for terminal tasks (COMPLETE, CANCELLED, terminal ERROR) that might have missed cleanup...") + cleaned_during_this_pass = 0 + # `tasks` variable is from `get_all_celery_tasks_info()` called at the beginning of the method + for task_summary in tasks: + task_id = task_summary.get("task_id") + if not task_id: + continue + + last_status_data = get_last_task_status(task_id) + if last_status_data: + current_status_str = last_status_data.get("status") + task_info_details = get_task_info(task_id) # Get full info for download_type etc. + + cleanup_reason = "" + schedule_cleanup = False + + if current_status_str == ProgressState.COMPLETE: + # If a task is COMPLETE (any download_type) and still here, its original scheduled deletion was missed. + logger.warning(f"Task {task_id} ('{task_summary.get('name', 'Unknown')}', type: {task_info_details.get('download_type')}) is COMPLETE and still in Redis. Re-scheduling cleanup.") + cleanup_reason = f"Task ({task_info_details.get('download_type')}) was COMPLETE; re-scheduling auto-cleanup." + schedule_cleanup = True + elif current_status_str == ProgressState.CANCELLED: + logger.warning(f"Task {task_id} ('{task_summary.get('name', 'Unknown')}') is CANCELLED and still in Redis. Re-scheduling cleanup.") + cleanup_reason = "Task was CANCELLED; re-scheduling auto-cleanup." + schedule_cleanup = True + elif current_status_str == ProgressState.ERROR: + can_retry_flag = last_status_data.get("can_retry", False) + # is_submission_error_task and is_duplicate_error_task are flags on task_info, not typically on last_status + is_submission_error = task_info_details.get("is_submission_error_task", False) + is_duplicate_error = task_info_details.get("is_duplicate_error_task", False) + # Check if it's an error state that should have been cleaned up + if not can_retry_flag or is_submission_error or is_duplicate_error or last_status_data.get("status") == ProgressState.ERROR_RETRIED: + # ERROR_RETRIED means the original task is done and should be cleaned. + logger.warning(f"Task {task_id} ('{task_summary.get('name', 'Unknown')}') is in a terminal ERROR state ('{last_status_data.get('error')}') and still in Redis. Re-scheduling cleanup.") + cleanup_reason = f"Task was in terminal ERROR state ('{last_status_data.get('error', 'Unknown error')}'); re-scheduling auto-cleanup." + schedule_cleanup = True + elif current_status_str == ProgressState.ERROR_RETRIED: + # This state itself implies the task is terminal and its data can be cleaned. + logger.warning(f"Task {task_id} ('{task_summary.get('name', 'Unknown')}') is ERROR_RETRIED and still in Redis. Re-scheduling cleanup.") + cleanup_reason = "Task was ERROR_RETRIED; re-scheduling auto-cleanup." + schedule_cleanup = True + + if schedule_cleanup: + delayed_delete_task_data.apply_async( + args=[task_id, cleanup_reason], + countdown=30 # Schedule with 30s delay + ) + cleaned_during_this_pass +=1 + + if cleaned_during_this_pass > 0: + logger.info(f"Re-scheduled cleanup for {cleaned_during_this_pass} terminal tasks that were still in Redis.") + else: + logger.info("No additional terminal tasks found in Redis needing cleanup re-scheduling.") except Exception as e: logger.error(f"Error during stale task cleanup: {e}", exc_info=True) @@ -107,6 +172,9 @@ class CeleryManager: self.running = True + # Initialize history database + init_history_db() + # Clean up stale tasks BEFORE starting/restarting workers self._cleanup_stale_tasks() @@ -221,7 +289,7 @@ class CeleryManager: 'worker', '--loglevel=info', f'--concurrency={new_worker_count}', - '-Q', 'downloads', + '-Q', 'downloads,default', '--logfile=-', # Output logs to stdout '--without-heartbeat', # Reduce log noise '--without-gossip', # Reduce log noise diff --git a/routes/utils/celery_tasks.py b/routes/utils/celery_tasks.py index 9e2d77e..650878f 100644 --- a/routes/utils/celery_tasks.py +++ b/routes/utils/celery_tasks.py @@ -18,6 +18,9 @@ from routes.utils.celery_config import REDIS_URL, REDIS_BACKEND, REDIS_PASSWORD, # Import for playlist watch DB update from routes.utils.watch.db import add_single_track_to_playlist_db +# Import history manager function +from .history_manager import add_entry_to_history + # Initialize Celery app celery_app = Celery('download_tasks', broker=REDIS_URL, @@ -146,6 +149,50 @@ def get_task_info(task_id): logger.error(f"Error getting task info: {e}") return {} +# --- History Logging Helper --- +def _log_task_to_history(task_id, final_status_str, error_msg=None): + """Helper function to gather task data and log it to the history database.""" + try: + task_info = get_task_info(task_id) + last_status_obj = get_last_task_status(task_id) + + if not task_info: + logger.warning(f"History: No task_info found for task_id {task_id}. Cannot log to history.") + return + + # Extract Spotify ID from item URL if possible + spotify_id = None + item_url = task_info.get('url', '') + if item_url: + try: + spotify_id = item_url.split('/')[-1] + # Further validation if it looks like a Spotify ID (e.g., 22 chars, alphanumeric) + if not (spotify_id and len(spotify_id) == 22 and spotify_id.isalnum()): + spotify_id = None # Reset if not a valid-looking ID + except Exception: + spotify_id = None # Ignore errors in parsing + + history_entry = { + 'task_id': task_id, + 'download_type': task_info.get('download_type'), + 'item_name': task_info.get('name'), + 'item_artist': task_info.get('artist'), + 'item_album': task_info.get('album', task_info.get('name') if task_info.get('download_type') == 'album' else None), + 'item_url': item_url, + 'spotify_id': spotify_id, + 'status_final': final_status_str, + 'error_message': error_msg if error_msg else (last_status_obj.get('error') if last_status_obj else None), + 'timestamp_added': task_info.get('created_at', time.time()), + 'timestamp_completed': last_status_obj.get('timestamp', time.time()) if last_status_obj else time.time(), + 'original_request_json': json.dumps(task_info.get('original_request', {})), + 'last_status_obj_json': json.dumps(last_status_obj if last_status_obj else {}) + } + add_entry_to_history(history_entry) + 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 --- + def cancel_task(task_id): """Cancel a task by its ID""" try: @@ -159,7 +206,16 @@ def cancel_task(task_id): # Try to revoke the Celery task if it hasn't started yet celery_app.control.revoke(task_id, terminate=True, signal='SIGTERM') - logger.info(f"Task {task_id} cancelled by user") + # Log cancellation to history + _log_task_to_history(task_id, 'CANCELLED', "Task cancelled by user") + + # Schedule deletion of task data after 30 seconds + delayed_delete_task_data.apply_async( + args=[task_id, "Task cancelled by user and auto-cleaned."], + countdown=30 + ) + logger.info(f"Task {task_id} cancelled by user. Data scheduled for deletion in 30s.") + return {"status": "cancelled", "task_id": task_id} except Exception as e: logger.error(f"Error cancelling task {task_id}: {e}") @@ -440,17 +496,6 @@ class ProgressTrackingTask(Task): # Store the processed status update store_task_status(task_id, stored_data) - # Immediately delete task info from Redis after marking as complete - if stored_data.get("status") == ProgressState.COMPLETE: - logger.info(f"Task {task_id} completed. Deleting task data from Redis.") - try: - redis_client.delete(f"task:{task_id}:info") - redis_client.delete(f"task:{task_id}:status") - redis_client.delete(f"task:{task_id}:status:next_id") # Also delete the counter - logger.info(f"Successfully deleted Redis data for completed task {task_id}.") - except Exception as e: - logger.error(f"Error deleting Redis data for completed task {task_id}: {e}", exc_info=True) - def _handle_initializing(self, task_id, data, task_info): """Handle initializing status from deezspot""" # Extract relevant fields @@ -789,6 +834,11 @@ class ProgressTrackingTask(Task): # 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."], + countdown=30 # Delay in seconds + ) else: # Generic done for other types @@ -796,20 +846,6 @@ class ProgressTrackingTask(Task): data["status"] = ProgressState.COMPLETE data["message"] = "Download complete" - # Store the processed status update - store_task_status(task_id, data) - - # Immediately delete task info from Redis after marking as complete - if data.get("status") == ProgressState.COMPLETE: - logger.info(f"Task {task_id} ({task_info.get('name', 'Unknown')}) completed. Deleting task data from Redis.") - try: - redis_client.delete(f"task:{task_id}:info") - redis_client.delete(f"task:{task_id}:status") - redis_client.delete(f"task:{task_id}:status:next_id") # Also delete the counter - logger.info(f"Successfully deleted Redis data for completed task {task_id}.") - except Exception as e: - logger.error(f"Error deleting Redis data for completed task {task_id}: {e}", exc_info=True) - # Celery signal handlers @task_prerun.connect def task_prerun_handler(task_id=None, task=None, *args, **kwargs): @@ -834,25 +870,40 @@ def task_prerun_handler(task_id=None, task=None, *args, **kwargs): def task_postrun_handler(task_id=None, task=None, retval=None, state=None, *args, **kwargs): """Signal handler when a task finishes""" try: - # Skip if task is already marked as complete or error in Redis - last_status = get_last_task_status(task_id) - if last_status and last_status.get("status") in [ProgressState.COMPLETE, ProgressState.ERROR]: - return - - # Get task info + # Skip if task is already marked as complete or error in Redis for history logging purposes + last_status_for_history = get_last_task_status(task_id) + if last_status_for_history and last_status_for_history.get("status") in [ProgressState.COMPLETE, ProgressState.ERROR, ProgressState.CANCELLED, "ERROR_RETRIED", "ERROR_AUTO_CLEANED"]: + # Check if it was a REVOKED (cancelled) task, if so, ensure it's logged. + if state == states.REVOKED and last_status_for_history.get("status") != ProgressState.CANCELLED: + logger.info(f"Task {task_id} was REVOKED (likely cancelled), logging to history.") + _log_task_to_history(task_id, 'CANCELLED', "Task was revoked/cancelled.") + # else: + # logger.debug(f"History: Task {task_id} already in terminal state {last_status_for_history.get('status')} in Redis. History logging likely handled.") + # return # Do not return here, let the normal status update proceed for Redis if necessary + task_info = get_task_info(task_id) - + current_redis_status = last_status_for_history.get("status") if last_status_for_history else None + # Update task status based on Celery task state if state == states.SUCCESS: - 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 != 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." + }) logger.info(f"Task {task_id} completed successfully: {task_info.get('name', 'Unknown')}") + _log_task_to_history(task_id, 'COMPLETED') + + # If the task was a single track, schedule its data for deletion after a delay + if task_info.get("download_type") == "track": + delayed_delete_task_data.apply_async( + args=[task_id, "Task completed successfully and auto-cleaned."], + countdown=30 # Delay in seconds + ) # If from playlist_watch and successful, add track to DB original_request = task_info.get("original_request", {}) @@ -896,24 +947,34 @@ def task_failure_handler(task_id=None, exception=None, traceback=None, *args, ** # Check if we can retry can_retry = retry_count < max_retries - # Update task status to error - error_message_str = str(exception) - store_task_status(task_id, { - "status": ProgressState.ERROR, - "timestamp": time.time(), - "type": task_info.get("type", "unknown"), - "name": task_info.get("name", "Unknown"), - "artist": task_info.get("artist", ""), - "error": error_message_str, - "traceback": str(traceback), - "can_retry": can_retry, - "retry_count": retry_count, - "max_retries": max_retries - }) + # Update task status to error in Redis if not already an error + if last_status and last_status.get("status") != ProgressState.ERROR: + store_task_status(task_id, { + "status": ProgressState.ERROR, + "timestamp": time.time(), + "type": task_info.get("type", "unknown"), + "name": task_info.get("name", "Unknown"), + "artist": task_info.get("artist", ""), + "error": str(exception), + "traceback": str(traceback), + "can_retry": can_retry, + "retry_count": retry_count, + "max_retries": max_retries + }) - logger.error(f"Task {task_id} failed: {error_message_str}") + logger.error(f"Task {task_id} failed: {str(exception)}") + _log_task_to_history(task_id, 'ERROR', str(exception)) + if can_retry: logger.info(f"Task {task_id} can be retried ({retry_count}/{max_retries})") + else: + # If task cannot be retried, schedule its data for deletion + logger.info(f"Task {task_id} failed and cannot be retried. Data scheduled for deletion in 30s.") + delayed_delete_task_data.apply_async( + args=[task_id, f"Task failed ({str(exception)}) and max retries reached. Auto-cleaned."], + countdown=30 + ) + except Exception as e: logger.error(f"Error in task_failure_handler: {e}") @@ -1147,15 +1208,40 @@ def delete_task_data_and_log(task_id, reason="Task data deleted"): try: task_info = get_task_info(task_id) # Get info before deleting last_status = get_last_task_status(task_id) + current_status_val = last_status.get("status") if last_status else None - # Update status to cancelled if it's not already in a terminal state that implies deletion is okay - if not last_status or last_status.get("status") not in [ProgressState.CANCELLED, ProgressState.ERROR_RETRIED, ProgressState.ERROR_AUTO_CLEANED]: + # Determine the final status for Redis before deletion + # The reason passed to this function indicates why it's being deleted. + final_redis_status = ProgressState.ERROR_AUTO_CLEANED # Default for most cleanup scenarios + error_message_for_status = reason + + if reason == "Task completed successfully and auto-cleaned.": + final_redis_status = ProgressState.COMPLETE # It was already complete + error_message_for_status = "Task completed and auto-cleaned." + elif reason == "Task cancelled by user and auto-cleaned.": + final_redis_status = ProgressState.CANCELLED # It was already cancelled + error_message_for_status = "Task cancelled and auto-cleaned." + elif "Task failed" in reason and "max retries reached" in reason: + final_redis_status = ProgressState.ERROR # It was already an error (non-retryable) + error_message_for_status = reason + elif reason == "Task interrupted by application restart and auto-cleaned.": + final_redis_status = ProgressState.ERROR # It was marked as ERROR (interrupted) + error_message_for_status = reason + # Add more specific conditions if needed based on other reasons `delayed_delete_task_data` might be called with. + + # Update Redis status one last time if it's not already reflecting the final intended state for this cleanup. + # This is mainly for cases where cleanup is initiated for tasks not yet in a fully terminal state by other handlers. + if current_status_val not in [ProgressState.COMPLETE, ProgressState.CANCELLED, ProgressState.ERROR_RETRIED, ProgressState.ERROR_AUTO_CLEANED, final_redis_status]: store_task_status(task_id, { - "status": ProgressState.ERROR_AUTO_CLEANED, # Use specific status - "error": reason, + "status": final_redis_status, + "error": error_message_for_status, # Use the reason as the error/message for this status "timestamp": time.time() }) - + # History logging for COMPLETION, CANCELLATION, or definitive ERROR should have occurred when those states were first reached. + # If this cleanup is for a task that *wasn't* in such a state (e.g. stale, still processing), log it now. + if final_redis_status == ProgressState.ERROR_AUTO_CLEANED: + _log_task_to_history(task_id, 'ERROR', error_message_for_status) # Or a more specific status if desired + # Delete Redis keys associated with the task redis_client.delete(f"task:{task_id}:info") redis_client.delete(f"task:{task_id}:status") @@ -1204,4 +1290,12 @@ def cleanup_stale_errors(): return {"status": "complete", "cleaned_count": cleaned_count} except Exception as e: logger.error(f"Error during cleanup_stale_errors: {e}", exc_info=True) - return {"status": "error", "error": str(e)} \ No newline at end of file + return {"status": "error", "error": str(e)} + +@celery_app.task(name="delayed_delete_task_data", queue="default") # Use default queue for utility tasks +def delayed_delete_task_data(task_id, reason): + """ + Celery task to delete task data after a delay. + """ + logger.info(f"Executing delayed deletion for task {task_id}. Reason: {reason}") + delete_task_data_and_log(task_id, reason) \ No newline at end of file diff --git a/routes/utils/get_info.py b/routes/utils/get_info.py index 9b8b49c..da1133d 100644 --- a/routes/utils/get_info.py +++ b/routes/utils/get_info.py @@ -52,15 +52,17 @@ def get_spotify_info(spotify_id, spotify_type, limit=None, offset=None): return Spo.get_album(spotify_id) elif spotify_type == "playlist": return Spo.get_playlist(spotify_id) - elif spotify_type == "artist": + elif spotify_type == "artist_discography": if limit is not None and offset is not None: - return Spo.get_artist(spotify_id, limit=limit, offset=offset) + return Spo.get_artist_discography(spotify_id, limit=limit, offset=offset) elif limit is not None: - return Spo.get_artist(spotify_id, limit=limit) + return Spo.get_artist_discography(spotify_id, limit=limit) elif offset is not None: - return Spo.get_artist(spotify_id, offset=offset) + return Spo.get_artist_discography(spotify_id, offset=offset) else: - return Spo.get_artist(spotify_id) + return Spo.get_artist_discography(spotify_id) + elif spotify_type == "artist": + return Spo.get_artist(spotify_id) elif spotify_type == "episode": return Spo.get_episode(spotify_id) else: diff --git a/routes/utils/history_manager.py b/routes/utils/history_manager.py new file mode 100644 index 0000000..d3c55d7 --- /dev/null +++ b/routes/utils/history_manager.py @@ -0,0 +1,235 @@ +import sqlite3 +import json +import time +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + +HISTORY_DIR = Path('./data/history') +HISTORY_DB_FILE = HISTORY_DIR / 'download_history.db' + +def init_history_db(): + """Initializes the download history database and creates the table if it doesn't exist.""" + try: + HISTORY_DIR.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(HISTORY_DB_FILE) + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS download_history ( + task_id TEXT PRIMARY KEY, + download_type TEXT, + item_name TEXT, + item_artist TEXT, + item_album TEXT, + item_url TEXT, + spotify_id TEXT, + status_final TEXT, -- 'COMPLETED', 'ERROR', 'CANCELLED' + error_message TEXT, + timestamp_added REAL, + timestamp_completed REAL, + original_request_json TEXT, + last_status_obj_json TEXT + ) + """) + conn.commit() + logger.info(f"Download history database initialized at {HISTORY_DB_FILE}") + except sqlite3.Error as e: + logger.error(f"Error initializing download history database: {e}", exc_info=True) + finally: + if conn: + conn.close() + +def add_entry_to_history(history_data: dict): + """Adds or replaces an entry in the download_history table. + + Args: + history_data (dict): A dictionary containing the data for the history entry. + Expected keys match the table columns. + """ + required_keys = [ + 'task_id', 'download_type', 'item_name', 'item_artist', 'item_album', + 'item_url', 'spotify_id', 'status_final', 'error_message', + 'timestamp_added', 'timestamp_completed', 'original_request_json', + 'last_status_obj_json' + ] + # Ensure all keys are present, filling with None if not + for key in required_keys: + history_data.setdefault(key, None) + + conn = None + try: + conn = sqlite3.connect(HISTORY_DB_FILE) + cursor = conn.cursor() + cursor.execute(""" + INSERT OR REPLACE INTO download_history ( + task_id, download_type, item_name, item_artist, item_album, + item_url, spotify_id, status_final, error_message, + timestamp_added, timestamp_completed, original_request_json, + last_status_obj_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + history_data['task_id'], history_data['download_type'], history_data['item_name'], + history_data['item_artist'], history_data['item_album'], history_data['item_url'], + history_data['spotify_id'], history_data['status_final'], history_data['error_message'], + history_data['timestamp_added'], history_data['timestamp_completed'], + history_data['original_request_json'], history_data['last_status_obj_json'] + )) + conn.commit() + logger.info(f"Added/Updated history for task_id: {history_data['task_id']}, status: {history_data['status_final']}") + except sqlite3.Error as e: + logger.error(f"Error adding entry to download history for task_id {history_data.get('task_id')}: {e}", exc_info=True) + except Exception as e: + logger.error(f"Unexpected error adding to history for task_id {history_data.get('task_id')}: {e}", exc_info=True) + finally: + if conn: + conn.close() + +def get_history_entries(limit=25, offset=0, sort_by='timestamp_completed', sort_order='DESC', filters=None): + """Retrieves entries from the download_history table with pagination, sorting, and filtering. + + Args: + limit (int): Maximum number of entries to return. + offset (int): Number of entries to skip (for pagination). + sort_by (str): Column name to sort by. + sort_order (str): 'ASC' or 'DESC'. + filters (dict, optional): A dictionary of column_name: value to filter by. + Currently supports exact matches. + + Returns: + tuple: (list of history entries as dicts, total_count of matching entries) + """ + conn = None + try: + conn = sqlite3.connect(HISTORY_DB_FILE) + conn.row_factory = sqlite3.Row # Access columns by name + cursor = conn.cursor() + + base_query = "FROM download_history" + count_query = "SELECT COUNT(*) " + base_query + select_query = "SELECT * " + base_query + + where_clauses = [] + params = [] + + if filters: + 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) + + if where_clauses: + where_sql = " WHERE " + " AND ".join(where_clauses) + count_query += where_sql + select_query += where_sql + + # Get total count for pagination + cursor.execute(count_query, params) + total_count = cursor.fetchone()[0] + + # Validate sort_by and sort_order to prevent SQL injection + valid_sort_columns = [ + 'task_id', 'download_type', 'item_name', 'item_artist', 'item_album', + 'item_url', 'status_final', 'timestamp_added', 'timestamp_completed' + ] + if sort_by not in valid_sort_columns: + sort_by = 'timestamp_completed' # Default sort + + sort_order_upper = sort_order.upper() + if sort_order_upper not in ['ASC', 'DESC']: + sort_order_upper = 'DESC' + + select_query += f" ORDER BY {sort_by} {sort_order_upper} LIMIT ? OFFSET ?" + params.extend([limit, offset]) + + cursor.execute(select_query, params) + rows = cursor.fetchall() + + # Convert rows to list of dicts + entries = [dict(row) for row in rows] + return entries, total_count + + except sqlite3.Error as e: + logger.error(f"Error retrieving history entries: {e}", exc_info=True) + return [], 0 + finally: + if conn: + conn.close() + +if __name__ == '__main__': + # For testing purposes + logging.basicConfig(level=logging.INFO) + init_history_db() + + sample_data_complete = { + 'task_id': 'test_task_123', + 'download_type': 'track', + 'item_name': 'Test Song', + 'item_artist': 'Test Artist', + 'item_album': 'Test Album', + 'item_url': 'http://spotify.com/track/123', + 'spotify_id': '123', + 'status_final': 'COMPLETED', + 'error_message': None, + 'timestamp_added': time.time() - 3600, + 'timestamp_completed': time.time(), + 'original_request_json': json.dumps({'param1': 'value1'}), + 'last_status_obj_json': json.dumps({'status': 'complete', 'message': 'Finished!'}) + } + add_entry_to_history(sample_data_complete) + + sample_data_error = { + 'task_id': 'test_task_456', + 'download_type': 'album', + 'item_name': 'Another Album', + 'item_artist': 'Another Artist', + 'item_album': 'Another Album', # For albums, item_name and item_album are often the same + 'item_url': 'http://spotify.com/album/456', + 'spotify_id': '456', + 'status_final': 'ERROR', + 'error_message': 'Download failed due to network issue.', + 'timestamp_added': time.time() - 7200, + 'timestamp_completed': time.time() - 60, + 'original_request_json': json.dumps({'param2': 'value2'}), + 'last_status_obj_json': json.dumps({'status': 'error', 'error': 'Network issue'}) + } + add_entry_to_history(sample_data_error) + + # Test updating an entry + updated_data_complete = { + 'task_id': 'test_task_123', + 'download_type': 'track', + 'item_name': 'Test Song (Updated)', + 'item_artist': 'Test Artist', + 'item_album': 'Test Album II', + 'item_url': 'http://spotify.com/track/123', + 'spotify_id': '123', + 'status_final': 'COMPLETED', + 'error_message': None, + 'timestamp_added': time.time() - 3600, + 'timestamp_completed': time.time() + 100, # Updated completion time + 'original_request_json': json.dumps({'param1': 'value1', 'new_param': 'added'}), + 'last_status_obj_json': json.dumps({'status': 'complete', 'message': 'Finished! With update.'}) + } + add_entry_to_history(updated_data_complete) + + print(f"Test entries added/updated in {HISTORY_DB_FILE}") + + print("\nFetching all history entries (default sort):") + entries, total = get_history_entries(limit=5) + print(f"Total entries: {total}") + for entry in entries: + print(entry) + + print("\nFetching history entries (sorted by item_name ASC, limit 2, offset 1):") + entries_sorted, total_sorted = get_history_entries(limit=2, offset=1, sort_by='item_name', sort_order='ASC') + print(f"Total entries (should be same as above): {total_sorted}") + for entry in entries_sorted: + print(entry) + + print("\nFetching history entries with filter (status_final = COMPLETED):") + entries_filtered, total_filtered = get_history_entries(filters={'status_final': 'COMPLETED'}) + print(f"Total COMPLETED entries: {total_filtered}") + for entry in entries_filtered: + print(entry) \ No newline at end of file diff --git a/routes/utils/watch/manager.py b/routes/utils/watch/manager.py index 5ed25e9..560de77 100644 --- a/routes/utils/watch/manager.py +++ b/routes/utils/watch/manager.py @@ -231,7 +231,7 @@ def check_watched_artists(specific_artist_id: str = None): # The 'artist-albums' type for get_spotify_info needs to support pagination params. # And return a list of album objects. logger.debug(f"Artist Watch Manager: Fetching albums for {artist_spotify_id}. Limit: {limit}, Offset: {offset}") - artist_albums_page = get_spotify_info(artist_spotify_id, "artist", limit=limit, offset=offset) + artist_albums_page = get_spotify_info(artist_spotify_id, "artist_discography", limit=limit, offset=offset) if not artist_albums_page or not isinstance(artist_albums_page.get('items'), list): logger.warning(f"Artist Watch Manager: No album items found or invalid format for artist {artist_spotify_id} (name: '{artist_name}') at offset {offset}. Response: {artist_albums_page}") diff --git a/src/js/history.ts b/src/js/history.ts new file mode 100644 index 0000000..ed13c34 --- /dev/null +++ b/src/js/history.ts @@ -0,0 +1,160 @@ +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; + + let currentPage = 1; + let limit = 25; + let totalEntries = 0; + let currentSortBy = 'timestamp_completed'; + let currentSortOrder = 'DESC'; + + 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}`; + } + + 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(); + } 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(); + row.insertCell().textContent = entry.item_name || 'N/A'; + row.insertCell().textContent = entry.item_artist || 'N/A'; + row.insertCell().textContent = entry.download_type ? entry.download_type.charAt(0).toUpperCase() + entry.download_type.slice(1) : 'N/A'; + + const statusCell = row.insertCell(); + statusCell.textContent = entry.status_final || 'N/A'; + statusCell.className = `status-${entry.status_final}`; + + row.insertCell().textContent = entry.timestamp_added ? new Date(entry.timestamp_added * 1000).toLocaleString() : 'N/A'; + row.insertCell().textContent = entry.timestamp_completed ? new Date(entry.timestamp_completed * 1000).toLocaleString() : 'N/A'; + + const detailsCell = row.insertCell(); + const detailsButton = document.createElement('button'); + detailsButton.innerHTML = `Details`; + detailsButton.className = 'details-btn btn-icon'; + detailsButton.title = 'Show Details'; + detailsButton.onclick = () => showDetailsModal(entry); + detailsCell.appendChild(detailsButton); + + 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 = 7; // Span across all columns + newCell.appendChild(errorDetailsDiv); + // Visually, this new cell will be after the 'Details' button cell. + // To make it appear as part of the status cell or below the row, more complex DOM manipulation or CSS would be needed. + } + errorDetailsDiv.textContent = entry.error_message; + // Toggle display by directly manipulating the style of the details div + 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 showDetailsModal(entry: any) { + const details = `Task ID: ${entry.task_id}\n` + + `Type: ${entry.download_type}\n` + + `Name: ${entry.item_name}\n` + + `Artist: ${entry.item_artist}\n` + + `Album: ${entry.item_album || 'N/A'}\n` + + `URL: ${entry.item_url}\n` + + `Spotify ID: ${entry.spotify_id || 'N/A'}\n` + + `Status: ${entry.status_final}\n` + + `Error: ${entry.error_message || 'None'}\n` + + `Added: ${new Date(entry.timestamp_added * 1000).toLocaleString()}\n` + + `Completed/Ended: ${new Date(entry.timestamp_completed * 1000).toLocaleString()}\n\n` + + `Original Request: ${JSON.stringify(JSON.parse(entry.original_request_json || '{}'), null, 2)}\n\n` + + `Last Status Object: ${JSON.stringify(JSON.parse(entry.last_status_obj_json || '{}'), null, 2)}`; + alert(details); + } + + 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); + }); + }); + + 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)); + + // Initial fetch + fetchHistory(); +}); \ No newline at end of file diff --git a/src/js/queue.ts b/src/js/queue.ts index ab9fe2d..3f0f594 100644 --- a/src/js/queue.ts +++ b/src/js/queue.ts @@ -162,7 +162,6 @@ export class DownloadQueue { // Load the saved visible count (or default to 10) visibleCount: number; - globalSyncIntervalId: number | null = null; // For the new global sync constructor() { const storedVisibleCount = localStorage.getItem("downloadQueueVisibleCount"); @@ -203,10 +202,9 @@ export class DownloadQueue { // Wait for initDOM to complete before setting up event listeners and loading existing PRG files. this.initDOM().then(() => { this.initEventListeners(); - this.loadExistingPrgFiles().then(() => { // Ensure loadExistingPrgFiles completes - // Start global task list synchronization after initial load - this.startGlobalTaskSync(); - }); + this.loadExistingPrgFiles(); + // Start periodic sync + setInterval(() => this.periodicSyncWithServer(), 10000); // Sync every 10 seconds }); } @@ -391,18 +389,6 @@ export class DownloadQueue { * Adds a new download entry. */ addDownload(item: QueueItem, type: string, prgFile: string, requestUrl: string | null = null, startMonitoring: boolean = false): string { - // Check if an entry with this prgFile already exists - const existingQueueId = this.findQueueIdByPrgFile(prgFile); - if (existingQueueId) { - console.log(`addDownload: Entry for prgFile ${prgFile} already exists with queueId ${existingQueueId}. Ensuring monitoring.`); - const existingEntry = this.queueEntries[existingQueueId]; - if (existingEntry && !existingEntry.hasEnded && startMonitoring && !this.pollingIntervals[existingQueueId]) { - // If it exists, is not ended, needs monitoring, and isn't currently polled, start its individual polling. - this.startDownloadStatusMonitoring(existingQueueId); - } - return existingQueueId; // Return existing ID - } - const queueId = this.generateQueueId(); const entry = this.createQueueEntry(item, type, prgFile, queueId, requestUrl); this.queueEntries[queueId] = entry; @@ -988,16 +974,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) return index >= 0 && index < this.visibleCount; } - findQueueIdByPrgFile(prgFile: string): string | undefined { - for (const queueId in this.queueEntries) { - if (this.queueEntries[queueId].prgFile === prgFile) { - return queueId; - } - } - return undefined; - } - - async cleanupEntry(queueId: string /* Parameter deleteFromServer removed */) { + async cleanupEntry(queueId: string) { const entry = this.queueEntries[queueId]; if (entry) { // Close any polling interval @@ -1023,9 +1000,6 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); } - // The block for deleting from server has been removed. - // console.log(`Entry ${queueId} (${entry.prgFile}) cleaned up from UI and local cache.`); - // Update the queue display this.updateQueueOrder(); } @@ -1319,22 +1293,16 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) // Stop polling this.clearPollingInterval(queueId); - - const statusData = typeof progress === 'object' ? progress : entry.lastStatus; - - if (statusData && (statusData.status === 'complete' || statusData.status === 'done')) { - // For completed tasks, show for 2 seconds then remove from UI only - setTimeout(() => { - this.cleanupEntry(queueId); // Pass only queueId - }, 2000); - } else { - // For other terminal states (error, cancelled), use existing cleanup logic (default 10s) - // The server-side delete for these will be handled by backend mechanisms or specific cancel actions - const cleanupDelay = 10000; - setTimeout(() => { - this.cleanupEntry(queueId); // Pass only queueId - }, cleanupDelay); - } + + // Use 3 seconds cleanup delay for completed, 10 seconds for other terminal states like errors + const cleanupDelay = (progress && typeof progress !== 'number' && (progress.status === 'complete' || progress.status === 'done')) ? 3000 : + (progress && typeof progress !== 'number' && (progress.status === 'cancelled' || progress.status === 'cancel' || progress.status === 'skipped')) ? 20000 : + 10000; // Default for other errors if not caught by the more specific error handler delay + + // Clean up after the appropriate delay + setTimeout(() => { + this.cleanupEntry(queueId); + }, cleanupDelay); } handleInactivity(entry: QueueEntry, queueId: string, logElement: HTMLElement | null) { // Add types @@ -1519,9 +1487,9 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) // Prepare query parameters const queryParams = new URLSearchParams(); - // Add item.name and item.artist only if they are not empty or undefined - if (item.name && item.name.trim() !== '') queryParams.append('name', item.name); - if (item.artist && item.artist.trim() !== '') queryParams.append('artist', item.artist); + // 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) { @@ -1663,202 +1631,107 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) // Clear existing queue entries first to avoid duplicates when refreshing for (const queueId in this.queueEntries) { const entry = this.queueEntries[queueId]; - // Close any active connections this.clearPollingInterval(queueId); - - // Don't remove the entry from DOM - we'll rebuild it entirely delete this.queueEntries[queueId]; } + // Fetch detailed task list from the new endpoint const response = await fetch('/api/prgs/list'); - const prgFiles: string[] = await response.json(); // Add type - - // Sort filenames by the numeric portion (assumes format "type_number.prg"). - prgFiles.sort((a, b) => { - const numA = parseInt(a.split('_')[1]); - const numB = parseInt(b.split('_')[1]); - return numA - numB; - }); + 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 - // Iterate through each PRG file and add it as a dummy queue entry. - for (const prgFile of prgFiles) { - try { - const prgResponse = await fetch(`/api/prgs/${prgFile}`); - if (!prgResponse.ok) continue; - const prgData: StatusData = await prgResponse.json(); // Add type - - // Skip prg files that are marked as cancelled, completed, or interrupted - if (prgData.last_line && - (prgData.last_line.status === "cancel" || - prgData.last_line.status === "cancelled" || - prgData.last_line.status === "interrupted" || - prgData.last_line.status === "complete")) { - // Delete old completed or cancelled PRG files - try { - await fetch(`/api/prgs/delete/${prgFile}`, { method: 'DELETE' }); - console.log(`Cleaned up old PRG file: ${prgFile}`); - } catch (error) { - console.error(`Failed to delete completed/cancelled PRG file ${prgFile}:`, error); - } - continue; + const terminalStates = ['complete', 'done', 'cancelled', 'ERROR_AUTO_CLEANED', 'ERROR_RETRIED', 'cancel', 'interrupted', 'error']; + + for (const taskData of existingTasks) { + const prgFile = taskData.task_id; // Use task_id as prgFile 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 ${prgFile}, status: ${lastStatus.status}`); + // Also ensure it's cleaned from local cache if it was there + if (this.queueCache[prgFile]) { + delete this.queueCache[prgFile]; } - - // Check cached status - if we marked it cancelled locally, delete it and skip - const cachedStatus: StatusData | undefined = this.queueCache[prgFile]; // Add type - if (cachedStatus && - (cachedStatus.status === 'cancelled' || - cachedStatus.status === 'cancel' || - cachedStatus.status === 'interrupted' || - cachedStatus.status === 'complete')) { - try { - await fetch(`/api/prgs/delete/${prgFile}`, { method: 'DELETE' }); - console.log(`Cleaned up cached cancelled PRG file: ${prgFile}`); - } catch (error) { - console.error(`Failed to delete cached cancelled PRG file ${prgFile}:`, error); - } - continue; - } - - // Use the enhanced original request info from the first line - const originalRequest = prgData.original_request || {}; - let lastLineData: StatusData = prgData.last_line || {}; // Add type - - // First check if this is a track with a parent (part of an album/playlist) - let itemType = lastLineData.type || prgData.display_type || originalRequest.display_type || originalRequest.type || 'unknown'; - let dummyItem: QueueItem = {}; // Add type - - // If this is a track with a parent, treat it as the parent type for UI purposes - if (lastLineData.type === 'track' && lastLineData.parent) { - const parent = lastLineData.parent; - - if (parent.type === 'album') { - itemType = 'album'; - dummyItem = { - name: parent.title || 'Unknown Album', - artist: parent.artist || 'Unknown Artist', - type: 'album', - url: parent.url || '', - // Keep track of the current track info for progress display - current_track: lastLineData.current_track, - total_tracks: (typeof parent.total_tracks === 'string' ? parseInt(parent.total_tracks, 10) : parent.total_tracks) || (typeof lastLineData.total_tracks === 'string' ? parseInt(lastLineData.total_tracks, 10) : lastLineData.total_tracks) || 0, - // Store parent info directly in the item - 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 || '', - // Keep track of the current track info for progress display - current_track: lastLineData.current_track, - total_tracks: (typeof parent.total_tracks === 'string' ? parseInt(parent.total_tracks, 10) : parent.total_tracks) || (typeof lastLineData.total_tracks === 'string' ? parseInt(lastLineData.total_tracks, 10) : lastLineData.total_tracks) || 0, - // Store parent info directly in the item - parent: parent - }; - } - } else { - // Use the explicit display fields if available, or fall back to other fields + continue; + } + + let itemType = taskData.type || originalRequest.type || 'unknown'; + let dummyItem: QueueItem = { + name: taskData.name || originalRequest.name || prgFile, + 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: prgData.display_title || originalRequest.display_title || lastLineData.name || lastLineData.song || lastLineData.title || originalRequest.name || prgFile, - artist: prgData.display_artist || originalRequest.display_artist || lastLineData.artist || originalRequest.artist || '', - type: itemType, - url: originalRequest.url || lastLineData.url || '', - endpoint: originalRequest.endpoint || '', - download_type: originalRequest.download_type || '', - // Include any available track info - song: lastLineData.song, - title: lastLineData.title, - total_tracks: typeof lastLineData.total_tracks === 'string' ? parseInt(lastLineData.total_tracks, 10) : lastLineData.total_tracks, - current_track: lastLineData.current_track + 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 }; - }; - - // Check if this is a retry file and get the retry count - let retryCount = 0; - if (prgFile.includes('_retry')) { + } 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 (prgFile.includes('_retry')) { const retryMatch = prgFile.match(/_retry(\d+)/); if (retryMatch && retryMatch[1]) { retryCount = parseInt(retryMatch[1], 10); - } else if (prgData.last_line && prgData.last_line.retry_count) { - retryCount = prgData.last_line.retry_count; } - } else if (prgData.last_line && prgData.last_line.retry_count) { - retryCount = prgData.last_line.retry_count; - } - - // Build a potential requestUrl from the original information - let requestUrl: string | null = null; // Add type - if (dummyItem.endpoint && dummyItem.url) { - const params = new CustomURLSearchParams(); - params.append('url', dummyItem.url); - - if (dummyItem.name) params.append('name', dummyItem.name); - if (dummyItem.artist) params.append('artist', dummyItem.artist); - - // Add any other parameters from the original request - for (const [key, value] of Object.entries(originalRequest)) { - if (!['url', 'name', 'artist', 'type', 'endpoint', 'download_type', - 'display_title', 'display_type', 'display_artist', 'service'].includes(key)) { - params.append(key, value as string); // Cast value to string - } - } - - requestUrl = `${dummyItem.endpoint}?${params.toString()}`; - } - - // Add to download queue - const queueId = this.generateQueueId(); - const entry = this.createQueueEntry(dummyItem, itemType, prgFile, queueId, requestUrl); - entry.retryCount = retryCount; - - // Set the entry's last status from the PRG file - if (prgData.last_line) { - entry.lastStatus = prgData.last_line; - - // If this is a track that's part of an album/playlist - if (prgData.last_line.parent) { - entry.parentInfo = prgData.last_line.parent; - } - - // Make sure to save the status to the cache for persistence - this.queueCache[prgFile] = prgData.last_line; - - // Apply proper status classes - this.applyStatusClasses(entry, prgData.last_line); - - // Update log display with current info - const logElement = entry.element.querySelector('.log') as HTMLElement | null; - if (logElement) { - if (prgData.last_line.song && prgData.last_line.artist && - ['progress', 'real-time', 'real_time', 'processing', 'downloading'].includes(prgData.last_line.status || '')) { // Add null check - logElement.textContent = `Currently downloading: ${prgData.last_line.song} by ${prgData.last_line.artist}`; - } else if (entry.parentInfo && !['done', 'complete', 'error', 'skipped'].includes(prgData.last_line.status || '')) { - // Show parent info for non-terminal states - if (entry.parentInfo.type === 'album') { - logElement.textContent = `From album: "${entry.parentInfo.title}"`; - } else if (entry.parentInfo.type === 'playlist') { - logElement.textContent = `From playlist: "${entry.parentInfo.name}" by ${entry.parentInfo.owner}`; - } - } - } - } - - this.queueEntries[queueId] = entry; - } catch (error) { - console.error("Error fetching details for", prgFile, error); } + + 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, prgFile, queueId, requestUrl); + entry.retryCount = retryCount; + + if (lastStatus) { + entry.lastStatus = lastStatus; + if (lastStatus.parent) { + entry.parentInfo = lastStatus.parent; + } + this.queueCache[prgFile] = 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; } - // Save updated cache to localStorage localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); - - // After adding all entries, update the queue this.updateQueueOrder(); - - // Start monitoring for all active entries that are visible - // This is the key change to ensure continued status updates after page refresh this.startMonitoringActiveEntries(); } catch (error) { console.error("Error loading existing PRG files:", error); @@ -2028,32 +1901,27 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) if (data.last_line.status === 'cancelled' || data.last_line.status === 'cancel') { console.log('Cleaning up cancelled download immediately'); this.clearPollingInterval(queueId); - this.cleanupEntry(queueId); // Pass only queueId + this.cleanupEntry(queueId); return; // No need to process further } - // For completed tasks, start 2s UI timer - if (data.last_line.status === 'complete' || data.last_line.status === 'done') { - this.clearPollingInterval(queueId); + // 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(() => { - this.cleanupEntry(queueId); // Pass only queueId - }, 2000); - // Do not return here, allow UI to update to complete state first - } else { - // For other terminal states like 'error' - // Only set up cleanup if this is not an error that we're in the process of retrying - const isRetrying = entry.isRetrying || - (data.last_line.status === 'error' && - entry.element.querySelector('.log')?.textContent?.includes('Retry')); - - if (!isRetrying) { - // Errors will use the handleDownloadCompletion logic which has its own timeout - // this.handleDownloadCompletion(entry, queueId, data.last_line); - // No, we want to ensure polling stops here for errors too if not retrying - this.clearPollingInterval(queueId); - // Existing logic for error display and auto-cleanup (15s) is below - // and cleanupEntry for errors will be called from there or from handleDownloadCompletion - } + // 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 } } @@ -2247,7 +2115,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) const closeErrorBtn = errorLogElement.querySelector('.close-error-btn') as HTMLButtonElement | null; if (closeErrorBtn) { closeErrorBtn.addEventListener('click', () => { - this.cleanupEntry(queueId); // Pass only queueId + this.cleanupEntry(queueId); }); } @@ -2273,7 +2141,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) !currentEntryForCleanup.isRetrying) { this.cleanupEntry(queueId); } - }, 15000); + }, 20000); // Changed from 15000 to 20000 } else { // Error UI already exists, just update the message text if it's different if (errorMessageElement.textContent !== errMsg) { @@ -2286,21 +2154,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) // Handle terminal states for non-error cases if (['complete', 'cancel', 'cancelled', 'done', 'skipped'].includes(status)) { entry.hasEnded = true; - // this.handleDownloadCompletion(entry, queueId, statusData); // Already called from fetchDownloadStatus for terminal states - // We need to ensure the 2-second rule for 'complete'/'done' is applied here too, if not already handled - if (status === 'complete' || status === 'done') { - if (!this.pollingIntervals[queueId]) { // Check if polling was already cleared (meaning timeout started) - this.clearPollingInterval(queueId); - setTimeout(() => { - this.cleanupEntry(queueId); // Pass only queueId - }, 2000); - } - } else if (status === 'cancel' || status === 'cancelled' || status === 'skipped') { - // For cancelled or skipped, can cleanup sooner or use existing server delete logic - this.clearPollingInterval(queueId); - this.cleanupEntry(queueId); // Pass only queueId - } - // Errors are handled by their specific block below + this.handleDownloadCompletion(entry, queueId, statusData); } // Cache the status for potential page reloads @@ -2774,79 +2628,127 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) for (const queueId in this.pollingIntervals) { this.clearPollingInterval(queueId); } - if (this.globalSyncIntervalId !== null) { - clearInterval(this.globalSyncIntervalId as number); - this.globalSyncIntervalId = null; - console.log('Stopped global task sync polling.'); - } } - async syncWithBackendTaskList() { + /* 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('Failed to fetch backend task list:', response.status); + console.error("Periodic sync: Failed to fetch task list from server", response.status); return; } - const backendTaskIds: string[] = await response.json(); - const backendTaskIdSet = new Set(backendTaskIds); + const serverTasks: any[] = await response.json(); - // console.log('Backend task IDs:', backendTaskIds); - // console.log('Frontend task IDs (prgFiles):', Object.values(this.queueEntries).map(e => e.prgFile)); + const localTaskPrgFiles = new Set(Object.values(this.queueEntries).map(entry => entry.prgFile)); + const serverTaskPrgFiles = new Set(serverTasks.map(task => task.task_id)); - // 1. Add new tasks from backend that are not in frontend - for (const taskId of backendTaskIds) { - if (!this.findQueueIdByPrgFile(taskId)) { - console.log(`Sync: Task ${taskId} found in backend but not frontend. Fetching details.`); - try { - const taskDetailsResponse = await fetch(`/api/prgs/${taskId}`); - if (taskDetailsResponse.ok) { - const taskDetails: StatusData = await taskDetailsResponse.json(); - // Construct a minimal item for addDownload. The actual details will be filled by status updates. - const item: QueueItem = { - name: taskDetails.last_line?.name || taskDetails.last_line?.song || taskDetails.last_line?.title || taskDetails.original_request?.name || taskId, - artist: taskDetails.last_line?.artist || taskDetails.original_request?.artist || '', - type: taskDetails.last_line?.type || taskDetails.original_request?.type || 'unknown' - }; - const requestUrl = taskDetails.original_url || taskDetails.original_request?.url || null; - this.addDownload(item, item.type || 'unknown', taskId, requestUrl, true); // true to start monitoring - } else { - console.warn(`Sync: Failed to fetch details for new task ${taskId} from backend.`); + 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.prgFile === 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.prgFile === 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); } - } catch (fetchError) { - console.error(`Sync: Error fetching details for task ${taskId}:`, fetchError); } } } - // 2. Remove stale tasks from frontend that are not in backend active list - const frontendPrgFiles = Object.values(this.queueEntries).map(entry => entry.prgFile); - for (const prgFile of frontendPrgFiles) { - const queueId = this.findQueueIdByPrgFile(prgFile); - if (queueId && !backendTaskIdSet.has(prgFile)) { - const entry = this.queueEntries[queueId]; - // Only remove if it's not already considered ended by frontend (e.g., completed and timer running) - if (entry && !entry.hasEnded) { - console.log(`Sync: Task ${prgFile} (queueId: ${queueId}) found in frontend but not in backend active list. Removing.`); - this.cleanupEntry(queueId); + // 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.prgFile)) { + if (!localEntry.hasEnded) { + console.log(`Periodic sync: Local task ${localEntry.prgFile} not found on server. Assuming completed/cleaned. Removing.`); + this.cleanupEntry(localEntry.uniqueId); + } + } else { + const serverEquivalent = serverTasks.find(st => st.task_id === localEntry.prgFile); + if (serverEquivalent && serverEquivalent.last_status_obj && terminalStates.includes(serverEquivalent.last_status_obj.status)) { + if (!localEntry.hasEnded) { + console.log(`Periodic sync: Local task ${localEntry.prgFile} 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 global task sync:', error); + console.error("Error during periodic sync with server:", error); } } - - startGlobalTaskSync() { - if (this.globalSyncIntervalId !== null) { - clearInterval(this.globalSyncIntervalId as number); - } - this.syncWithBackendTaskList(); // Initial sync - this.globalSyncIntervalId = setInterval(() => { - this.syncWithBackendTaskList(); - }, 5000) as unknown as number; // Poll every 5 seconds - console.log('Started global task sync polling every 5 seconds.'); - } } // Singleton instance diff --git a/static/css/config/config.css b/static/css/config/config.css index 3d11079..f6df3b3 100644 --- a/static/css/config/config.css +++ b/static/css/config/config.css @@ -65,36 +65,6 @@ body { font-weight: bold; } -/* Back button as floating icon - keep this for our floating button */ -.back-button.floating-icon { - position: fixed; - width: 56px; - height: 56px; - bottom: 20px; - left: 20px; - background-color: var(--color-primary); - border-radius: 50%; - box-shadow: var(--shadow-lg); - z-index: 9999; - display: flex; - align-items: center; - justify-content: center; - transition: transform 0.2s ease, background-color 0.2s ease; - text-decoration: none !important; -} - -.back-button.floating-icon:hover { - background-color: var(--color-primary-hover); - transform: scale(1.05); -} - -.back-button.floating-icon img { - width: 24px; - height: 24px; - filter: brightness(0) invert(1); - margin: 0; -} - /* Queue Sidebar for Config Page */ #downloadQueue { position: fixed; diff --git a/static/css/history/history.css b/static/css/history/history.css new file mode 100644 index 0000000..ea4edba --- /dev/null +++ b/static/css/history/history.css @@ -0,0 +1,121 @@ +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; +} + +.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; +} + +.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; +} + +.status-COMPLETED { color: #1DB954; font-weight: bold; } +.status-ERROR { color: #FF4136; font-weight: bold; } +.status-CANCELLED { color: #AAAAAA; } + +.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 Details icon button in the table */ +.details-btn { + 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; +} + +.details-btn img { + width: 16px; /* Icon size */ + height: 16px; + filter: invert(1); /* Make icon white if it's dark, adjust if needed */ +} + +.details-btn:hover { + background-color: #333; /* Darker on hover */ +} \ No newline at end of file diff --git a/static/css/main/base.css b/static/css/main/base.css index 06037c3..12c2968 100644 --- a/static/css/main/base.css +++ b/static/css/main/base.css @@ -158,44 +158,36 @@ a:hover, a:focus { background-color: var(--color-surface-hover); } -/* Floating icons (queue and settings) */ +/* General styles for floating action buttons (FABs) */ .floating-icon { - position: fixed; - width: 56px; - height: 56px; - bottom: 20px; - background-color: var(--color-primary); - border-radius: var(--radius-round); - box-shadow: var(--shadow-lg); - z-index: 9999; - display: flex; - align-items: center; - justify-content: center; - transition: transform 0.2s ease, background-color 0.2s ease; - text-decoration: none !important; + position: fixed; + z-index: 1000; /* Base z-index, can be overridden */ + border-radius: 50%; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); + display: flex; + align-items: center; + justify-content: center; + width: 48px; /* Standard size */ + height: 48px; /* Standard size */ + background-color: #282828; /* Dark background */ + transition: background-color 0.3s ease, transform 0.2s ease; + text-decoration: none !important; /* Ensure no underline for tags */ +} + +.floating-icon:hover { + background-color: #333; /* Slightly lighter on hover */ + transform: scale(1.05); } -.floating-icon:hover, .floating-icon:active { - background-color: var(--color-primary-hover); - transform: scale(1.05); + transform: scale(0.98); } .floating-icon img { - width: 24px; - height: 24px; - filter: brightness(0) invert(1); - margin: 0; -} - -/* Settings icon - bottom left */ -.settings-icon { - left: 20px; -} - -/* Queue icon - bottom right */ -.queue-icon { - right: 20px; + width: 24px; + height: 24px; + filter: invert(1); /* White icon */ + margin: 0; /* Reset any margin if inherited */ } /* Home button */ @@ -221,20 +213,17 @@ a:hover, a:focus { transform: scale(0.98); } -/* When home button is used as a floating button */ -.floating-icon.home-btn { - background-color: var(--color-primary); - padding: 0; - display: flex; - align-items: center; - justify-content: center; -} - -.floating-icon.home-btn img { - width: 24px; - height: 24px; - filter: brightness(0) invert(1); - margin: 0; +/* Styles for buttons that are specifically floating icons (like home button when it's a FAB) */ +/* This ensures that if a .home-btn also has .floating-icon, it gets the correct FAB styling. */ +.home-btn.floating-icon, +.settings-icon.floating-icon, /* If settings button is an or diff --git a/static/html/artist.html b/static/html/artist.html index 6a31561..bc93f87 100644 --- a/static/html/artist.html +++ b/static/html/artist.html @@ -51,6 +51,9 @@ + + History + diff --git a/static/html/config.html b/static/html/config.html index 7616c60..8794495 100644 --- a/static/html/config.html +++ b/static/html/config.html @@ -317,6 +317,9 @@ + + History + Back diff --git a/static/html/history.html b/static/html/history.html new file mode 100644 index 0000000..6a42101 --- /dev/null +++ b/static/html/history.html @@ -0,0 +1,83 @@ + + + + + + Download History + + + + + + + + + +
+

Download History

+ +
+ + + + + +
+ + + + + + + + + + + + + + + + +
NameArtistTypeStatusDate AddedDate Completed/EndedDetails
+ +
+ + + + Home + + + + + + + + + \ No newline at end of file diff --git a/static/html/main.html b/static/html/main.html index ed3cec0..e9a5b66 100755 --- a/static/html/main.html +++ b/static/html/main.html @@ -59,6 +59,9 @@ + + History + Settings diff --git a/static/html/playlist.html b/static/html/playlist.html index de78ec9..de08696 100644 --- a/static/html/playlist.html +++ b/static/html/playlist.html @@ -58,6 +58,9 @@ + + History + diff --git a/static/html/track.html b/static/html/track.html index 3da8c44..4dbb8bd 100644 --- a/static/html/track.html +++ b/static/html/track.html @@ -45,6 +45,9 @@ + + History + diff --git a/static/html/watch.html b/static/html/watch.html index ec30a9f..6462734 100644 --- a/static/html/watch.html +++ b/static/html/watch.html @@ -43,6 +43,9 @@ + + History + Home diff --git a/static/images/history.svg b/static/images/history.svg new file mode 100644 index 0000000..98aeee1 --- /dev/null +++ b/static/images/history.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/static/images/info.svg b/static/images/info.svg new file mode 100644 index 0000000..bbdc061 --- /dev/null +++ b/static/images/info.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file