diff --git a/.dockerignore b/.dockerignore index c69773a..617bfc5 100755 --- a/.dockerignore +++ b/.dockerignore @@ -6,24 +6,10 @@ /Test.py /prgs/ /flask_server.log -/routes/__pycache__/ -routes/utils/__pycache__/ test.sh __pycache__/ -routes/__pycache__/__init__.cpython-312.pyc -routes/__pycache__/credentials.cpython-312.pyc -routes/__pycache__/search.cpython-312.pyc -routes/utils/__pycache__/__init__.cpython-312.pyc -routes/utils/__pycache__/credentials.cpython-312.pyc -routes/utils/__pycache__/search.cpython-312.pyc -routes/utils/__pycache__/__init__.cpython-312.pyc -routes/utils/__pycache__/credentials.cpython-312.pyc -routes/utils/__pycache__/search.cpython-312.pyc -routes/utils/__pycache__/credentials.cpython-312.pyc -routes/utils/__pycache__/search.cpython-312.pyc -routes/utils/__pycache__/__init__.cpython-312.pyc -routes/utils/__pycache__/credentials.cpython-312.pyc -routes/utils/__pycache__/search.cpython-312.pyc +routes/__pycache__/* +routes/utils/__pycache__/* search_test.py config/main.json .cache @@ -31,4 +17,10 @@ config/state/queue_state.json output.log queue_state.json search_demo.py -celery_worker.log \ No newline at end of file +celery_worker.log +static/js/* +logs/ +.env.example +.env +.venv +data diff --git a/.env b/.env.example similarity index 50% rename from .env rename to .env.example index 6cc087b..20ecd23 100644 --- a/.env +++ b/.env.example @@ -1,4 +1,4 @@ -# Docker Compose environment variables +# Docker Compose environment variables# Delete all comments of this when deploying (everything that is ) # Redis connection (external or internal) REDIS_HOST=redis @@ -6,12 +6,14 @@ REDIS_PORT=6379 REDIS_DB=0 REDIS_PASSWORD=CHANGE_ME -# Optional: Redis connection details -EXPLICIT_FILTER=false +# Set to true to filter out explicit content +EXPLICIT_FILTER=false # User ID for the container -PUID=1000 +PUID=1000 + # Group ID for the container -PGID=1000 -# Optional: Sets the default file permissions for newly created files within the container. -UMASK=0022 +PGID=1000 + +# Optional: Sets the default file permissions for newly created files within the container. +UMASK=0022 \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 575b0f0..79f6a27 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -12,11 +12,13 @@ A clear and concise description of what the bug is. **To Reproduce** Precise steps to reproduce the behavior (start from how you built your container): -1. Go to '...' -2. Click on '....' +1. Search for '...' +2. Download album/track/playlist 'https://open.spotify.com/...' 3. Scroll down to '....' 4. See error +*Note: Sometimes, an error is specific to an album, track or playlist, so preferrably share the specific url of the album you downloaded* + **Expected behavior** A clear and concise description of what you expected to happen. @@ -31,11 +33,20 @@ If applicable, add screenshots to help explain your problem. ``` Paste it here ``` + +**.env** +``` +Paste it here +``` + +**Config** + +- You can either share a screenshot of the config page or, preferably, the config file (should be under `./config/main.json`, depending on where you mapped it on your docker-compose.yaml) + **Logs** ``` Preferably, restart the app before reproducing so you can paste the logs from the bare beginning ``` -**Image** -Run -```docker container ls --format "{{.Names}}: {{.Image}}"``` and share the relevant output (e.g. spotizerr: cooldockerizer93/spotizerr:latest) \ No newline at end of file +**Version** +Go to config page and look for the version number \ No newline at end of file diff --git a/.gitignore b/.gitignore index f53d8b2..f8065d8 100755 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,8 @@ queue_state.json search_demo.py celery_worker.log logs/spotizerr.log -/.venv \ No newline at end of file +/.venv +static/js +data +logs/ +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index f53b86b..6d6a417 100755 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,8 @@ 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,9 +24,17 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy application code COPY . . +# Install TypeScript globally +RUN npm install -g typescript + +# Compile TypeScript +# tsc will use tsconfig.json from the current directory (/app) +# It will read from /app/src/js and output to /app/static/js +RUN tsc + # Create necessary directories with proper permissions -RUN mkdir -p downloads config creds logs && \ - chmod 777 downloads config creds logs +RUN mkdir -p downloads data/config data/creds data/watch data/history logs/tasks && \ + chmod -R 777 downloads data logs # Make entrypoint script executable RUN chmod +x entrypoint.sh diff --git a/README.md b/README.md index 99c05f4..93fa211 100755 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ mkdir spotizerr && cd spotizerr 3. Copy `docker-compose.yml` from this repo. 4. Create required directories: ```bash -mkdir -p creds config downloads logs cache +mkdir -p data/creds data/config data/watch data/history downloads logs/tasks .cache ``` 5. Launch containers: ```bash @@ -262,9 +262,14 @@ UMASK=0022 # Default file permission mask - API errors: Ensure your Spotify client ID and client secret are correctly entered **Log Locations**: -- Credentials: `./creds/` directory -- Downloads: `./downloads/` directory -- Application logs: `docker logs spotizerr` +- Application Logs: `docker logs spotizerr` (for main app and Celery workers) +- Individual Task Logs: `./logs/tasks/` (inside the container, maps to your volume) +- Credentials: `./data/creds/` +- Configuration Files: `./data/config/` +- Downloaded Music: `./downloads/` +- Watch Feature Database: `./data/watch/` +- Download History Database: `./data/history/` +- Spotify Token Cache: `./.cache/` (if `SPOTIPY_CACHE_PATH` is set to `/app/cache/.cache` and mapped) ## Notes diff --git a/app.py b/app.py index 2b87998..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 @@ -38,6 +39,10 @@ def setup_logging(): root_logger = logging.getLogger() root_logger.setLevel(logging.INFO) + # Clear any existing handlers from the root logger + if root_logger.hasHandlers(): + root_logger.handlers.clear() + # Log formatting log_format = logging.Formatter( '%(asctime)s [%(processName)s:%(threadName)s] [%(name)s] [%(levelname)s] - %(message)s', @@ -131,7 +136,7 @@ def check_redis_connection(): return False def create_app(): - app = Flask(__name__) + app = Flask(__name__, template_folder='static/html') # Set up CORS CORS(app) @@ -141,10 +146,11 @@ def create_app(): app.register_blueprint(search_bp, url_prefix='/api') app.register_blueprint(credentials_bp, url_prefix='/api/credentials') app.register_blueprint(album_bp, url_prefix='/api/album') - app.register_blueprint(track_bp, url_prefix='/api/track') + app.register_blueprint(track_bp, url_prefix='/api/track') 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(prgs_bp, url_prefix='/api/prgs') + app.register_blueprint(history_bp, url_prefix='/api/history') # Serve frontend @app.route('/') @@ -156,12 +162,17 @@ def create_app(): def serve_config(): return render_template('config.html') + # New route: Serve watch.html under /watchlist + @app.route('/watchlist') + def serve_watchlist(): + return render_template('watch.html') + # New route: Serve playlist.html under /playlist/ @app.route('/playlist/') def serve_playlist(id): # The id parameter is captured, but you can use it as needed. return render_template('playlist.html') - # New route: Serve playlist.html under /playlist/ + @app.route('/album/') def serve_album(id): # The id parameter is captured, but you can use it as needed. @@ -177,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) @@ -184,7 +199,7 @@ def create_app(): # Serve favicon.ico from the same directory as index.html (templates) @app.route('/favicon.ico') def serve_favicon(): - return send_from_directory('templates', 'favicon.ico') + return send_from_directory('static/html', 'favicon.ico') # Add request logging middleware @app.before_request @@ -230,6 +245,10 @@ if __name__ == '__main__': # Check Redis connection before starting workers if check_redis_connection(): + # Start Watch Manager + from routes.utils.watch.manager import start_watch_manager + start_watch_manager() + # Start Celery workers start_celery_workers() diff --git a/docker-compose.yaml b/docker-compose.yaml index 4e05d65..e554adf 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -3,13 +3,12 @@ name: spotizerr services: spotizerr: volumes: - - ./creds:/app/creds - - ./config:/app/config + - ./data:/app/data - ./downloads:/app/downloads # <-- Change this for your music library dir - ./logs:/app/logs # <-- Volume for persistent logs ports: - 7171:7171 - image: cooldockerizer93/spotizerr:latest + image: cooldockerizer93/spotizerr container_name: spotizerr-app restart: unless-stopped environment: diff --git a/requirements.txt b/requirements.txt index 8b596f7..30e3410 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,47 +1,61 @@ +amqp==5.3.1 annotated-types==0.7.0 -anyio==4.8.0 +anyio==4.9.0 +billiard==4.2.1 blinker==1.9.0 -certifi==2024.12.14 -charset-normalizer==3.4.1 -click==8.1.8 +celery==5.5.2 +certifi==2025.4.26 +charset-normalizer==3.4.2 +click==8.2.1 +click-didyoumean==0.3.1 +click-plugins==1.1.1 +click-repl==0.3.0 deezspot @ git+https://github.com/Xoconoch/deezspot-fork-again defusedxml==0.7.1 -fastapi==0.115.7 -Flask==3.1.0 -Flask-Cors==5.0.0 -h11==0.14.0 +fastapi==0.115.12 +Flask==3.1.1 +Flask-Celery-Helper==1.1.0 +flask-cors==6.0.0 +h11==0.16.0 httptools==0.6.4 idna==3.10 ifaddr==0.2.0 itsdangerous==2.2.0 -Jinja2==3.1.5 +Jinja2==3.1.6 +kombu==5.5.3 librespot==0.0.9 MarkupSafe==3.0.2 mutagen==1.47.0 +prompt_toolkit==3.0.51 protobuf==3.20.1 -pycryptodome==3.21.0 +pycryptodome==3.23.0 pycryptodomex==3.17 -pydantic==2.10.6 -pydantic_core==2.27.2 +pydantic==2.11.5 +pydantic_core==2.33.2 PyOgg==0.6.14a1 -python-dotenv==1.0.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.1.0 PyYAML==6.0.2 -redis==5.2.1 +redis==6.2.0 requests==2.30.0 +six==1.17.0 sniffio==1.3.1 -spotipy -spotipy_anon -starlette==0.45.3 +spotipy==2.25.1 +spotipy_anon==1.4 +sse-starlette==2.3.5 +starlette==0.46.2 tqdm==4.67.1 -typing_extensions==4.12.2 -urllib3==2.3.0 -uvicorn==0.34.0 +typing-inspection==0.4.1 +typing_extensions==4.13.2 +tzdata==2025.2 +urllib3==2.4.0 +uvicorn==0.34.2 uvloop==0.21.0 +vine==5.1.0 waitress==3.0.2 -watchfiles==1.0.4 +watchfiles==1.0.5 +wcwidth==0.2.13 websocket-client==1.5.1 -websockets==14.2 +websockets==15.0.1 Werkzeug==3.1.3 zeroconf==0.62.0 -celery==5.3.6 -flask-celery-helper==1.1.0 \ No newline at end of file diff --git a/routes/__init__.py b/routes/__init__.py index e69de29..d4013a1 100755 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -0,0 +1,24 @@ +import logging +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') + +logger = logging.getLogger(__name__) + +try: + from routes.utils.watch.manager import start_watch_manager, stop_watch_manager + # Start the playlist watch manager when the application/blueprint is initialized + start_watch_manager() + # Register the stop function to be called on application exit + atexit.register(stop_watch_manager) + logger.info("Playlist Watch Manager initialized and registered for shutdown.") +except ImportError as e: + logger.error(f"Could not import or start Playlist Watch Manager: {e}. Playlist watching will be disabled.") +except Exception as e: + logger.error(f"An unexpected error occurred during Playlist Watch Manager setup: {e}", exc_info=True) + +from .artist import artist_bp +from .prgs import prgs_bp diff --git a/routes/album.py b/routes/album.py index 7ec11e5..8a3df33 100755 --- a/routes/album.py +++ b/routes/album.py @@ -2,16 +2,42 @@ from flask import Blueprint, Response, request import json import os import traceback +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_bp.route('/download/', methods=['GET']) +def handle_download(album_id): # Retrieve essential parameters from the request. - url = request.args.get('url') - 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: @@ -26,13 +52,38 @@ def handle_download(): # Include full original request URL in metadata orig_params = request.args.to_dict() orig_params["original_url"] = request.url - task_id = download_queue_manager.add_task({ - "download_type": "album", - "url": url, - "name": name, - "artist": artist, - "orig_request": orig_params - }) + try: + task_id = download_queue_manager.add_task({ + "download_type": "album", + "url": url, + "name": name_from_spotify, + "artist": artist_from_spotify, + "orig_request": orig_params + }) + except Exception as e: + # Generic error handling for other issues during task submission + # Create an error task ID if add_task itself fails before returning an ID + error_task_id = str(uuid.uuid4()) + + store_task_info(error_task_id, { + "download_type": "album", + "url": url, + "name": name_from_spotify, + "artist": artist_from_spotify, + "original_request": orig_params, + "created_at": time.time(), + "is_submission_error_task": True + }) + store_task_status(error_task_id, { + "status": ProgressState.ERROR, + "error": f"Failed to queue album download: {str(e)}", + "timestamp": time.time() + }) + return Response( + json.dumps({"error": f"Failed to queue album download: {str(e)}", "task_id": error_task_id}), + status=500, + mimetype='application/json' + ) return Response( json.dumps({"prg_file": task_id}), diff --git a/routes/artist.py b/routes/artist.py index c679688..d585984 100644 --- a/routes/artist.py +++ b/routes/artist.py @@ -3,32 +3,53 @@ Artist endpoint blueprint. """ -from flask import Blueprint, Response, request +from flask import Blueprint, Response, request, jsonify import json import os import traceback from routes.utils.celery_queue_manager import download_queue_manager +from routes.utils.artist import download_artist_albums -artist_bp = Blueprint('artist', __name__) +# Imports for merged watch functionality +import logging +import threading +from routes.utils.watch.db import ( + add_artist_to_watch as add_artist_db, + remove_artist_from_watch as remove_artist_db, + get_watched_artist, + get_watched_artists, + add_specific_albums_to_artist_table, + remove_specific_albums_from_artist_table, + is_album_in_artist_db +) +from routes.utils.watch.manager import check_watched_artists +from routes.utils.get_info import get_spotify_info + +artist_bp = Blueprint('artist', __name__, url_prefix='/api/artist') + +# Existing log_json can be used, or a logger instance. +# Let's initialize a logger for consistency with merged code. +logger = logging.getLogger(__name__) def log_json(message_dict): print(json.dumps(message_dict)) -@artist_bp.route('/download', methods=['GET']) -def handle_artist_download(): +@artist_bp.route('/download/', methods=['GET']) +def handle_artist_download(artist_id): """ Enqueues album download tasks for the given artist. Expected query parameters: - - url: string (a Spotify artist URL) - album_type: string(s); comma-separated values such as "album,single,appears_on,compilation" """ + # Construct the artist URL from artist_id + url = f"https://open.spotify.com/artist/{artist_id}" + # Retrieve essential parameters from the request. - url = request.args.get('url') album_type = request.args.get('album_type', "album,single,compilation") # Validate required parameters - if not url: + if not url: # This check is mostly for safety, as url is constructed return Response( json.dumps({"error": "Missing required parameter: url"}), status=400, @@ -37,23 +58,28 @@ def handle_artist_download(): try: # Import and call the updated download_artist_albums() function. - from routes.utils.artist import download_artist_albums + # from routes.utils.artist import download_artist_albums # Already imported at top # Delegate to the download_artist_albums function which will handle album filtering - task_ids = download_artist_albums( + successfully_queued_albums, duplicate_albums = download_artist_albums( url=url, album_type=album_type, request_args=request.args.to_dict() ) # Return the list of album task IDs. + response_data = { + "status": "complete", + "message": f"Artist discography processing initiated. {len(successfully_queued_albums)} albums queued.", + "queued_albums": successfully_queued_albums + } + if duplicate_albums: + response_data["duplicate_albums"] = duplicate_albums + response_data["message"] += f" {len(duplicate_albums)} albums were already in progress or queued." + return Response( - json.dumps({ - "status": "complete", - "task_ids": task_ids, - "message": f"Artist discography queued – {len(task_ids)} album tasks have been queued." - }), - status=202, + json.dumps(response_data), + status=202, # Still 202 Accepted as some operations may have succeeded mimetype='application/json' ) except Exception as e: @@ -97,8 +123,22 @@ 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 + if artist_info and artist_info.get('items'): + watched_artist_details = get_watched_artist(spotify_id) # spotify_id is the artist ID + if watched_artist_details: # Artist is being watched + for album_item in artist_info['items']: + if album_item and album_item.get('id'): + album_id = album_item['id'] + album_item['is_locally_known'] = is_album_in_artist_db(spotify_id, album_id) + elif album_item: # Album object exists but no ID + album_item['is_locally_known'] = False + # If not watched, or no albums, is_locally_known will not be added. + # Frontend should handle absence of this key as false. + return Response( json.dumps(artist_info), status=200, @@ -113,3 +153,178 @@ def get_artist_info(): status=500, mimetype='application/json' ) + +# --- Merged Artist Watch Routes --- + +@artist_bp.route('/watch/', methods=['PUT']) +def add_artist_to_watchlist(artist_spotify_id): + """Adds an artist to the watchlist.""" + logger.info(f"Attempting to add artist {artist_spotify_id} to watchlist.") + try: + if get_watched_artist(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_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_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 + # The actual artist name might be consistently found in the items, if they exist + artist_name_from_albums = "Unknown Artist" # Default + if artist_album_list_data['items']: + first_album = artist_album_list_data['items'][0] + if first_album and isinstance(first_album.get('artists'), list) and first_album['artists']: + # Find the artist in the list that matches the artist_spotify_id + found_artist = next((art for art in first_album['artists'] if art.get('id') == artist_spotify_id), None) + if found_artist and found_artist.get('name'): + artist_name_from_albums = found_artist['name'] + elif first_album['artists'][0].get('name'): # Fallback to first artist if specific match not found or no ID + artist_name_from_albums = first_album['artists'][0]['name'] + logger.warning(f"Could not find exact artist ID {artist_spotify_id} in first album's artists list. Using name '{artist_name_from_albums}'.") + else: + logger.warning(f"No album items found for artist {artist_spotify_id} to extract name. Using default.") + + # Construct the artist_data object expected by add_artist_db + # We use the provided artist_spotify_id as the primary ID. + artist_data_for_db = { + "id": artist_spotify_id, # This is the crucial part + "name": artist_name_from_albums, + "albums": { # Mimic structure if add_artist_db expects it for total_albums + "total": artist_album_list_data.get('total', 0) + } + # Add any other fields add_artist_db might expect from a true artist object if necessary + } + + add_artist_db(artist_data_for_db) + + logger.info(f"Artist {artist_spotify_id} ('{artist_name_from_albums}') added to watchlist. Their albums will be processed by the watch manager.") + return jsonify({"message": f"Artist {artist_spotify_id} added to watchlist. Albums will be processed shortly."}), 201 + except Exception as e: + logger.error(f"Error adding artist {artist_spotify_id} to watchlist: {e}", exc_info=True) + return jsonify({"error": f"Could not add artist to watchlist: {str(e)}"}), 500 + +@artist_bp.route('/watch//status', methods=['GET']) +def get_artist_watch_status(artist_spotify_id): + """Checks if a specific artist is being watched.""" + logger.info(f"Checking watch status for artist {artist_spotify_id}.") + try: + artist = get_watched_artist(artist_spotify_id) + if artist: + return jsonify({"is_watched": True, "artist_data": dict(artist)}), 200 + else: + return jsonify({"is_watched": False}), 200 + except Exception as e: + logger.error(f"Error checking watch status for artist {artist_spotify_id}: {e}", exc_info=True) + return jsonify({"error": f"Could not check watch status: {str(e)}"}), 500 + +@artist_bp.route('/watch/', methods=['DELETE']) +def remove_artist_from_watchlist(artist_spotify_id): + """Removes an artist from the watchlist.""" + logger.info(f"Attempting to remove artist {artist_spotify_id} from watchlist.") + try: + if not get_watched_artist(artist_spotify_id): + return jsonify({"error": f"Artist {artist_spotify_id} not found in watchlist."}), 404 + + remove_artist_db(artist_spotify_id) + logger.info(f"Artist {artist_spotify_id} removed from watchlist successfully.") + return jsonify({"message": f"Artist {artist_spotify_id} removed from watchlist."}), 200 + except Exception as e: + logger.error(f"Error removing artist {artist_spotify_id} from watchlist: {e}", exc_info=True) + return jsonify({"error": f"Could not remove artist from watchlist: {str(e)}"}), 500 + +@artist_bp.route('/watch/list', methods=['GET']) +def list_watched_artists_endpoint(): + """Lists all artists currently in the watchlist.""" + try: + artists = get_watched_artists() + return jsonify([dict(artist) for artist in artists]), 200 + except Exception as e: + logger.error(f"Error listing watched artists: {e}", exc_info=True) + return jsonify({"error": f"Could not list watched artists: {str(e)}"}), 500 + +@artist_bp.route('/watch/trigger_check', methods=['POST']) +def trigger_artist_check_endpoint(): + """Manually triggers the artist checking mechanism for all watched artists.""" + logger.info("Manual trigger for artist check received for all artists.") + try: + thread = threading.Thread(target=check_watched_artists, args=(None,)) + thread.start() + return jsonify({"message": "Artist check triggered successfully in the background for all artists."}), 202 + except Exception as e: + logger.error(f"Error manually triggering artist check for all: {e}", exc_info=True) + return jsonify({"error": f"Could not trigger artist check for all: {str(e)}"}), 500 + +@artist_bp.route('/watch/trigger_check/', methods=['POST']) +def trigger_specific_artist_check_endpoint(artist_spotify_id: str): + """Manually triggers the artist checking mechanism for a specific artist.""" + logger.info(f"Manual trigger for specific artist check received for ID: {artist_spotify_id}") + try: + watched_artist = get_watched_artist(artist_spotify_id) + if not watched_artist: + logger.warning(f"Trigger specific check: Artist ID {artist_spotify_id} not found in watchlist.") + return jsonify({"error": f"Artist {artist_spotify_id} is not in the watchlist. Add it first."}), 404 + + thread = threading.Thread(target=check_watched_artists, args=(artist_spotify_id,)) + thread.start() + logger.info(f"Artist check triggered in background for specific artist ID: {artist_spotify_id}") + return jsonify({"message": f"Artist check triggered successfully in the background for {artist_spotify_id}."}), 202 + except Exception as e: + logger.error(f"Error manually triggering specific artist check for {artist_spotify_id}: {e}", exc_info=True) + return jsonify({"error": f"Could not trigger artist check for {artist_spotify_id}: {str(e)}"}), 500 + +@artist_bp.route('/watch//albums', methods=['POST']) +def mark_albums_as_known_for_artist(artist_spotify_id): + """Fetches details for given album IDs and adds/updates them in the artist's local DB table.""" + logger.info(f"Attempting to mark albums as known for artist {artist_spotify_id}.") + try: + album_ids = request.json + if not isinstance(album_ids, list) or not all(isinstance(aid, str) for aid in album_ids): + return jsonify({"error": "Invalid request body. Expecting a JSON array of album Spotify IDs."}), 400 + + if not get_watched_artist(artist_spotify_id): + return jsonify({"error": f"Artist {artist_spotify_id} is not being watched."}), 404 + + fetched_albums_details = [] + for album_id in album_ids: + try: + # We need full album details. get_spotify_info with type "album" should provide this. + album_detail = get_spotify_info(album_id, "album") + if album_detail and album_detail.get('id'): + fetched_albums_details.append(album_detail) + else: + logger.warning(f"Could not fetch details for album {album_id} when marking as known for artist {artist_spotify_id}.") + except Exception as e: + logger.error(f"Failed to fetch Spotify details for album {album_id}: {e}") + + if not fetched_albums_details: + return jsonify({"message": "No valid album details could be fetched to mark as known.", "processed_count": 0}), 200 + + processed_count = add_specific_albums_to_artist_table(artist_spotify_id, fetched_albums_details) + logger.info(f"Successfully marked/updated {processed_count} albums as known for artist {artist_spotify_id}.") + return jsonify({"message": f"Successfully processed {processed_count} albums for artist {artist_spotify_id}."}), 200 + except Exception as e: + logger.error(f"Error marking albums as known for artist {artist_spotify_id}: {e}", exc_info=True) + return jsonify({"error": f"Could not mark albums as known: {str(e)}"}), 500 + +@artist_bp.route('/watch//albums', methods=['DELETE']) +def mark_albums_as_missing_locally_for_artist(artist_spotify_id): + """Removes specified albums from the artist's local DB table.""" + logger.info(f"Attempting to mark albums as missing (delete locally) for artist {artist_spotify_id}.") + try: + album_ids = request.json + if not isinstance(album_ids, list) or not all(isinstance(aid, str) for aid in album_ids): + return jsonify({"error": "Invalid request body. Expecting a JSON array of album Spotify IDs."}), 400 + + if not get_watched_artist(artist_spotify_id): + return jsonify({"error": f"Artist {artist_spotify_id} is not being watched."}), 404 + + deleted_count = remove_specific_albums_from_artist_table(artist_spotify_id, album_ids) + logger.info(f"Successfully removed {deleted_count} albums locally for artist {artist_spotify_id}.") + return jsonify({"message": f"Successfully removed {deleted_count} albums locally for artist {artist_spotify_id}."}), 200 + except Exception as e: + logger.error(f"Error marking albums as missing (deleting locally) for artist {artist_spotify_id}: {e}", exc_info=True) + return jsonify({"error": f"Could not mark albums as missing: {str(e)}"}), 500 diff --git a/routes/config.py b/routes/config.py index de38a76..d363550 100644 --- a/routes/config.py +++ b/routes/config.py @@ -7,7 +7,8 @@ import time import os config_bp = Blueprint('config_bp', __name__) -CONFIG_PATH = Path('./config/main.json') +CONFIG_PATH = Path('./data/config/main.json') +CONFIG_PATH_WATCH = Path('./data/config/watch.json') # Flag for config change notifications config_changed = False @@ -63,6 +64,41 @@ def save_config(config_data): logging.error(f"Error saving config: {str(e)}") return False +def get_watch_config(): + """Reads watch.json and returns its content or defaults.""" + try: + if not CONFIG_PATH_WATCH.exists(): + CONFIG_PATH_WATCH.parent.mkdir(parents=True, exist_ok=True) + # Default watch config + defaults = { + 'enabled': False, + 'watchedArtistAlbumGroup': ["album", "single"], + 'watchPollIntervalSeconds': 3600 + } + CONFIG_PATH_WATCH.write_text(json.dumps(defaults, indent=2)) + return defaults + with open(CONFIG_PATH_WATCH, 'r') as f: + return json.load(f) + except Exception as e: + logging.error(f"Error reading watch config: {str(e)}") + # Return defaults on error to prevent crashes + return { + 'enabled': False, + 'watchedArtistAlbumGroup': ["album", "single"], + 'watchPollIntervalSeconds': 3600 + } + +def save_watch_config(watch_config_data): + """Saves data to watch.json.""" + try: + CONFIG_PATH_WATCH.parent.mkdir(parents=True, exist_ok=True) + with open(CONFIG_PATH_WATCH, 'w') as f: + json.dump(watch_config_data, f, indent=2) + return True + except Exception as e: + logging.error(f"Error saving watch config: {str(e)}") + return False + @config_bp.route('/config', methods=['GET']) def handle_config(): config = get_config() @@ -70,7 +106,7 @@ def handle_config(): return jsonify({"error": "Could not read config file"}), 500 # Create config/state directory - Path('./config/state').mkdir(parents=True, exist_ok=True) + Path('./data/config/state').mkdir(parents=True, exist_ok=True) # Set default values for any missing config options defaults = { @@ -116,7 +152,14 @@ def update_config(): if not save_config(new_config): return jsonify({"error": "Failed to save config"}), 500 - return jsonify({"message": "Config updated successfully"}) + # Return the updated config + updated_config_values = get_config() + if updated_config_values is None: + # This case should ideally not be reached if save_config succeeded + # and get_config handles errors by returning a default or None. + return jsonify({"error": "Failed to retrieve configuration after saving"}), 500 + + return jsonify(updated_config_values) except json.JSONDecodeError: return jsonify({"error": "Invalid JSON data"}), 400 except Exception as e: @@ -141,4 +184,40 @@ def check_config_changes(): return jsonify({ "changed": has_changed, "last_config": last_config - }) \ No newline at end of file + }) + +@config_bp.route('/config/watch', methods=['GET']) +def handle_watch_config(): + watch_config = get_watch_config() + # Ensure defaults are applied if file was corrupted or missing fields + defaults = { + 'enabled': False, + 'watchedArtistAlbumGroup': ["album", "single"], + 'watchPollIntervalSeconds': 3600 + } + for key, default_value in defaults.items(): + if key not in watch_config: + watch_config[key] = default_value + + return jsonify(watch_config) + +@config_bp.route('/config/watch', methods=['POST', 'PUT']) +def update_watch_config(): + try: + new_watch_config = request.get_json() + if not isinstance(new_watch_config, dict): + return jsonify({"error": "Invalid watch config format"}), 400 + + if not save_watch_config(new_watch_config): + return jsonify({"error": "Failed to save watch config"}), 500 + + updated_watch_config_values = get_watch_config() + if updated_watch_config_values is None: + return jsonify({"error": "Failed to retrieve watch configuration after saving"}), 500 + + return jsonify(updated_watch_config_values) + except json.JSONDecodeError: + return jsonify({"error": "Invalid JSON data for watch config"}), 400 + except Exception as e: + logging.error(f"Error updating watch config: {str(e)}") + return jsonify({"error": "Failed to update watch config"}), 500 \ No newline at end of file diff --git a/routes/credentials.py b/routes/credentials.py index 0461de6..571d22f 100755 --- a/routes/credentials.py +++ b/routes/credentials.py @@ -69,7 +69,7 @@ def handle_search_credential(service, name): return jsonify({"error": "Both client_id and client_secret are required"}), 400 # For POST, first check if the credentials directory exists - if request.method == 'POST' and not any(Path(f'./creds/{service}/{name}').glob('*.json')): + if request.method == 'POST' and not any(Path(f'./data/{service}/{name}').glob('*.json')): return jsonify({"error": f"Account '{name}' doesn't exist. Create it first."}), 404 # Create or update search credentials 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 feb7eb8..2f53fb1 100755 --- a/routes/playlist.py +++ b/routes/playlist.py @@ -1,41 +1,105 @@ -from flask import Blueprint, Response, request +from flask import Blueprint, Response, request, jsonify import os import json import traceback +import logging # Added logging import +import uuid # For generating error task IDs +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 +import threading # For playlist watch trigger -playlist_bp = Blueprint('playlist', __name__) +# Imports from playlist_watch.py +from routes.utils.watch.db import ( + add_playlist_to_watch as add_playlist_db, + remove_playlist_from_watch as remove_playlist_db, + get_watched_playlist, + get_watched_playlists, + add_specific_tracks_to_playlist_table, + remove_specific_tracks_from_playlist_table, + is_track_in_playlist_db # Added import +) +from routes.utils.get_info import get_spotify_info # Already used, but ensure it's here +from routes.utils.watch.manager import check_watched_playlists # For manual trigger -@playlist_bp.route('/download', methods=['GET']) -def handle_download(): +logger = logging.getLogger(__name__) # Added logger initialization +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. - url = request.args.get('url') - 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"] = 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: + if not url: # This check might be redundant now but kept for safety return Response( json.dumps({"error": "Missing required parameter: url"}), status=400, mimetype='application/json' ) - # Add the task to the queue with only essential parameters - # The queue manager will now handle all config parameters - # Include full original request URL in metadata - orig_params = request.args.to_dict() - orig_params["original_url"] = request.url - task_id = download_queue_manager.add_task({ - "download_type": "playlist", - "url": url, - "name": name, - "artist": artist, - "orig_request": orig_params - }) + try: + task_id = download_queue_manager.add_task({ + "download_type": "playlist", + "url": url, + "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. + except Exception as e: + # Generic error handling for other issues during task submission + error_task_id = str(uuid.uuid4()) + store_task_info(error_task_id, { + "download_type": "playlist", + "url": url, + "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 + }) + store_task_status(error_task_id, { + "status": ProgressState.ERROR, + "error": f"Failed to queue playlist download: {str(e)}", + "timestamp": time.time() + }) + return Response( + json.dumps({"error": f"Failed to queue playlist download: {str(e)}", "task_id": error_task_id}), + status=500, + mimetype='application/json' + ) return Response( - json.dumps({"prg_file": task_id}), + json.dumps({"prg_file": task_id}), # prg_file is the old name for task_id status=202, mimetype='application/json' ) @@ -80,8 +144,23 @@ def get_playlist_info(): try: # Import and use the get_spotify_info function from the utility module. - from routes.utils.get_info import get_spotify_info playlist_info = get_spotify_info(spotify_id, "playlist") + + # If playlist_info is successfully fetched, check if it's watched + # and augment track items with is_locally_known status + if playlist_info and playlist_info.get('id'): + watched_playlist_details = get_watched_playlist(playlist_info['id']) + if watched_playlist_details: # Playlist is being watched + if playlist_info.get('tracks') and playlist_info['tracks'].get('items'): + for item in playlist_info['tracks']['items']: + if item and item.get('track') and item['track'].get('id'): + track_id = item['track']['id'] + item['track']['is_locally_known'] = is_track_in_playlist_db(playlist_info['id'], track_id) + elif item and item.get('track'): # Track object exists but no ID + item['track']['is_locally_known'] = False + # If not watched, or no tracks, is_locally_known will not be added, or tracks won't exist to add it to. + # Frontend should handle absence of this key as false. + return Response( json.dumps(playlist_info), status=200, @@ -97,3 +176,160 @@ def get_playlist_info(): status=500, mimetype='application/json' ) + +@playlist_bp.route('/watch/', methods=['PUT']) +def add_to_watchlist(playlist_spotify_id): + """Adds a playlist to the watchlist.""" + logger.info(f"Attempting to add playlist {playlist_spotify_id} to watchlist.") + try: + # Check if already watched + if get_watched_playlist(playlist_spotify_id): + return jsonify({"message": f"Playlist {playlist_spotify_id} is already being watched."}), 200 + + # Fetch playlist details from Spotify to populate our DB + playlist_data = get_spotify_info(playlist_spotify_id, "playlist") + if not playlist_data or 'id' not in playlist_data: + logger.error(f"Could not fetch details for playlist {playlist_spotify_id} from Spotify.") + return jsonify({"error": f"Could not fetch details for playlist {playlist_spotify_id} from Spotify."}), 404 + + add_playlist_db(playlist_data) # This also creates the tracks table + + # REMOVED: Do not add initial tracks directly to DB. + # The playlist watch manager will pick them up as new and queue downloads. + # Tracks will be added to DB only after successful download via Celery task callback. + # initial_track_items = playlist_data.get('tracks', {}).get('items', []) + # if initial_track_items: + # from routes.utils.watch.db import add_tracks_to_playlist_db # Keep local import for clarity + # add_tracks_to_playlist_db(playlist_spotify_id, initial_track_items) + + logger.info(f"Playlist {playlist_spotify_id} added to watchlist. Its tracks will be processed by the watch manager.") + return jsonify({"message": f"Playlist {playlist_spotify_id} added to watchlist. Tracks will be processed shortly."}), 201 + except Exception as e: + logger.error(f"Error adding playlist {playlist_spotify_id} to watchlist: {e}", exc_info=True) + return jsonify({"error": f"Could not add playlist to watchlist: {str(e)}"}), 500 + +@playlist_bp.route('/watch//status', methods=['GET']) +def get_playlist_watch_status(playlist_spotify_id): + """Checks if a specific playlist is being watched.""" + logger.info(f"Checking watch status for playlist {playlist_spotify_id}.") + try: + playlist = get_watched_playlist(playlist_spotify_id) + if playlist: + return jsonify({"is_watched": True, "playlist_data": playlist}), 200 + else: + # Return 200 with is_watched: false, so frontend can clearly distinguish + # between "not watched" and an actual error fetching status. + return jsonify({"is_watched": False}), 200 + except Exception as e: + logger.error(f"Error checking watch status for playlist {playlist_spotify_id}: {e}", exc_info=True) + return jsonify({"error": f"Could not check watch status: {str(e)}"}), 500 + +@playlist_bp.route('/watch/', methods=['DELETE']) +def remove_from_watchlist(playlist_spotify_id): + """Removes a playlist from the watchlist.""" + logger.info(f"Attempting to remove playlist {playlist_spotify_id} from watchlist.") + try: + if not get_watched_playlist(playlist_spotify_id): + return jsonify({"error": f"Playlist {playlist_spotify_id} not found in watchlist."}), 404 + + remove_playlist_db(playlist_spotify_id) + logger.info(f"Playlist {playlist_spotify_id} removed from watchlist successfully.") + return jsonify({"message": f"Playlist {playlist_spotify_id} removed from watchlist."}), 200 + except Exception as e: + logger.error(f"Error removing playlist {playlist_spotify_id} from watchlist: {e}", exc_info=True) + return jsonify({"error": f"Could not remove playlist from watchlist: {str(e)}"}), 500 + +@playlist_bp.route('/watch//tracks', methods=['POST']) +def mark_tracks_as_known(playlist_spotify_id): + """Fetches details for given track IDs and adds/updates them in the playlist's local DB table.""" + logger.info(f"Attempting to mark tracks as known for playlist {playlist_spotify_id}.") + try: + track_ids = request.json + if not isinstance(track_ids, list) or not all(isinstance(tid, str) for tid in track_ids): + return jsonify({"error": "Invalid request body. Expecting a JSON array of track Spotify IDs."}), 400 + + if not get_watched_playlist(playlist_spotify_id): + return jsonify({"error": f"Playlist {playlist_spotify_id} is not being watched."}), 404 + + fetched_tracks_details = [] + for track_id in track_ids: + try: + track_detail = get_spotify_info(track_id, "track") + if track_detail and track_detail.get('id'): + fetched_tracks_details.append(track_detail) + else: + logger.warning(f"Could not fetch details for track {track_id} when marking as known for playlist {playlist_spotify_id}.") + except Exception as e: + logger.error(f"Failed to fetch Spotify details for track {track_id}: {e}") + + if not fetched_tracks_details: + return jsonify({"message": "No valid track details could be fetched to mark as known.", "processed_count": 0}), 200 + + add_specific_tracks_to_playlist_table(playlist_spotify_id, fetched_tracks_details) + logger.info(f"Successfully marked/updated {len(fetched_tracks_details)} tracks as known for playlist {playlist_spotify_id}.") + return jsonify({"message": f"Successfully processed {len(fetched_tracks_details)} tracks for playlist {playlist_spotify_id}."}), 200 + except Exception as e: + logger.error(f"Error marking tracks as known for playlist {playlist_spotify_id}: {e}", exc_info=True) + return jsonify({"error": f"Could not mark tracks as known: {str(e)}"}), 500 + +@playlist_bp.route('/watch//tracks', methods=['DELETE']) +def mark_tracks_as_missing_locally(playlist_spotify_id): + """Removes specified tracks from the playlist's local DB table.""" + logger.info(f"Attempting to mark tracks as missing (delete locally) for playlist {playlist_spotify_id}.") + try: + track_ids = request.json + if not isinstance(track_ids, list) or not all(isinstance(tid, str) for tid in track_ids): + return jsonify({"error": "Invalid request body. Expecting a JSON array of track Spotify IDs."}), 400 + + if not get_watched_playlist(playlist_spotify_id): + return jsonify({"error": f"Playlist {playlist_spotify_id} is not being watched."}), 404 + + deleted_count = remove_specific_tracks_from_playlist_table(playlist_spotify_id, track_ids) + logger.info(f"Successfully removed {deleted_count} tracks locally for playlist {playlist_spotify_id}.") + return jsonify({"message": f"Successfully removed {deleted_count} tracks locally for playlist {playlist_spotify_id}."}), 200 + except Exception as e: + logger.error(f"Error marking tracks as missing (deleting locally) for playlist {playlist_spotify_id}: {e}", exc_info=True) + return jsonify({"error": f"Could not mark tracks as missing: {str(e)}"}), 500 + +@playlist_bp.route('/watch/list', methods=['GET']) +def list_watched_playlists_endpoint(): + """Lists all playlists currently in the watchlist.""" + try: + playlists = get_watched_playlists() + return jsonify(playlists), 200 + except Exception as e: + logger.error(f"Error listing watched playlists: {e}", exc_info=True) + return jsonify({"error": f"Could not list watched playlists: {str(e)}"}), 500 + +@playlist_bp.route('/watch/trigger_check', methods=['POST']) +def trigger_playlist_check_endpoint(): + """Manually triggers the playlist checking mechanism for all watched playlists.""" + logger.info("Manual trigger for playlist check received for all playlists.") + try: + # Run check_watched_playlists without an ID to check all + thread = threading.Thread(target=check_watched_playlists, args=(None,)) + thread.start() + return jsonify({"message": "Playlist check triggered successfully in the background for all playlists."}), 202 + except Exception as e: + logger.error(f"Error manually triggering playlist check for all: {e}", exc_info=True) + return jsonify({"error": f"Could not trigger playlist check for all: {str(e)}"}), 500 + +@playlist_bp.route('/watch/trigger_check/', methods=['POST']) +def trigger_specific_playlist_check_endpoint(playlist_spotify_id: str): + """Manually triggers the playlist checking mechanism for a specific playlist.""" + logger.info(f"Manual trigger for specific playlist check received for ID: {playlist_spotify_id}") + try: + # Check if the playlist is actually in the watchlist first + watched_playlist = get_watched_playlist(playlist_spotify_id) + if not watched_playlist: + logger.warning(f"Trigger specific check: Playlist ID {playlist_spotify_id} not found in watchlist.") + return jsonify({"error": f"Playlist {playlist_spotify_id} is not in the watchlist. Add it first."}), 404 + + # Run check_watched_playlists with the specific ID + thread = threading.Thread(target=check_watched_playlists, args=(playlist_spotify_id,)) + thread.start() + logger.info(f"Playlist check triggered in background for specific playlist ID: {playlist_spotify_id}") + return jsonify({"message": f"Playlist check triggered successfully in the background for {playlist_spotify_id}."}), 202 + except Exception as e: + logger.error(f"Error manually triggering specific playlist check for {playlist_spotify_id}: {e}", exc_info=True) + return jsonify({"error": f"Could not trigger playlist check for {playlist_spotify_id}: {str(e)}"}), 500 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 609a441..f1902b9 100755 --- a/routes/track.py +++ b/routes/track.py @@ -2,17 +2,45 @@ from flask import Blueprint, Response, request import os import json import traceback +import uuid # For generating error task IDs +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_bp.route('/download/', methods=['GET']) +def handle_download(track_id): # Retrieve essential parameters from the request. - url = request.args.get('url') - 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: @@ -31,20 +59,40 @@ def handle_download(): mimetype='application/json' ) - # Add the task to the queue with only essential parameters - # The queue manager will now handle all config parameters - orig_params = request.args.to_dict() - orig_params["original_url"] = request.url - task_id = download_queue_manager.add_task({ - "download_type": "track", - "url": url, - "name": name, - "artist": artist, - "orig_request": orig_params - }) + try: + task_id = download_queue_manager.add_task({ + "download_type": "track", + "url": url, + "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. + except Exception as e: + # Generic error handling for other issues during task submission + error_task_id = str(uuid.uuid4()) + store_task_info(error_task_id, { + "download_type": "track", + "url": url, + "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 + }) + store_task_status(error_task_id, { + "status": ProgressState.ERROR, + "error": f"Failed to queue track download: {str(e)}", + "timestamp": time.time() + }) + return Response( + json.dumps({"error": f"Failed to queue track download: {str(e)}", "task_id": error_task_id}), + status=500, + mimetype='application/json' + ) return Response( - json.dumps({"prg_file": task_id}), + json.dumps({"prg_file": task_id}), # prg_file is the old name for task_id status=202, mimetype='application/json' ) diff --git a/routes/utils/album.py b/routes/utils/album.py index 2a13a43..0cf35fb 100755 --- a/routes/utils/album.py +++ b/routes/utils/album.py @@ -47,11 +47,11 @@ def download_album( # Smartly determine where to look for Spotify search credentials if service == 'spotify' and fallback: # If fallback is enabled, use the fallback account for Spotify search credentials - search_creds_path = Path(f'./creds/spotify/{fallback}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{fallback}/search.json') print(f"DEBUG: Using Spotify search credentials from fallback: {search_creds_path}") else: # Otherwise use the main account for Spotify search credentials - search_creds_path = Path(f'./creds/spotify/{main}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{main}/search.json') print(f"DEBUG: Using Spotify search credentials from main: {search_creds_path}") if search_creds_path.exists(): @@ -77,7 +77,7 @@ def download_album( deezer_error = None try: # Load Deezer credentials from 'main' under deezer directory - deezer_creds_dir = os.path.join('./creds/deezer', main) + deezer_creds_dir = os.path.join('./data/creds/deezer', main) deezer_creds_path = os.path.abspath(os.path.join(deezer_creds_dir, 'credentials.json')) # DEBUG: Print Deezer credential paths being used @@ -89,8 +89,8 @@ def download_album( # List available directories to compare print(f"DEBUG: Available Deezer credential directories:") - for dir_name in os.listdir('./creds/deezer'): - print(f"DEBUG: ./creds/deezer/{dir_name}") + for dir_name in os.listdir('./data/creds/deezer'): + print(f"DEBUG: ./data/creds/deezer/{dir_name}") with open(deezer_creds_path, 'r') as f: deezer_creds = json.load(f) @@ -129,7 +129,7 @@ def download_album( # Load fallback Spotify credentials and attempt download try: - spo_creds_dir = os.path.join('./creds/spotify', fallback) + spo_creds_dir = os.path.join('./data/creds/spotify', fallback) spo_creds_path = os.path.abspath(os.path.join(spo_creds_dir, 'credentials.json')) print(f"DEBUG: Using Spotify fallback credentials from: {spo_creds_path}") @@ -173,7 +173,7 @@ def download_album( # Original behavior: use Spotify main if quality is None: quality = 'HIGH' - creds_dir = os.path.join('./creds/spotify', main) + creds_dir = os.path.join('./data/creds/spotify', main) credentials_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json')) print(f"DEBUG: Using Spotify main credentials from: {credentials_path}") print(f"DEBUG: Credentials exist: {os.path.exists(credentials_path)}") @@ -208,7 +208,7 @@ def download_album( if quality is None: quality = 'FLAC' # Existing code remains the same, ignoring fallback - creds_dir = os.path.join('./creds/deezer', main) + creds_dir = os.path.join('./data/creds/deezer', main) creds_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json')) print(f"DEBUG: Using Deezer credentials from: {creds_path}") print(f"DEBUG: Credentials exist: {os.path.exists(creds_path)}") diff --git a/routes/utils/artist.py b/routes/utils/artist.py index 6510b0d..e0cac69 100644 --- a/routes/utils/artist.py +++ b/routes/utils/artist.py @@ -6,6 +6,7 @@ import logging from flask import Blueprint, Response, request, url_for from routes.utils.celery_queue_manager import download_queue_manager, get_config_params from routes.utils.get_info import get_spotify_info +from routes.utils.celery_tasks import get_last_task_status, ProgressState from deezspot.easy_spoty import Spo from deezspot.libutils.utils import get_ids, link_is_valid @@ -32,7 +33,7 @@ def get_artist_discography(url, main, album_type='album,single,compilation,appea # Initialize Spotify API with credentials spotify_client_id = None spotify_client_secret = None - search_creds_path = Path(f'./creds/spotify/{main}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{main}/search.json') if search_creds_path.exists(): try: with open(search_creds_path, 'r') as f: @@ -76,7 +77,7 @@ def download_artist_albums(url, album_type="album,single,compilation", request_a request_args (dict): Original request arguments for tracking Returns: - list: List of task IDs for the queued album downloads + tuple: (list of successfully queued albums, list of duplicate albums) """ if not url: raise ValueError("Missing required parameter: url") @@ -133,10 +134,12 @@ def download_artist_albums(url, album_type="album,single,compilation", request_a if not filtered_albums: logger.warning(f"No albums match the specified types: {album_type}") - return [] + return [], [] # Queue each album as a separate download task album_task_ids = [] + successfully_queued_albums = [] + duplicate_albums = [] # To store info about albums that were duplicates for album in filtered_albums: # Add detailed logging to inspect each album's structure and URLs @@ -185,10 +188,38 @@ def download_artist_albums(url, album_type="album,single,compilation", request_a # Debug log the task data being sent to the queue logger.debug(f"Album task data: url={task_data['url']}, retry_url={task_data['retry_url']}") - # Add the task to the queue manager - task_id = download_queue_manager.add_task(task_data) - album_task_ids.append(task_id) - logger.info(f"Queued album download: {album_name} ({task_id})") + try: + task_id = download_queue_manager.add_task(task_data) + + # Check the status of the newly added task to see if it was marked as a duplicate error + last_status = get_last_task_status(task_id) + + if last_status and last_status.get("status") == ProgressState.ERROR and last_status.get("existing_task_id"): + logger.warning(f"Album {album_name} (URL: {album_url}) is a duplicate. Error task ID: {task_id}. Existing task ID: {last_status.get('existing_task_id')}") + duplicate_albums.append({ + "name": album_name, + "artist": album_artist, + "url": album_url, + "error_task_id": task_id, # This is the ID of the task marked as a duplicate error + "existing_task_id": last_status.get("existing_task_id"), + "message": last_status.get("message", "Duplicate download attempt.") + }) + else: + # If not a duplicate error, it was successfully queued (or failed for other reasons handled by add_task) + # We only add to successfully_queued_albums if it wasn't a duplicate error from add_task + # Other errors from add_task (like submission failure) would also result in an error status for task_id + # but won't have 'existing_task_id'. The client can check the status of this task_id. + album_task_ids.append(task_id) # Keep track of all task_ids returned by add_task + successfully_queued_albums.append({ + "name": album_name, + "artist": album_artist, + "url": album_url, + "task_id": task_id + }) + logger.info(f"Queued album download: {album_name} ({task_id})") + except Exception as e: # Catch any other unexpected error from add_task itself (though it should be rare now) + logger.error(f"Failed to queue album {album_name} due to an unexpected error in add_task: {str(e)}") + # Optionally, collect these errors. For now, just logging and continuing. - logger.info(f"Queued {len(album_task_ids)} album downloads for artist: {artist_name}") - return album_task_ids + logger.info(f"Artist album processing: {len(successfully_queued_albums)} queued, {len(duplicate_albums)} duplicates found.") + return successfully_queued_albums, duplicate_albums diff --git a/routes/utils/celery_config.py b/routes/utils/celery_config.py index f455ae6..3b849a4 100644 --- a/routes/utils/celery_config.py +++ b/routes/utils/celery_config.py @@ -22,7 +22,7 @@ REDIS_BACKEND = os.getenv('REDIS_BACKEND', REDIS_URL) logger.info(f"Redis configuration: REDIS_URL={REDIS_URL}, REDIS_BACKEND={REDIS_BACKEND}") # Config path -CONFIG_PATH = './config/main.json' +CONFIG_PATH = './data/config/main.json' def get_config_params(): """ diff --git a/routes/utils/celery_manager.py b/routes/utils/celery_manager.py index 24aeeba..8e3cb7f 100644 --- a/routes/utils/celery_manager.py +++ b/routes/utils/celery_manager.py @@ -11,11 +11,25 @@ import queue import sys import uuid +# Import Celery task utilities +from .celery_tasks import ( + ProgressState, + get_task_info, + get_last_task_status, + store_task_status, + get_all_tasks as get_all_celery_tasks_info, + 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__) # Configuration -CONFIG_PATH = './config/main.json' +CONFIG_PATH = './data/config/main.json' CELERY_APP = 'routes.utils.celery_tasks.celery_app' CELERY_PROCESS = None CONFIG_CHECK_INTERVAL = 30 # seconds @@ -29,10 +43,128 @@ class CeleryManager: self.celery_process = None self.current_worker_count = 0 self.monitoring_thread = None + self.error_cleanup_thread = None self.running = False self.log_queue = queue.Queue() self.output_threads = [] + def _cleanup_stale_tasks(self): + logger.info("Cleaning up potentially stale Celery tasks...") + try: + tasks = get_all_celery_tasks_info() + if not tasks: + logger.info("No tasks found in Redis to check for staleness.") + return + + active_stale_states = [ + ProgressState.PROCESSING, + ProgressState.INITIALIZING, + ProgressState.DOWNLOADING, + ProgressState.PROGRESS, + ProgressState.REAL_TIME, + ProgressState.RETRYING + ] + + stale_tasks_count = 0 + 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") + if current_status_str in active_stale_states: + logger.warning(f"Task {task_id} ('{task_summary.get('name', 'Unknown')}') found in stale state '{current_status_str}'. Marking as error.") + + task_info_details = get_task_info(task_id) + config = get_config_params() + + error_payload = { + "status": ProgressState.ERROR, + "message": "Task interrupted due to application restart.", + "error": "Task interrupted due to application restart.", + "timestamp": time.time(), + "type": task_info_details.get("type", task_summary.get("type", "unknown")), + "name": task_info_details.get("name", task_summary.get("name", "Unknown")), + "artist": task_info_details.get("artist", task_summary.get("artist", "")), + "can_retry": True, + "retry_count": last_status_data.get("retry_count", 0), + "max_retries": config.get('maxRetries', 3) + } + 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 (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) + def start(self): """Start the Celery manager and initial workers""" if self.running: @@ -40,12 +172,22 @@ class CeleryManager: self.running = True + # Initialize history database + init_history_db() + + # Clean up stale tasks BEFORE starting/restarting workers + self._cleanup_stale_tasks() + # Start initial workers self._update_workers() # Start monitoring thread for config changes self.monitoring_thread = threading.Thread(target=self._monitor_config, daemon=True) self.monitoring_thread.start() + + # Start periodic error cleanup thread + self.error_cleanup_thread = threading.Thread(target=self._run_periodic_error_cleanup, daemon=True) + self.error_cleanup_thread.start() # Register shutdown handler atexit.register(self.stop) @@ -147,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 @@ -257,5 +399,24 @@ class CeleryManager: logger.error(f"Error in config monitoring thread: {e}") time.sleep(5) # Wait before retrying + def _run_periodic_error_cleanup(self): + """Periodically triggers the cleanup_stale_errors Celery task.""" + cleanup_interval = 60 # Run cleanup task every 60 seconds + logger.info(f"Starting periodic error cleanup scheduler (runs every {cleanup_interval}s).") + while self.running: + try: + logger.info("Scheduling cleanup_stale_errors task...") + cleanup_stale_errors.delay() # Call the Celery task + except Exception as e: + logger.error(f"Error scheduling cleanup_stale_errors task: {e}", exc_info=True) + + # Wait for the next interval + # Use a loop to check self.running more frequently to allow faster shutdown + for _ in range(cleanup_interval): + if not self.running: + break + time.sleep(1) + logger.info("Periodic error cleanup scheduler stopped.") + # Create single instance celery_manager = CeleryManager() \ No newline at end of file diff --git a/routes/utils/celery_queue_manager.py b/routes/utils/celery_queue_manager.py index 010665a..362ec48 100644 --- a/routes/utils/celery_queue_manager.py +++ b/routes/utils/celery_queue_manager.py @@ -25,7 +25,7 @@ from routes.utils.celery_tasks import ( logger = logging.getLogger(__name__) # Load configuration -CONFIG_PATH = './config/main.json' +CONFIG_PATH = './data/config/main.json' try: with open(CONFIG_PATH, 'r') as f: config_data = json.load(f) @@ -94,77 +94,120 @@ class CeleryDownloadQueueManager: self.paused = False print(f"Celery Download Queue Manager initialized with max_concurrent={self.max_concurrent}") - def add_task(self, task): + def add_task(self, task: dict, from_watch_job: bool = False): """ - Add a new download task to the Celery queue + Add a new download task to the Celery queue. + - If from_watch_job is True and an active duplicate is found, the task is not queued and None is returned. + - If from_watch_job is False and an active duplicate is found, a new task ID is created, + set to an ERROR state indicating the duplicate, and this new error task's ID is returned. Args: task (dict): Task parameters including download_type, url, etc. + from_watch_job (bool): If True, duplicate active tasks are skipped. Defaults to False. Returns: - str: Task ID + str | None: Task ID if successfully queued or an error task ID for non-watch duplicates. + None if from_watch_job is True and an active duplicate was found. """ try: - # Extract essential parameters - download_type = task.get("download_type", "unknown") + # Extract essential parameters for duplicate check + incoming_url = task.get("url") + incoming_type = task.get("download_type", "unknown") + + if not incoming_url: + logger.warning("Task being added with no URL. Duplicate check might be unreliable.") + + NON_BLOCKING_STATES = [ + ProgressState.COMPLETE, + ProgressState.CANCELLED, + ProgressState.ERROR, + ProgressState.ERROR_RETRIED, + ProgressState.ERROR_AUTO_CLEANED + ] - # Debug existing task data - logger.debug(f"Adding {download_type} task with data: {json.dumps({k: v for k, v in task.items() if k != 'orig_request'})}") - - # Create a unique task ID + all_existing_tasks_summary = get_all_tasks() + if incoming_url: + for task_summary in all_existing_tasks_summary: + existing_task_id = task_summary.get("task_id") + if not existing_task_id: + continue + + existing_task_info = get_task_info(existing_task_id) + existing_last_status_obj = get_last_task_status(existing_task_id) + + if not existing_task_info or not existing_last_status_obj: + continue + + existing_url = existing_task_info.get("url") + existing_type = existing_task_info.get("download_type") + existing_status = existing_last_status_obj.get("status") + + if (existing_url == incoming_url and + existing_type == incoming_type and + existing_status not in NON_BLOCKING_STATES): + + message = f"Duplicate download: URL '{incoming_url}' (type: {incoming_type}) is already being processed by task {existing_task_id} (status: {existing_status})." + logger.warning(message) + + if from_watch_job: + logger.info(f"Task from watch job for {incoming_url} not queued due to active duplicate {existing_task_id}.") + return None # Skip execution for watch jobs + else: + # Create a new task_id for this duplicate request and mark it as an error + error_task_id = str(uuid.uuid4()) + error_task_info_payload = { + "download_type": incoming_type, + "type": task.get("type", incoming_type), + "name": task.get("name", "Duplicate Task"), + "artist": task.get("artist", ""), + "url": incoming_url, + "original_request": task.get("orig_request", task.get("original_request", {})), + "created_at": time.time(), + "is_duplicate_error_task": True + } + store_task_info(error_task_id, error_task_info_payload) + error_status_payload = { + "status": ProgressState.ERROR, + "error": message, + "existing_task_id": existing_task_id, + "timestamp": time.time(), + "type": error_task_info_payload["type"], + "name": error_task_info_payload["name"], + "artist": error_task_info_payload["artist"] + } + store_task_status(error_task_id, error_status_payload) + return error_task_id # Return the ID of this new error-state task + task_id = str(uuid.uuid4()) - - # Get config parameters and process original request config_params = get_config_params() - - # Extract original request or use empty dict original_request = task.get("orig_request", task.get("original_request", {})) - # Debug retry_url if present - if "retry_url" in task: - logger.debug(f"Task has retry_url: {task['retry_url']}") - - # Build the complete task with config parameters complete_task = { - "download_type": download_type, - "type": task.get("type", download_type), + "download_type": incoming_type, + "type": task.get("type", incoming_type), "name": task.get("name", ""), "artist": task.get("artist", ""), "url": task.get("url", ""), - - # Preserve retry_url if present "retry_url": task.get("retry_url", ""), - - # Use main account from config "main": original_request.get("main", config_params['deezer']), - - # Set fallback if enabled in config "fallback": original_request.get("fallback", config_params['spotify'] if config_params['fallback'] else None), - - # Use default quality settings "quality": original_request.get("quality", config_params['deezerQuality']), - "fall_quality": original_request.get("fall_quality", config_params['spotifyQuality']), - - # Parse boolean parameters from string values "real_time": self._parse_bool_param(original_request.get("real_time"), config_params['realTime']), - "custom_dir_format": original_request.get("custom_dir_format", config_params['customDirFormat']), "custom_track_format": original_request.get("custom_track_format", config_params['customTrackFormat']), - - # Parse boolean parameters from string values "pad_tracks": self._parse_bool_param(original_request.get("tracknum_padding"), config_params['tracknum_padding']), - "retry_count": 0, "original_request": original_request, "created_at": time.time() } + + # If from_watch_job is True, ensure track_details_for_db is passed through + if from_watch_job and "track_details_for_db" in task: + complete_task["track_details_for_db"] = task["track_details_for_db"] - # Store the task info in Redis for later retrieval store_task_info(task_id, complete_task) - - # Store initial queued status store_task_status(task_id, { "status": ProgressState.QUEUED, "timestamp": time.time(), @@ -172,46 +215,35 @@ class CeleryDownloadQueueManager: "name": complete_task["name"], "artist": complete_task["artist"], "retry_count": 0, - "queue_position": len(get_all_tasks()) + 1 # Approximate queue position + "queue_position": len(get_all_tasks()) + 1 }) - # Launch the appropriate Celery task based on download_type - celery_task = None + celery_task_map = { + "track": download_track, + "album": download_album, + "playlist": download_playlist + } - if download_type == "track": - celery_task = download_track.apply_async( - kwargs=complete_task, - task_id=task_id, - countdown=0 if not self.paused else 3600 # Delay task if paused - ) - elif download_type == "album": - celery_task = download_album.apply_async( - kwargs=complete_task, - task_id=task_id, - countdown=0 if not self.paused else 3600 - ) - elif download_type == "playlist": - celery_task = download_playlist.apply_async( + task_func = celery_task_map.get(incoming_type) + if task_func: + task_func.apply_async( kwargs=complete_task, task_id=task_id, countdown=0 if not self.paused else 3600 ) + logger.info(f"Added {incoming_type} download task {task_id} to Celery queue.") + return task_id else: - # Store error status for unknown download type store_task_status(task_id, { "status": ProgressState.ERROR, - "message": f"Unsupported download type: {download_type}", + "message": f"Unsupported download type: {incoming_type}", "timestamp": time.time() }) - logger.error(f"Unsupported download type: {download_type}") - return task_id # Still return the task_id so the error can be tracked - - logger.info(f"Added {download_type} download task {task_id} to Celery queue") - return task_id + logger.error(f"Unsupported download type: {incoming_type}") + return task_id except Exception as e: logger.error(f"Error adding task to Celery queue: {e}", exc_info=True) - # Generate a task ID even for failed tasks so we can track the error error_task_id = str(uuid.uuid4()) store_task_status(error_task_id, { "status": ProgressState.ERROR, diff --git a/routes/utils/celery_tasks.py b/routes/utils/celery_tasks.py index c671328..6f7001c 100644 --- a/routes/utils/celery_tasks.py +++ b/routes/utils/celery_tasks.py @@ -7,12 +7,19 @@ from datetime import datetime from celery import Celery, Task, states from celery.signals import task_prerun, task_postrun, task_failure, worker_ready, worker_init, setup_logging from celery.exceptions import Retry +import os # Added for path operations +from pathlib import Path # Added for path operations # Configure logging logger = logging.getLogger(__name__) # Setup Redis and Celery from routes.utils.celery_config import REDIS_URL, REDIS_BACKEND, REDIS_PASSWORD, get_config_params +# Import for playlist watch DB update +from routes.utils.watch.db import add_single_track_to_playlist_db, add_or_update_album_for_artist + +# Import history manager function +from .history_manager import add_entry_to_history # Initialize Celery app celery_app = Celery('download_tasks', @@ -44,6 +51,8 @@ class ProgressState: REAL_TIME = "real_time" SKIPPED = "skipped" DONE = "done" + ERROR_RETRIED = "ERROR_RETRIED" # Status for an error task that has been retried + ERROR_AUTO_CLEANED = "ERROR_AUTO_CLEANED" # Status for an error task that was auto-cleaned # Reuse the application's logging configuration for Celery workers @setup_logging.connect @@ -140,20 +149,73 @@ 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: # Mark the task as cancelled in Redis store_task_status(task_id, { "status": ProgressState.CANCELLED, - "message": "Task cancelled by user", + "error": "Task cancelled by user", "timestamp": time.time() }) # 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}") @@ -165,12 +227,12 @@ def retry_task(task_id): # Get task info task_info = get_task_info(task_id) if not task_info: - return {"status": "error", "message": f"Task {task_id} not found"} + return {"status": "error", "error": f"Task {task_id} not found"} # Check if task has error status last_status = get_last_task_status(task_id) if not last_status or last_status.get("status") != ProgressState.ERROR: - return {"status": "error", "message": "Task is not in a failed state"} + return {"status": "error", "error": "Task is not in a failed state"} # Get current retry count retry_count = last_status.get("retry_count", 0) @@ -185,7 +247,7 @@ def retry_task(task_id): if retry_count >= max_retries: return { "status": "error", - "message": f"Maximum retry attempts ({max_retries}) exceeded" + "error": f"Maximum retry attempts ({max_retries}) exceeded" } # Calculate retry delay @@ -255,34 +317,51 @@ def retry_task(task_id): # Launch the appropriate task based on download_type download_type = task_info.get("download_type", "unknown") - task = None + new_celery_task_obj = None logger.info(f"Retrying task {task_id} as {new_task_id} (retry {retry_count + 1}/{max_retries})") if download_type == "track": - task = download_track.apply_async( + new_celery_task_obj = download_track.apply_async( kwargs=task_info, task_id=new_task_id, queue='downloads' ) elif download_type == "album": - task = download_album.apply_async( + new_celery_task_obj = download_album.apply_async( kwargs=task_info, task_id=new_task_id, queue='downloads' ) elif download_type == "playlist": - task = download_playlist.apply_async( + new_celery_task_obj = download_playlist.apply_async( kwargs=task_info, task_id=new_task_id, queue='downloads' ) else: logger.error(f"Unknown download type for retry: {download_type}") + store_task_status(new_task_id, { + "status": ProgressState.ERROR, + "error": f"Cannot retry: Unknown download type '{download_type}' for original task {task_id}", + "timestamp": time.time() + }) return { "status": "error", - "message": f"Unknown download type: {download_type}" + "error": f"Unknown download type: {download_type}" } + + # If retry was successfully submitted, update the original task's status + if new_celery_task_obj: + store_task_status(task_id, { + "status": "ERROR_RETRIED", + "error": f"Task superseded by retry: {new_task_id}", + "retried_as_task_id": new_task_id, + "timestamp": time.time() + }) + logger.info(f"Original task {task_id} status updated to ERROR_RETRIED, superseded by {new_task_id}") + else: + logger.error(f"Retry submission for task {task_id} (as {new_task_id}) did not return a Celery AsyncResult. Original task not marked as ERROR_RETRIED.") return { "status": "requeued", @@ -292,9 +371,8 @@ def retry_task(task_id): "retry_delay": retry_delay } except Exception as e: - logger.error(f"Error retrying task {task_id}: {e}") - traceback.print_exc() - return {"status": "error", "message": str(e)} + logger.error(f"Error retrying task {task_id}: {e}", exc_info=True) + return {"status": "error", "error": str(e)} def get_all_tasks(): """Get all active task IDs""" @@ -340,6 +418,27 @@ class ProgressTrackingTask(Task): """ task_id = self.request.id + # Ensure ./logs/tasks directory exists + logs_tasks_dir = Path('./logs/tasks') # Using relative path as per your update + try: + logs_tasks_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + logger.error(f"Task {task_id}: Could not create log directory {logs_tasks_dir}: {e}") + + # Define log file path + log_file_path = logs_tasks_dir / f"{task_id}.log" + + # Log progress_data to the task-specific file + try: + with open(log_file_path, 'a') as log_file: + # Add a timestamp to the log entry if not present, for consistency in the file + log_entry = progress_data.copy() + if 'timestamp' not in log_entry: + log_entry['timestamp'] = time.time() + print(json.dumps(log_entry), file=log_file) # Use print to file + except Exception as e: + logger.error(f"Task {task_id}: Could not write to task log file {log_file_path}: {e}") + # Add timestamp if not present if 'timestamp' not in progress_data: progress_data['timestamp'] = time.time() @@ -657,8 +756,9 @@ class ProgressTrackingTask(Task): task_info['error_count'] = error_count store_task_info(task_id, task_info) - # Set status + # Set status and error message data['status'] = ProgressState.ERROR + data['error'] = message def _handle_done(self, task_id, data, task_info): """Handle done status from deezspot""" @@ -734,7 +834,47 @@ 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 + ) + # If from playlist_watch and successful, add track to DB + original_request = task_info.get("original_request", {}) + if original_request.get("source") == "playlist_watch" and task_info.get("download_type") == "track": # ensure it's a track for playlist + playlist_id = original_request.get("playlist_id") + track_item_for_db = original_request.get("track_item_for_db") + + if playlist_id and track_item_for_db and track_item_for_db.get('track'): + logger.info(f"Task {task_id} was from playlist watch for playlist {playlist_id}. Adding track to DB.") + try: + add_single_track_to_playlist_db(playlist_id, track_item_for_db) + except Exception as db_add_err: + logger.error(f"Failed to add track to DB for playlist {playlist_id} after successful download task {task_id}: {db_add_err}", exc_info=True) + else: + logger.warning(f"Task {task_id} was from playlist_watch but missing playlist_id or track_item_for_db for DB update. Original Request: {original_request}") + + # If from artist_watch and successful, update album in DB + if original_request.get("source") == "artist_watch" and task_info.get("download_type") == "album": + artist_spotify_id = original_request.get("artist_spotify_id") + album_data_for_db = original_request.get("album_data_for_db") + + if artist_spotify_id and album_data_for_db and album_data_for_db.get("id"): + album_spotify_id = album_data_for_db.get("id") + logger.info(f"Task {task_id} was from artist watch for artist {artist_spotify_id}, album {album_spotify_id}. Updating album in DB as complete.") + try: + add_or_update_album_for_artist( + artist_spotify_id=artist_spotify_id, + album_data=album_data_for_db, + task_id=task_id, + is_download_complete=True + ) + except Exception as db_update_err: + logger.error(f"Failed to update album {album_spotify_id} in DB for artist {artist_spotify_id} after successful download task {task_id}: {db_update_err}", exc_info=True) + else: + logger.warning(f"Task {task_id} was from artist_watch (album) but missing key data (artist_spotify_id or album_data_for_db) for DB update. Original Request: {original_request}") + else: # Generic done for other types logger.info(f"Task {task_id} completed: {content_type.upper()}") @@ -765,27 +905,72 @@ 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 + 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"]: + 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.") + # return # Let status update proceed if necessary + task_info = get_task_info(task_id) - - # Update task status based on Celery task state + current_redis_status = last_status_for_history.get("status") if last_status_for_history else None + 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 task_info.get("download_type") == "track": # Applies to single track downloads and tracks from playlists/albums + delayed_delete_task_data.apply_async( + args=[task_id, "Task completed successfully and auto-cleaned."], + countdown=30 + ) + + original_request = task_info.get("original_request", {}) + # Handle successful track from playlist watch + if original_request.get("source") == "playlist_watch" and task_info.get("download_type") == "track": + playlist_id = original_request.get("playlist_id") + track_item_for_db = original_request.get("track_item_for_db") + + if playlist_id and track_item_for_db and track_item_for_db.get('track'): + logger.info(f"Task {task_id} was from playlist watch for playlist {playlist_id}. Adding track to DB.") + try: + add_single_track_to_playlist_db(playlist_id, track_item_for_db) + except Exception as db_add_err: + logger.error(f"Failed to add track to DB for playlist {playlist_id} after successful download task {task_id}: {db_add_err}", exc_info=True) + else: + logger.warning(f"Task {task_id} was from playlist_watch but missing playlist_id or track_item_for_db for DB update. Original Request: {original_request}") + + # Handle successful album from artist watch + if original_request.get("source") == "artist_watch" and task_info.get("download_type") == "album": + artist_spotify_id = original_request.get("artist_spotify_id") + album_data_for_db = original_request.get("album_data_for_db") + + if artist_spotify_id and album_data_for_db and album_data_for_db.get("id"): + album_spotify_id = album_data_for_db.get("id") + logger.info(f"Task {task_id} was from artist watch for artist {artist_spotify_id}, album {album_spotify_id}. Updating album in DB as complete.") + try: + add_or_update_album_for_artist( + artist_spotify_id=artist_spotify_id, + album_data=album_data_for_db, + task_id=task_id, + is_download_complete=True + ) + except Exception as db_update_err: + logger.error(f"Failed to update album {album_spotify_id} in DB for artist {artist_spotify_id} after successful download task {task_id}: {db_update_err}", exc_info=True) + else: + logger.warning(f"Task {task_id} was from artist_watch (album) but missing key data (artist_spotify_id or album_data_for_db) for DB update. Original Request: {original_request}") + except Exception as e: - logger.error(f"Error in task_postrun_handler: {e}") + logger.error(f"Error in task_postrun_handler: {e}", exc_info=True) @task_failure.connect def task_failure_handler(task_id=None, exception=None, traceback=None, *args, **kwargs): @@ -811,25 +996,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(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, - "traceback": str(traceback), - "can_retry": can_retry, - "retry_count": retry_count, - "max_retries": max_retries, - "message": f"Error: {error_message}" - }) + # 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}") + 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}") @@ -1053,4 +1247,104 @@ def download_playlist(self, **task_data): except Exception as e: logger.error(f"Error in download_playlist task: {e}") traceback.print_exc() - raise \ No newline at end of file + raise + +# Helper function to fully delete task data from Redis +def delete_task_data_and_log(task_id, reason="Task data deleted"): + """ + Marks a task as cancelled (if not already) and deletes all its data from Redis. + """ + 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 + + # 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": 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") + redis_client.delete(f"task:{task_id}:status:next_id") + + logger.info(f"Data for task {task_id} ('{task_info.get('name', 'Unknown')}') deleted from Redis. Reason: {reason}") + return True + except Exception as e: + logger.error(f"Error deleting task data for {task_id}: {e}", exc_info=True) + return False + +@celery_app.task(name="cleanup_stale_errors", queue="default") # Put on default queue, not downloads +def cleanup_stale_errors(): + """ + Periodically checks for tasks in ERROR state for more than 1 minute and cleans them up. + """ + logger.info("Running cleanup_stale_errors task...") + cleaned_count = 0 + try: + task_keys = redis_client.keys("task:*:info") + if not task_keys: + logger.info("No task keys found for cleanup.") + return {"status": "complete", "message": "No tasks to check."} + + current_time = time.time() + stale_threshold = 60 # 1 minute + + for key_bytes in task_keys: + task_id = key_bytes.decode('utf-8').split(':')[1] + last_status = get_last_task_status(task_id) + + if last_status and last_status.get("status") == ProgressState.ERROR: + error_timestamp = last_status.get("timestamp", 0) + if (current_time - error_timestamp) > stale_threshold: + # Check again to ensure it wasn't retried just before cleanup + current_last_status_before_delete = get_last_task_status(task_id) + if current_last_status_before_delete and current_last_status_before_delete.get("status") == ProgressState.ERROR_RETRIED: + logger.info(f"Task {task_id} was retried just before cleanup. Skipping delete.") + continue + + logger.info(f"Task {task_id} is in ERROR state for more than {stale_threshold}s. Cleaning up.") + if delete_task_data_and_log(task_id, reason=f"Auto-cleaned: Task was in ERROR state for over {stale_threshold} seconds without manual retry."): + cleaned_count += 1 + + logger.info(f"cleanup_stale_errors task finished. Cleaned up {cleaned_count} stale errored tasks.") + 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)} + +@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/credentials.py b/routes/utils/credentials.py index 1d7c97b..0ed671b 100755 --- a/routes/utils/credentials.py +++ b/routes/utils/credentials.py @@ -1,6 +1,85 @@ import json from pathlib import Path import shutil +from deezspot.spotloader import SpoLogin +from deezspot.deezloader import DeeLogin +import traceback # For logging detailed error messages +import time # For retry delays + +def _get_spotify_search_creds(creds_dir: Path): + """Helper to load client_id and client_secret from search.json for a Spotify account.""" + search_file = creds_dir / 'search.json' + if search_file.exists(): + try: + with open(search_file, 'r') as f: + search_data = json.load(f) + return search_data.get('client_id'), search_data.get('client_secret') + except Exception: + # Log error if search.json is malformed or unreadable + print(f"Warning: Could not read Spotify search credentials from {search_file}") + traceback.print_exc() + return None, None + +def _validate_with_retry(service_name, account_name, creds_dir_path, cred_file_path, data_for_validation, is_spotify): + """ + Attempts to validate credentials with retries for connection errors. + - For Spotify, cred_file_path is used. + - For Deezer, data_for_validation (which contains the 'arl' key) is used. + Returns True if validated, raises ValueError if not. + """ + max_retries = 5 + last_exception = None + + for attempt in range(max_retries): + try: + if is_spotify: + client_id, client_secret = _get_spotify_search_creds(creds_dir_path) + SpoLogin(credentials_path=str(cred_file_path), spotify_client_id=client_id, spotify_client_secret=client_secret) + else: # Deezer + arl = data_for_validation.get('arl') + if not arl: + # This should be caught by prior checks, but as a safeguard: + raise ValueError("Missing 'arl' for Deezer validation.") + DeeLogin(arl=arl) + + print(f"{service_name.capitalize()} credentials for {account_name} validated successfully (attempt {attempt + 1}).") + return True # Validation successful + except Exception as e: + last_exception = e + error_str = str(e).lower() + # More comprehensive check for connection-related errors + is_connection_error = ( + "connection refused" in error_str or + "connection error" in error_str or + "timeout" in error_str or + "temporary failure in name resolution" in error_str or + "dns lookup failed" in error_str or + "network is unreachable" in error_str or + "ssl handshake failed" in error_str or # Can be network-related + "connection reset by peer" in error_str + ) + + if is_connection_error and attempt < max_retries - 1: + retry_delay = 2 + attempt # Increasing delay (2s, 3s, 4s, 5s) + print(f"Validation for {account_name} ({service_name}) failed on attempt {attempt + 1}/{max_retries} due to connection issue: {e}. Retrying in {retry_delay}s...") + time.sleep(retry_delay) + continue # Go to next retry attempt + else: + # Not a connection error, or it's the last retry for a connection error + print(f"Validation for {account_name} ({service_name}) failed on attempt {attempt + 1} with non-retryable error or max retries reached for connection error.") + break # Exit retry loop + + # If loop finished without returning True, validation failed + print(f"ERROR: Credential validation definitively failed for {service_name} account {account_name} after {attempt + 1} attempt(s).") + if last_exception: + base_error_message = str(last_exception).splitlines()[-1] + detailed_error_message = f"Invalid {service_name} credentials. Verification failed: {base_error_message}" + if is_spotify and "incorrect padding" in base_error_message.lower(): + detailed_error_message += ". Hint: Do not throw your password here, read the docs" + # traceback.print_exc() # Already printed in create/edit, avoid duplicate full trace + raise ValueError(detailed_error_message) + else: # Should not happen if loop runs at least once + raise ValueError(f"Invalid {service_name} credentials. Verification failed (unknown reason after retries).") def get_credential(service, name, cred_type='credentials'): """ @@ -28,7 +107,7 @@ def get_credential(service, name, cred_type='credentials'): if service == 'deezer' and cred_type == 'search': raise ValueError("Search credentials are only supported for Spotify") - creds_dir = Path('./creds') / service / name + creds_dir = Path('./data/creds') / service / name file_path = creds_dir / f'{cred_type}.json' if not file_path.exists(): @@ -56,7 +135,7 @@ def list_credentials(service): if service not in ['spotify', 'deezer']: raise ValueError("Service must be 'spotify' or 'deezer'") - service_dir = Path('./creds') / service + service_dir = Path('./data/creds') / service if not service_dir.exists(): return [] @@ -116,21 +195,80 @@ def create_credential(service, name, data, cred_type='credentials'): raise ValueError(f"Missing required field for {cred_type}: {field}") # Create directory - creds_dir = Path('./creds') / service / name + creds_dir = Path('./data/creds') / service / name + file_created_now = False + dir_created_now = False + if cred_type == 'credentials': try: creds_dir.mkdir(parents=True, exist_ok=False) + dir_created_now = True except FileExistsError: - raise FileExistsError(f"Credential '{name}' already exists for {service}") - else: + # Directory already exists, which is fine for creating credentials.json + # if it doesn't exist yet, or if we are overwriting (though POST usually means new) + pass + except Exception as e: + raise ValueError(f"Could not create directory {creds_dir}: {e}") + + file_path = creds_dir / 'credentials.json' + if file_path.exists() and request.method == 'POST': # type: ignore + # Safety check for POST to not overwrite if file exists unless it's an edit (PUT) + raise FileExistsError(f"Credential file {file_path} already exists. Use PUT to modify.") + + # Write the credential file first + try: + with open(file_path, 'w') as f: + json.dump(data, f, indent=4) + file_created_now = True # Mark as created for potential cleanup + except Exception as e: + if dir_created_now: # Cleanup directory if file write failed + try: + creds_dir.rmdir() + except OSError: # rmdir fails if not empty, though it should be + pass + raise ValueError(f"Could not write credential file {file_path}: {e}") + + # --- Validation Step --- + try: + _validate_with_retry( + service_name=service, + account_name=name, + creds_dir_path=creds_dir, + cred_file_path=file_path, + data_for_validation=data, # 'data' contains the arl for Deezer + is_spotify=(service == 'spotify') + ) + except ValueError as val_err: # Catch the specific error from our helper + print(f"ERROR: Credential validation failed during creation for {service} account {name}: {val_err}") + traceback.print_exc() # Print full traceback here for creation failure context + # Clean up the created file and directory if validation fails + if file_created_now: + try: + file_path.unlink(missing_ok=True) + except OSError: + pass # Ignore if somehow already gone + if dir_created_now and not any(creds_dir.iterdir()): # Only remove if empty + try: + creds_dir.rmdir() + except OSError: + pass + raise # Re-raise the ValueError from validation + + elif cred_type == 'search': # Spotify only # For search.json, ensure the directory exists (it should if credentials.json exists) if not creds_dir.exists(): - raise FileNotFoundError(f"Credential '{name}' not found for {service}") - - # Write credentials file - file_path = creds_dir / f'{cred_type}.json' - with open(file_path, 'w') as f: - json.dump(data, f, indent=4) + # This implies credentials.json was not created first, which is an issue. + # However, the form logic might allow adding API creds to an existing empty dir. + # For now, let's create it if it's missing, assuming API creds can be standalone. + try: + creds_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + raise ValueError(f"Could not create directory for search credentials {creds_dir}: {e}") + + file_path = creds_dir / 'search.json' + # No specific validation for client_id/secret themselves, they are validated in use. + with open(file_path, 'w') as f: + json.dump(data, f, indent=4) def delete_credential(service, name, cred_type=None): """ @@ -145,7 +283,7 @@ def delete_credential(service, name, cred_type=None): Raises: FileNotFoundError: If the credential directory or specified file does not exist """ - creds_dir = Path('./creds') / service / name + creds_dir = Path('./data/creds') / service / name if cred_type: if cred_type not in ['credentials', 'search']: @@ -193,23 +331,29 @@ def edit_credential(service, name, new_data, cred_type='credentials'): raise ValueError("Search credentials are only supported for Spotify") # Get file path - creds_dir = Path('./creds') / service / name + creds_dir = Path('./data/creds') / service / name file_path = creds_dir / f'{cred_type}.json' - # For search.json, create if it doesn't exist - if cred_type == 'search' and not file_path.exists(): - if not creds_dir.exists(): - raise FileNotFoundError(f"Credential '{name}' not found for {service}") - data = {} - else: - # Load existing data - if not file_path.exists(): - raise FileNotFoundError(f"{cred_type.capitalize()} credential '{name}' not found for {service}") - + original_data_str = None # Store original data as string to revert + file_existed_before_edit = file_path.exists() + + if file_existed_before_edit: with open(file_path, 'r') as f: - data = json.load(f) - - # Validate new_data fields + original_data_str = f.read() + try: + data = json.loads(original_data_str) + except json.JSONDecodeError: + # If existing file is corrupt, treat as if we are creating it anew for edit + data = {} + original_data_str = None # Can't revert to corrupt data + else: + # If file doesn't exist, and we're editing (PUT), it's usually an error + # unless it's for search.json which can be created during an edit flow. + if cred_type == 'credentials': + raise FileNotFoundError(f"Cannot edit non-existent credentials file: {file_path}") + data = {} # Start with empty data for search.json creation + + # Validate new_data fields (data to be merged) allowed_fields = [] if cred_type == 'credentials': if service == 'spotify': @@ -223,15 +367,66 @@ def edit_credential(service, name, new_data, cred_type='credentials'): if key not in allowed_fields: raise ValueError(f"Invalid field '{key}' for {cred_type} credentials") - # Update data + # Update data (merging new_data into existing or empty data) data.update(new_data) - # For Deezer: Strip all fields except 'arl' + # --- Write and Validate Step for 'credentials' type --- + if cred_type == 'credentials': + try: + # Temporarily write new data for validation + with open(file_path, 'w') as f: + json.dump(data, f, indent=4) + + _validate_with_retry( + service_name=service, + account_name=name, + creds_dir_path=creds_dir, + cred_file_path=file_path, + data_for_validation=data, # 'data' is the merged data with 'arl' for Deezer + is_spotify=(service == 'spotify') + ) + except ValueError as val_err: # Catch the specific error from our helper + print(f"ERROR: Edited credential validation failed for {service} account {name}: {val_err}") + traceback.print_exc() # Print full traceback here for edit failure context + # Revert or delete the file + if original_data_str is not None: + with open(file_path, 'w') as f: + f.write(original_data_str) # Restore original content + elif file_existed_before_edit: # file existed but original_data_str is None (corrupt) + pass + else: # File didn't exist before this edit attempt, so remove it + try: + file_path.unlink(missing_ok=True) + except OSError: + pass # Ignore if somehow already gone + raise # Re-raise the ValueError from validation + except Exception as e: # Catch other potential errors like file IO during temp write + print(f"ERROR: Unexpected error during edit/validation for {service} account {name}: {e}") + traceback.print_exc() + # Attempt revert/delete + if original_data_str is not None: + with open(file_path, 'w') as f: f.write(original_data_str) + elif file_existed_before_edit: + pass + else: + try: + file_path.unlink(missing_ok=True) + except OSError: pass + raise ValueError(f"Failed to save edited {service} credentials due to: {str(e).splitlines()[-1]}") + + # For 'search' type, just write, no specific validation here for client_id/secret + elif cred_type == 'search': + if not creds_dir.exists(): # Should not happen if we're editing + raise FileNotFoundError(f"Credential directory {creds_dir} not found for editing search credentials.") + with open(file_path, 'w') as f: + json.dump(data, f, indent=4) # `data` here is the merged data for search + + # For Deezer: Strip all fields except 'arl' - This should use `data` which is `updated_data` if service == 'deezer' and cred_type == 'credentials': if 'arl' not in data: - raise ValueError("Missing 'arl' field for Deezer credential") + raise ValueError("Missing 'arl' field for Deezer credential after edit.") data = {'arl': data['arl']} - + # Ensure required fields are present required_fields = [] if cred_type == 'credentials': diff --git a/routes/utils/get_info.py b/routes/utils/get_info.py index 602801e..da1133d 100644 --- a/routes/utils/get_info.py +++ b/routes/utils/get_info.py @@ -7,13 +7,15 @@ from routes.utils.celery_queue_manager import get_config_params # We'll rely on get_config_params() instead of directly loading the config file -def get_spotify_info(spotify_id, spotify_type): +def get_spotify_info(spotify_id, spotify_type, limit=None, offset=None): """ Get info from Spotify API using the default Spotify account configured in main.json Args: spotify_id: The Spotify ID of the entity spotify_type: The type of entity (track, album, playlist, artist) + limit (int, optional): The maximum number of items to return. Only used if spotify_type is "artist". + offset (int, optional): The index of the first item to return. Only used if spotify_type is "artist". Returns: Dictionary with the entity information @@ -29,7 +31,7 @@ def get_spotify_info(spotify_id, spotify_type): raise ValueError("No Spotify account configured in settings") if spotify_id: - search_creds_path = Path(f'./creds/spotify/{main}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{main}/search.json') if search_creds_path.exists(): try: with open(search_creds_path, 'r') as f: @@ -50,6 +52,15 @@ def get_spotify_info(spotify_id, spotify_type): return Spo.get_album(spotify_id) elif spotify_type == "playlist": return Spo.get_playlist(spotify_id) + elif spotify_type == "artist_discography": + if limit is not None and offset is not None: + return Spo.get_artist_discography(spotify_id, limit=limit, offset=offset) + elif limit is not None: + return Spo.get_artist_discography(spotify_id, limit=limit) + elif offset is not None: + return Spo.get_artist_discography(spotify_id, offset=offset) + else: + return Spo.get_artist_discography(spotify_id) elif spotify_type == "artist": return Spo.get_artist(spotify_id) elif spotify_type == "episode": 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/playlist.py b/routes/utils/playlist.py index 6830460..1b16df1 100755 --- a/routes/utils/playlist.py +++ b/routes/utils/playlist.py @@ -47,11 +47,11 @@ def download_playlist( # Smartly determine where to look for Spotify search credentials if service == 'spotify' and fallback: # If fallback is enabled, use the fallback account for Spotify search credentials - search_creds_path = Path(f'./creds/spotify/{fallback}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{fallback}/search.json') print(f"DEBUG: Using Spotify search credentials from fallback: {search_creds_path}") else: # Otherwise use the main account for Spotify search credentials - search_creds_path = Path(f'./creds/spotify/{main}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{main}/search.json') print(f"DEBUG: Using Spotify search credentials from main: {search_creds_path}") if search_creds_path.exists(): @@ -77,7 +77,7 @@ def download_playlist( deezer_error = None try: # Load Deezer credentials from 'main' under deezer directory - deezer_creds_dir = os.path.join('./creds/deezer', main) + deezer_creds_dir = os.path.join('./data/creds/deezer', main) deezer_creds_path = os.path.abspath(os.path.join(deezer_creds_dir, 'credentials.json')) # DEBUG: Print Deezer credential paths being used @@ -124,7 +124,7 @@ def download_playlist( # Load fallback Spotify credentials and attempt download try: - spo_creds_dir = os.path.join('./creds/spotify', fallback) + spo_creds_dir = os.path.join('./data/creds/spotify', fallback) spo_creds_path = os.path.abspath(os.path.join(spo_creds_dir, 'credentials.json')) print(f"DEBUG: Using Spotify fallback credentials from: {spo_creds_path}") @@ -168,7 +168,7 @@ def download_playlist( # Original behavior: use Spotify main if quality is None: quality = 'HIGH' - creds_dir = os.path.join('./creds/spotify', main) + creds_dir = os.path.join('./data/creds/spotify', main) credentials_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json')) print(f"DEBUG: Using Spotify main credentials from: {credentials_path}") print(f"DEBUG: Credentials exist: {os.path.exists(credentials_path)}") @@ -203,7 +203,7 @@ def download_playlist( if quality is None: quality = 'FLAC' # Existing code for Deezer, using main as Deezer account. - creds_dir = os.path.join('./creds/deezer', main) + creds_dir = os.path.join('./data/creds/deezer', main) creds_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json')) print(f"DEBUG: Using Deezer credentials from: {creds_path}") print(f"DEBUG: Credentials exist: {os.path.exists(creds_path)}") diff --git a/routes/utils/search.py b/routes/utils/search.py index 6ea5a0e..a0d20f6 100755 --- a/routes/utils/search.py +++ b/routes/utils/search.py @@ -19,7 +19,7 @@ def search( client_secret = None if main: - search_creds_path = Path(f'./creds/spotify/{main}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{main}/search.json') logger.debug(f"Looking for credentials at: {search_creds_path}") if search_creds_path.exists(): diff --git a/routes/utils/track.py b/routes/utils/track.py index ffa84aa..1ae1853 100755 --- a/routes/utils/track.py +++ b/routes/utils/track.py @@ -47,11 +47,11 @@ def download_track( # Smartly determine where to look for Spotify search credentials if service == 'spotify' and fallback: # If fallback is enabled, use the fallback account for Spotify search credentials - search_creds_path = Path(f'./creds/spotify/{fallback}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{fallback}/search.json') print(f"DEBUG: Using Spotify search credentials from fallback: {search_creds_path}") else: # Otherwise use the main account for Spotify search credentials - search_creds_path = Path(f'./creds/spotify/{main}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{main}/search.json') print(f"DEBUG: Using Spotify search credentials from main: {search_creds_path}") if search_creds_path.exists(): @@ -76,7 +76,7 @@ def download_track( # First attempt: use Deezer's download_trackspo with 'main' (Deezer credentials) deezer_error = None try: - deezer_creds_dir = os.path.join('./creds/deezer', main) + deezer_creds_dir = os.path.join('./data/creds/deezer', main) deezer_creds_path = os.path.abspath(os.path.join(deezer_creds_dir, 'credentials.json')) # DEBUG: Print Deezer credential paths being used @@ -88,8 +88,8 @@ def download_track( # List available directories to compare print(f"DEBUG: Available Deezer credential directories:") - for dir_name in os.listdir('./creds/deezer'): - print(f"DEBUG: ./creds/deezer/{dir_name}") + for dir_name in os.listdir('./data/creds/deezer'): + print(f"DEBUG: ./data/creds/deezer/{dir_name}") with open(deezer_creds_path, 'r') as f: deezer_creds = json.load(f) @@ -122,7 +122,7 @@ def download_track( # If the first attempt fails, use the fallback Spotify credentials try: - spo_creds_dir = os.path.join('./creds/spotify', fallback) + spo_creds_dir = os.path.join('./data/creds/spotify', fallback) spo_creds_path = os.path.abspath(os.path.join(spo_creds_dir, 'credentials.json')) # We've already loaded the Spotify client credentials above based on fallback @@ -159,7 +159,7 @@ def download_track( # Directly use Spotify main account if quality is None: quality = 'HIGH' - creds_dir = os.path.join('./creds/spotify', main) + creds_dir = os.path.join('./data/creds/spotify', main) credentials_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json')) spo = SpoLogin( credentials_path=credentials_path, @@ -188,7 +188,7 @@ def download_track( if quality is None: quality = 'FLAC' # Deezer download logic remains unchanged, with the custom formatting parameters passed along. - creds_dir = os.path.join('./creds/deezer', main) + creds_dir = os.path.join('./data/creds/deezer', main) creds_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json')) with open(creds_path, 'r') as f: creds = json.load(f) diff --git a/routes/utils/watch/db.py b/routes/utils/watch/db.py new file mode 100644 index 0000000..225690a --- /dev/null +++ b/routes/utils/watch/db.py @@ -0,0 +1,723 @@ +import sqlite3 +import json +from pathlib import Path +import logging +import time + +logger = logging.getLogger(__name__) + +DB_DIR = Path('./data/watch') +# Define separate DB paths +PLAYLISTS_DB_PATH = DB_DIR / 'playlists.db' +ARTISTS_DB_PATH = DB_DIR / 'artists.db' + +# Config path remains the same +CONFIG_PATH = Path('./data/config/watch.json') + +def _get_playlists_db_connection(): + DB_DIR.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(PLAYLISTS_DB_PATH, timeout=10) + conn.row_factory = sqlite3.Row + return conn + +def _get_artists_db_connection(): + DB_DIR.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(ARTISTS_DB_PATH, timeout=10) + conn.row_factory = sqlite3.Row + return conn + +def init_playlists_db(): + """Initializes the playlists database and creates the main watched_playlists table if it doesn't exist.""" + try: + with _get_playlists_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS watched_playlists ( + spotify_id TEXT PRIMARY KEY, + name TEXT, + owner_id TEXT, + owner_name TEXT, + total_tracks INTEGER, + link TEXT, + snapshot_id TEXT, + last_checked INTEGER, + added_at INTEGER, + is_active INTEGER DEFAULT 1 + ) + """) + conn.commit() + logger.info(f"Playlists database initialized successfully at {PLAYLISTS_DB_PATH}") + except sqlite3.Error as e: + logger.error(f"Error initializing watched_playlists table: {e}", exc_info=True) + raise + +def _create_playlist_tracks_table(playlist_spotify_id: str): + """Creates a table for a specific playlist to store its tracks if it doesn't exist in playlists.db.""" + table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" # Sanitize table name + try: + with _get_playlists_db_connection() as conn: # Use playlists connection + cursor = conn.cursor() + cursor.execute(f""" + CREATE TABLE IF NOT EXISTS {table_name} ( + spotify_track_id TEXT PRIMARY KEY, + title TEXT, + artist_names TEXT, -- Comma-separated artist names + album_name TEXT, + album_artist_names TEXT, -- Comma-separated album artist names + track_number INTEGER, + album_spotify_id TEXT, + duration_ms INTEGER, + added_at_playlist TEXT, -- When track was added to Spotify playlist + added_to_db INTEGER, -- Timestamp when track was added to this DB table + is_present_in_spotify INTEGER DEFAULT 1, -- Flag to mark if still in Spotify playlist + last_seen_in_spotify INTEGER -- Timestamp when last confirmed in Spotify playlist + ) + """) + conn.commit() + logger.info(f"Tracks table '{table_name}' created or already exists in {PLAYLISTS_DB_PATH}.") + except sqlite3.Error as e: + logger.error(f"Error creating playlist tracks table {table_name} in {PLAYLISTS_DB_PATH}: {e}", exc_info=True) + raise + +def add_playlist_to_watch(playlist_data: dict): + """Adds a playlist to the watched_playlists table and creates its tracks table in playlists.db.""" + try: + _create_playlist_tracks_table(playlist_data['id']) + with _get_playlists_db_connection() as conn: # Use playlists connection + cursor = conn.cursor() + cursor.execute(""" + INSERT OR REPLACE INTO watched_playlists + (spotify_id, name, owner_id, owner_name, total_tracks, link, snapshot_id, last_checked, added_at, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1) + """, ( + playlist_data['id'], + playlist_data['name'], + playlist_data['owner']['id'], + playlist_data['owner'].get('display_name', playlist_data['owner']['id']), + playlist_data['tracks']['total'], + playlist_data['external_urls']['spotify'], + playlist_data.get('snapshot_id'), + int(time.time()), + int(time.time()) + )) + conn.commit() + logger.info(f"Playlist '{playlist_data['name']}' ({playlist_data['id']}) added to watchlist in {PLAYLISTS_DB_PATH}.") + except sqlite3.Error as e: + logger.error(f"Error adding playlist {playlist_data.get('id')} to watchlist in {PLAYLISTS_DB_PATH}: {e}", exc_info=True) + raise + +def remove_playlist_from_watch(playlist_spotify_id: str): + """Removes a playlist from watched_playlists and drops its tracks table in playlists.db.""" + table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" + try: + with _get_playlists_db_connection() as conn: # Use playlists connection + cursor = conn.cursor() + cursor.execute("DELETE FROM watched_playlists WHERE spotify_id = ?", (playlist_spotify_id,)) + cursor.execute(f"DROP TABLE IF EXISTS {table_name}") + conn.commit() + logger.info(f"Playlist {playlist_spotify_id} removed from watchlist and its table '{table_name}' dropped in {PLAYLISTS_DB_PATH}.") + except sqlite3.Error as e: + logger.error(f"Error removing playlist {playlist_spotify_id} from watchlist in {PLAYLISTS_DB_PATH}: {e}", exc_info=True) + raise + +def get_watched_playlists(): + """Retrieves all active playlists from the watched_playlists table in playlists.db.""" + try: + with _get_playlists_db_connection() as conn: # Use playlists connection + cursor = conn.cursor() + cursor.execute("SELECT * FROM watched_playlists WHERE is_active = 1") + playlists = [dict(row) for row in cursor.fetchall()] + return playlists + except sqlite3.Error as e: + logger.error(f"Error retrieving watched playlists from {PLAYLISTS_DB_PATH}: {e}", exc_info=True) + return [] + +def get_watched_playlist(playlist_spotify_id: str): + """Retrieves a specific playlist from the watched_playlists table in playlists.db.""" + try: + with _get_playlists_db_connection() as conn: # Use playlists connection + cursor = conn.cursor() + cursor.execute("SELECT * FROM watched_playlists WHERE spotify_id = ?", (playlist_spotify_id,)) + row = cursor.fetchone() + return dict(row) if row else None + except sqlite3.Error as e: + logger.error(f"Error retrieving playlist {playlist_spotify_id} from {PLAYLISTS_DB_PATH}: {e}", exc_info=True) + return None + +def update_playlist_snapshot(playlist_spotify_id: str, snapshot_id: str, total_tracks: int): + """Updates the snapshot_id and total_tracks for a watched playlist in playlists.db.""" + try: + with _get_playlists_db_connection() as conn: # Use playlists connection + cursor = conn.cursor() + cursor.execute(""" + UPDATE watched_playlists + SET snapshot_id = ?, total_tracks = ?, last_checked = ? + WHERE spotify_id = ? + """, (snapshot_id, total_tracks, int(time.time()), playlist_spotify_id)) + conn.commit() + except sqlite3.Error as e: + logger.error(f"Error updating snapshot for playlist {playlist_spotify_id} in {PLAYLISTS_DB_PATH}: {e}", exc_info=True) + +def get_playlist_track_ids_from_db(playlist_spotify_id: str): + """Retrieves all track Spotify IDs from a specific playlist's tracks table in playlists.db.""" + table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" + track_ids = set() + try: + with _get_playlists_db_connection() as conn: # Use playlists connection + cursor = conn.cursor() + cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';") + if cursor.fetchone() is None: + logger.warning(f"Track table {table_name} does not exist in {PLAYLISTS_DB_PATH}. Cannot fetch track IDs.") + return track_ids + cursor.execute(f"SELECT spotify_track_id FROM {table_name} WHERE is_present_in_spotify = 1") + rows = cursor.fetchall() + for row in rows: + track_ids.add(row['spotify_track_id']) + return track_ids + except sqlite3.Error as e: + logger.error(f"Error retrieving track IDs for playlist {playlist_spotify_id} from table {table_name} in {PLAYLISTS_DB_PATH}: {e}", exc_info=True) + return track_ids + +def add_tracks_to_playlist_db(playlist_spotify_id: str, tracks_data: list): + """ + Updates existing tracks in the playlist's DB table to mark them as currently present + in Spotify and updates their last_seen timestamp. Also refreshes metadata. + Does NOT insert new tracks. New tracks are only added upon successful download. + """ + table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" + if not tracks_data: + return + + current_time = int(time.time()) + tracks_to_update = [] + for track_item in tracks_data: + track = track_item.get('track') + if not track or not track.get('id'): + logger.warning(f"Skipping track update due to missing data or ID in playlist {playlist_spotify_id}: {track_item}") + continue + + artist_names = ", ".join([artist['name'] for artist in track.get('artists', []) if artist.get('name')]) + album_artist_names = ", ".join([artist['name'] for artist in track.get('album', {}).get('artists', []) if artist.get('name')]) + + # Prepare tuple for UPDATE statement. + # Order: title, artist_names, album_name, album_artist_names, track_number, + # album_spotify_id, duration_ms, added_at_playlist, + # is_present_in_spotify, last_seen_in_spotify, spotify_track_id (for WHERE) + tracks_to_update.append(( + track.get('name', 'N/A'), + artist_names, + track.get('album', {}).get('name', 'N/A'), + album_artist_names, + track.get('track_number'), + track.get('album', {}).get('id'), + track.get('duration_ms'), + track_item.get('added_at'), # From playlist item, update if changed + 1, # is_present_in_spotify flag + current_time, # last_seen_in_spotify timestamp + # added_to_db is NOT updated here as this function only updates existing records. + track['id'] # spotify_track_id for the WHERE clause + )) + + if not tracks_to_update: + logger.info(f"No valid tracks to prepare for update for playlist {playlist_spotify_id}.") + return + + try: + with _get_playlists_db_connection() as conn: # Use playlists connection + cursor = conn.cursor() + # The table should have been created when the playlist was added to watch + # or when the first track was successfully downloaded. + # _create_playlist_tracks_table(playlist_spotify_id) # Not strictly needed here if table creation is robust elsewhere. + + # The fields in SET must match the order of ?s, excluding the last one for WHERE. + # This will only update rows where spotify_track_id matches. + cursor.executemany(f""" + UPDATE {table_name} SET + title = ?, + artist_names = ?, + album_name = ?, + album_artist_names = ?, + track_number = ?, + album_spotify_id = ?, + duration_ms = ?, + added_at_playlist = ?, + is_present_in_spotify = ?, + last_seen_in_spotify = ? + WHERE spotify_track_id = ? + """, tracks_to_update) + conn.commit() + logger.info(f"Attempted to update metadata for {len(tracks_to_update)} tracks from API in DB for playlist {playlist_spotify_id}. Actual rows updated: {cursor.rowcount if cursor.rowcount != -1 else 'unknown'}.") + except sqlite3.Error as e: + logger.error(f"Error updating tracks in playlist {playlist_spotify_id} in table {table_name} in {PLAYLISTS_DB_PATH}: {e}", exc_info=True) + # Not raising here to allow other operations to continue if one batch fails. + +def mark_tracks_as_not_present_in_spotify(playlist_spotify_id: str, track_ids_to_mark: list): + """Marks specified tracks as not present in the Spotify playlist anymore in playlists.db.""" + table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" + if not track_ids_to_mark: + return + try: + with _get_playlists_db_connection() as conn: # Use playlists connection + cursor = conn.cursor() + placeholders = ','.join('?' for _ in track_ids_to_mark) + sql = f"UPDATE {table_name} SET is_present_in_spotify = 0 WHERE spotify_track_id IN ({placeholders})" + cursor.execute(sql, track_ids_to_mark) + conn.commit() + logger.info(f"Marked {cursor.rowcount} tracks as not present in Spotify for playlist {playlist_spotify_id} in {PLAYLISTS_DB_PATH}.") + except sqlite3.Error as e: + logger.error(f"Error marking tracks as not present for playlist {playlist_spotify_id} in {PLAYLISTS_DB_PATH}: {e}", exc_info=True) + +def add_specific_tracks_to_playlist_table(playlist_spotify_id: str, track_details_list: list): + """ + Adds specific tracks (with full details fetched separately) to the playlist's table. + This is used when a user manually marks tracks as "downloaded" or "known". + """ + table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" + if not track_details_list: + return + + current_time = int(time.time()) + tracks_to_insert = [] + + for track in track_details_list: # track here is assumed to be a full Spotify TrackObject + if not track or not track.get('id'): + logger.warning(f"Skipping track due to missing data or ID (manual add) in playlist {playlist_spotify_id}: {track}") + continue + + artist_names = ", ".join([artist['name'] for artist in track.get('artists', []) if artist.get('name')]) + album_artist_names = ", ".join([artist['name'] for artist in track.get('album', {}).get('artists', []) if artist.get('name')]) + + tracks_to_insert.append(( + track['id'], + track.get('name', 'N/A'), + artist_names, + track.get('album', {}).get('name', 'N/A'), + album_artist_names, + track.get('track_number'), + track.get('album', {}).get('id'), + track.get('duration_ms'), + None, # added_at_playlist - not known for manually added tracks this way + current_time, # added_to_db + 1, # is_present_in_spotify (assume user wants it considered present) + current_time # last_seen_in_spotify + )) + + if not tracks_to_insert: + logger.info(f"No valid tracks to insert (manual add) for playlist {playlist_spotify_id}.") + return + + try: + with _get_playlists_db_connection() as conn: # Use playlists connection + cursor = conn.cursor() + _create_playlist_tracks_table(playlist_spotify_id) # Ensure table exists + cursor.executemany(f""" + INSERT OR REPLACE INTO {table_name} + (spotify_track_id, title, artist_names, album_name, album_artist_names, track_number, album_spotify_id, duration_ms, added_at_playlist, added_to_db, is_present_in_spotify, last_seen_in_spotify) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, tracks_to_insert) + conn.commit() + logger.info(f"Manually added/updated {len(tracks_to_insert)} tracks in DB for playlist {playlist_spotify_id} in {PLAYLISTS_DB_PATH}.") + except sqlite3.Error as e: + logger.error(f"Error manually adding tracks to playlist {playlist_spotify_id} in table {table_name} in {PLAYLISTS_DB_PATH}: {e}", exc_info=True) + +def remove_specific_tracks_from_playlist_table(playlist_spotify_id: str, track_spotify_ids: list): + """Removes specific tracks from the playlist's local track table.""" + table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" + if not track_spotify_ids: + return 0 + + try: + with _get_playlists_db_connection() as conn: + cursor = conn.cursor() + placeholders = ','.join('?' for _ in track_spotify_ids) + # Check if table exists first + cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';") + if cursor.fetchone() is None: + logger.warning(f"Track table {table_name} does not exist. Cannot remove tracks.") + return 0 + + cursor.execute(f"DELETE FROM {table_name} WHERE spotify_track_id IN ({placeholders})", track_spotify_ids) + conn.commit() + deleted_count = cursor.rowcount + logger.info(f"Manually removed {deleted_count} tracks from DB for playlist {playlist_spotify_id}.") + return deleted_count + except sqlite3.Error as e: + logger.error(f"Error manually removing tracks for playlist {playlist_spotify_id} from table {table_name}: {e}", exc_info=True) + return 0 + +def add_single_track_to_playlist_db(playlist_spotify_id: str, track_item_for_db: dict): + """Adds or updates a single track in the specified playlist's tracks table in playlists.db.""" + table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" + track_detail = track_item_for_db.get('track') + if not track_detail or not track_detail.get('id'): + logger.warning(f"Skipping single track due to missing data for playlist {playlist_spotify_id}: {track_item_for_db}") + return + + current_time = int(time.time()) + artist_names = ", ".join([a['name'] for a in track_detail.get('artists', []) if a.get('name')]) + album_artist_names = ", ".join([a['name'] for a in track_detail.get('album', {}).get('artists', []) if a.get('name')]) + + track_data_tuple = ( + track_detail['id'], + track_detail.get('name', 'N/A'), + artist_names, + track_detail.get('album', {}).get('name', 'N/A'), + album_artist_names, + track_detail.get('track_number'), + track_detail.get('album', {}).get('id'), + track_detail.get('duration_ms'), + track_item_for_db.get('added_at'), + current_time, + 1, + current_time + ) + try: + with _get_playlists_db_connection() as conn: # Use playlists connection + cursor = conn.cursor() + _create_playlist_tracks_table(playlist_spotify_id) + cursor.execute(f""" + INSERT OR REPLACE INTO {table_name} + (spotify_track_id, title, artist_names, album_name, album_artist_names, track_number, album_spotify_id, duration_ms, added_at_playlist, added_to_db, is_present_in_spotify, last_seen_in_spotify) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, track_data_tuple) + conn.commit() + logger.info(f"Track '{track_detail.get('name')}' added/updated in DB for playlist {playlist_spotify_id} in {PLAYLISTS_DB_PATH}.") + except sqlite3.Error as e: + logger.error(f"Error adding single track to playlist {playlist_spotify_id} in {PLAYLISTS_DB_PATH}: {e}", exc_info=True) + +# --- Artist Watch Database Functions --- + +def init_artists_db(): + """Initializes the artists database and creates the watched_artists table if it doesn't exist.""" + try: + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS watched_artists ( + spotify_id TEXT PRIMARY KEY, + name TEXT, + total_albums_on_spotify INTEGER, + last_checked INTEGER, + added_at INTEGER, + is_active INTEGER DEFAULT 1, + last_known_status TEXT, + last_task_id TEXT + ) + """) + conn.commit() + logger.info(f"Artists database initialized successfully at {ARTISTS_DB_PATH}") + except sqlite3.Error as e: + logger.error(f"Error initializing watched_artists table in {ARTISTS_DB_PATH}: {e}", exc_info=True) + raise + +def _create_artist_albums_table(artist_spotify_id: str): + """Creates a table for a specific artist to store its albums if it doesn't exist in artists.db.""" + table_name = f"artist_{artist_spotify_id.replace('-', '_')}_albums" + try: + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(f""" + CREATE TABLE IF NOT EXISTS {table_name} ( + album_spotify_id TEXT PRIMARY KEY, + name TEXT, + album_group TEXT, + album_type TEXT, + release_date TEXT, + total_tracks INTEGER, + added_to_db_at INTEGER, + is_download_initiated INTEGER DEFAULT 0, + task_id TEXT, + last_checked_for_tracks INTEGER + ) + """) + conn.commit() + logger.info(f"Albums table '{table_name}' for artist {artist_spotify_id} created or exists in {ARTISTS_DB_PATH}.") + except sqlite3.Error as e: + logger.error(f"Error creating artist albums table {table_name} in {ARTISTS_DB_PATH}: {e}", exc_info=True) + raise + +def add_artist_to_watch(artist_data: dict): + """Adds an artist to the watched_artists table and creates its albums table in artists.db.""" + artist_id = artist_data.get('id') + if not artist_id: + logger.error("Cannot add artist to watch: Missing 'id' in artist_data.") + return + + try: + _create_artist_albums_table(artist_id) + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT OR REPLACE INTO watched_artists + (spotify_id, name, total_albums_on_spotify, last_checked, added_at, is_active) + VALUES (?, ?, ?, ?, ?, 1) + """, ( + artist_id, + artist_data.get('name', 'N/A'), + artist_data.get('albums', {}).get('total', 0), + int(time.time()), + int(time.time()) + )) + conn.commit() + logger.info(f"Artist '{artist_data.get('name')}' ({artist_id}) added to watchlist in {ARTISTS_DB_PATH}.") + except sqlite3.Error as e: + logger.error(f"Error adding artist {artist_id} to watchlist in {ARTISTS_DB_PATH}: {e}", exc_info=True) + raise + except KeyError as e: + logger.error(f"Missing key in artist_data for artist {artist_id}: {e}. Data: {artist_data}", exc_info=True) + raise + +def remove_artist_from_watch(artist_spotify_id: str): + """Removes an artist from watched_artists and drops its albums table in artists.db.""" + table_name = f"artist_{artist_spotify_id.replace('-', '_')}_albums" + try: + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM watched_artists WHERE spotify_id = ?", (artist_spotify_id,)) + cursor.execute(f"DROP TABLE IF EXISTS {table_name}") + conn.commit() + logger.info(f"Artist {artist_spotify_id} removed from watchlist and its table '{table_name}' dropped from {ARTISTS_DB_PATH}.") + except sqlite3.Error as e: + logger.error(f"Error removing artist {artist_spotify_id} from watchlist in {ARTISTS_DB_PATH}: {e}", exc_info=True) + raise + +def get_watched_artists(): + """Retrieves all active artists from the watched_artists table in artists.db.""" + try: + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT * FROM watched_artists WHERE is_active = 1") + artists = [dict(row) for row in cursor.fetchall()] + return artists + except sqlite3.Error as e: + logger.error(f"Error retrieving watched artists from {ARTISTS_DB_PATH}: {e}", exc_info=True) + return [] + +def get_watched_artist(artist_spotify_id: str): + """Retrieves a specific artist from the watched_artists table in artists.db.""" + try: + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT * FROM watched_artists WHERE spotify_id = ?", (artist_spotify_id,)) + row = cursor.fetchone() + return dict(row) if row else None + except sqlite3.Error as e: + logger.error(f"Error retrieving artist {artist_spotify_id} from {ARTISTS_DB_PATH}: {e}", exc_info=True) + return None + +def update_artist_metadata_after_check(artist_spotify_id: str, total_albums_from_api: int): + """Updates the total_albums_on_spotify and last_checked for an artist in artists.db.""" + try: + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + UPDATE watched_artists + SET total_albums_on_spotify = ?, last_checked = ? + WHERE spotify_id = ? + """, (total_albums_from_api, int(time.time()), artist_spotify_id)) + conn.commit() + except sqlite3.Error as e: + logger.error(f"Error updating metadata for artist {artist_spotify_id} in {ARTISTS_DB_PATH}: {e}", exc_info=True) + +def get_artist_album_ids_from_db(artist_spotify_id: str): + """Retrieves all album Spotify IDs from a specific artist's albums table in artists.db.""" + table_name = f"artist_{artist_spotify_id.replace('-', '_')}_albums" + album_ids = set() + try: + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';") + if cursor.fetchone() is None: + logger.warning(f"Album table {table_name} for artist {artist_spotify_id} does not exist in {ARTISTS_DB_PATH}. Cannot fetch album IDs.") + return album_ids + cursor.execute(f"SELECT album_spotify_id FROM {table_name}") + rows = cursor.fetchall() + for row in rows: + album_ids.add(row['album_spotify_id']) + return album_ids + except sqlite3.Error as e: + logger.error(f"Error retrieving album IDs for artist {artist_spotify_id} from {ARTISTS_DB_PATH}: {e}", exc_info=True) + return album_ids + +def add_or_update_album_for_artist(artist_spotify_id: str, album_data: dict, task_id: str = None, is_download_complete: bool = False): + """Adds or updates an album in the specified artist's albums table in artists.db.""" + table_name = f"artist_{artist_spotify_id.replace('-', '_')}_albums" + album_id = album_data.get('id') + if not album_id: + logger.warning(f"Skipping album for artist {artist_spotify_id} due to missing album ID: {album_data}") + return + + download_status = 0 + if task_id and not is_download_complete: + download_status = 1 + elif is_download_complete: + download_status = 2 + + current_time = int(time.time()) + album_tuple = ( + album_id, + album_data.get('name', 'N/A'), + album_data.get('album_group', 'N/A'), + album_data.get('album_type', 'N/A'), + album_data.get('release_date'), + album_data.get('total_tracks'), + current_time, + download_status, + task_id + ) + try: + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + _create_artist_albums_table(artist_spotify_id) + + cursor.execute(f"SELECT added_to_db_at FROM {table_name} WHERE album_spotify_id = ?", (album_id,)) + existing_row = cursor.fetchone() + + if existing_row: + update_tuple = ( + album_data.get('name', 'N/A'), + album_data.get('album_group', 'N/A'), + album_data.get('album_type', 'N/A'), + album_data.get('release_date'), + album_data.get('total_tracks'), + download_status, + task_id, + album_id + ) + cursor.execute(f""" + UPDATE {table_name} SET + name = ?, album_group = ?, album_type = ?, release_date = ?, total_tracks = ?, + is_download_initiated = ?, task_id = ? + WHERE album_spotify_id = ? + """, update_tuple) + logger.info(f"Updated album '{album_data.get('name')}' in DB for artist {artist_spotify_id} in {ARTISTS_DB_PATH}.") + else: + cursor.execute(f""" + INSERT INTO {table_name} + (album_spotify_id, name, album_group, album_type, release_date, total_tracks, added_to_db_at, is_download_initiated, task_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, album_tuple) + logger.info(f"Added album '{album_data.get('name')}' to DB for artist {artist_spotify_id} in {ARTISTS_DB_PATH}.") + conn.commit() + except sqlite3.Error as e: + logger.error(f"Error adding/updating album {album_id} for artist {artist_spotify_id} in {ARTISTS_DB_PATH}: {e}", exc_info=True) + +def update_album_download_status_for_artist(artist_spotify_id: str, album_spotify_id: str, task_id: str, status: int): + """Updates the download status (is_download_initiated) and task_id for a specific album of an artist in artists.db.""" + table_name = f"artist_{artist_spotify_id.replace('-', '_')}_albums" + try: + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(f""" + UPDATE {table_name} + SET is_download_initiated = ?, task_id = ? + WHERE album_spotify_id = ? + """, (status, task_id, album_spotify_id)) + if cursor.rowcount == 0: + logger.warning(f"Attempted to update download status for non-existent album {album_spotify_id} for artist {artist_spotify_id} in {ARTISTS_DB_PATH}.") + else: + logger.info(f"Updated download status to {status} for album {album_spotify_id} (task: {task_id}) for artist {artist_spotify_id} in {ARTISTS_DB_PATH}.") + conn.commit() + except sqlite3.Error as e: + logger.error(f"Error updating album download status for album {album_spotify_id}, artist {artist_spotify_id} in {ARTISTS_DB_PATH}: {e}", exc_info=True) + +def add_specific_albums_to_artist_table(artist_spotify_id: str, album_details_list: list): + """ + Adds specific albums (with full details fetched separately) to the artist's album table. + This can be used when a user manually marks albums as "known" or "processed". + Albums added this way are marked with is_download_initiated = 3 (Manually Added/Known). + """ + if not album_details_list: + logger.info(f"No album details provided to add specifically for artist {artist_spotify_id}.") + return 0 + + processed_count = 0 + for album_data in album_details_list: + if not album_data or not album_data.get('id'): + logger.warning(f"Skipping album due to missing data or ID (manual add) for artist {artist_spotify_id}: {album_data}") + continue + + # Use existing function to add/update, ensuring it handles manual state + # Set task_id to None and is_download_initiated to a specific state for manually added known albums + # The add_or_update_album_for_artist expects `is_download_complete` not `is_download_initiated` directly. + # We can adapt `add_or_update_album_for_artist` or pass status directly if it's modified to handle it. + # For now, let's pass task_id=None and a flag that implies manual addition (e.g. is_download_complete=True, and then modify add_or_update_album_for_artist status logic) + # Or, more directly, update the `is_download_initiated` field as part of the album_tuple for INSERT and in UPDATE. + # Let's stick to calling `add_or_update_album_for_artist` and adjust its status handling if needed. + # Setting `is_download_complete=True` and `task_id=None` should set `is_download_initiated = 2` (completed) + # We might need a new status like 3 for "Manually Marked as Known" + # For simplicity, we'll use `add_or_update_album_for_artist` and the status will be 'download_complete'. + # If a more distinct status is needed, `add_or_update_album_for_artist` would need adjustment. + + # Simplification: we'll call add_or_update_album_for_artist which will mark it based on task_id presence or completion. + # For a truly "manual" state distinct from "downloaded", `add_or_update_album_for_artist` would need a new status value. + # Let's assume for now that adding it via this function means it's "known" and doesn't need downloading. + # The `add_or_update_album_for_artist` function sets is_download_initiated based on task_id and is_download_complete. + # If task_id is None and is_download_complete is True, it implies it's processed. + try: + add_or_update_album_for_artist(artist_spotify_id, album_data, task_id=None, is_download_complete=True) + processed_count += 1 + except Exception as e: + logger.error(f"Error manually adding album {album_data.get('id')} for artist {artist_spotify_id}: {e}", exc_info=True) + + logger.info(f"Manually added/updated {processed_count} albums in DB for artist {artist_spotify_id} in {ARTISTS_DB_PATH}.") + return processed_count + +def remove_specific_albums_from_artist_table(artist_spotify_id: str, album_spotify_ids: list): + """Removes specific albums from the artist's local album table.""" + table_name = f"artist_{artist_spotify_id.replace('-', '_')}_albums" + if not album_spotify_ids: + return 0 + + try: + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + placeholders = ','.join('?' for _ in album_spotify_ids) + # Check if table exists first + cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';") + if cursor.fetchone() is None: + logger.warning(f"Album table {table_name} for artist {artist_spotify_id} does not exist. Cannot remove albums.") + return 0 + + cursor.execute(f"DELETE FROM {table_name} WHERE album_spotify_id IN ({placeholders})", album_spotify_ids) + conn.commit() + deleted_count = cursor.rowcount + logger.info(f"Manually removed {deleted_count} albums from DB for artist {artist_spotify_id}.") + return deleted_count + except sqlite3.Error as e: + logger.error(f"Error manually removing albums for artist {artist_spotify_id} from table {table_name}: {e}", exc_info=True) + return 0 + +def is_track_in_playlist_db(playlist_spotify_id: str, track_spotify_id: str) -> bool: + """Checks if a specific track Spotify ID exists in the given playlist's tracks table.""" + table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" + try: + with _get_playlists_db_connection() as conn: + cursor = conn.cursor() + # First, check if the table exists to prevent errors on non-watched or new playlists + cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';") + if cursor.fetchone() is None: + return False # Table doesn't exist, so track cannot be in it + + cursor.execute(f"SELECT 1 FROM {table_name} WHERE spotify_track_id = ?", (track_spotify_id,)) + return cursor.fetchone() is not None + except sqlite3.Error as e: + logger.error(f"Error checking if track {track_spotify_id} is in playlist {playlist_spotify_id} DB: {e}", exc_info=True) + return False # Assume not present on error + +def is_album_in_artist_db(artist_spotify_id: str, album_spotify_id: str) -> bool: + """Checks if a specific album Spotify ID exists in the given artist's albums table.""" + table_name = f"artist_{artist_spotify_id.replace('-', '_')}_albums" + try: + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + # First, check if the table exists + cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';") + if cursor.fetchone() is None: + return False # Table doesn't exist + + cursor.execute(f"SELECT 1 FROM {table_name} WHERE album_spotify_id = ?", (album_spotify_id,)) + return cursor.fetchone() is not None + except sqlite3.Error as e: + logger.error(f"Error checking if album {album_spotify_id} is in artist {artist_spotify_id} DB: {e}", exc_info=True) + return False # Assume not present on error \ No newline at end of file diff --git a/routes/utils/watch/manager.py b/routes/utils/watch/manager.py new file mode 100644 index 0000000..c056e21 --- /dev/null +++ b/routes/utils/watch/manager.py @@ -0,0 +1,420 @@ +import time +import threading +import logging +import json +from pathlib import Path + +from routes.utils.watch.db import ( + get_watched_playlists, + get_watched_playlist, + get_playlist_track_ids_from_db, + add_tracks_to_playlist_db, + update_playlist_snapshot, + mark_tracks_as_not_present_in_spotify, + # Artist watch DB functions + init_artists_db, + get_watched_artists, + get_watched_artist, + get_artist_album_ids_from_db, + add_or_update_album_for_artist, # Renamed from add_album_to_artist_db + update_artist_metadata_after_check # Renamed from update_artist_metadata +) +from routes.utils.get_info import get_spotify_info # To fetch playlist, track, artist, and album details +from routes.utils.celery_queue_manager import download_queue_manager, get_config_params + +logger = logging.getLogger(__name__) +CONFIG_PATH = Path('./data/config/watch.json') +STOP_EVENT = threading.Event() + +DEFAULT_WATCH_CONFIG = { + "enabled": False, + "watchPollIntervalSeconds": 3600, + "max_tracks_per_run": 50, # For playlists + "watchedArtistAlbumGroup": ["album", "single"], # Default for artists + "delay_between_playlists_seconds": 2, + "delay_between_artists_seconds": 5 # Added for artists +} + +def get_watch_config(): + """Loads the watch configuration from watch.json.""" + try: + if CONFIG_PATH.exists(): + with open(CONFIG_PATH, 'r') as f: + config = json.load(f) + # Ensure all default keys are present + for key, value in DEFAULT_WATCH_CONFIG.items(): + config.setdefault(key, value) + return config + else: + # Create a default config if it doesn't exist + with open(CONFIG_PATH, 'w') as f: + json.dump(DEFAULT_WATCH_CONFIG, f, indent=2) + logger.info(f"Created default watch config at {CONFIG_PATH}") + return DEFAULT_WATCH_CONFIG + except Exception as e: + logger.error(f"Error loading watch config: {e}", exc_info=True) + return DEFAULT_WATCH_CONFIG # Fallback + +def construct_spotify_url(item_id, item_type="track"): + return f"https://open.spotify.com/{item_type}/{item_id}" + +def check_watched_playlists(specific_playlist_id: str = None): + """Checks watched playlists for new tracks and queues downloads. + If specific_playlist_id is provided, only that playlist is checked. + """ + logger.info(f"Playlist Watch Manager: Starting check. Specific playlist: {specific_playlist_id or 'All'}") + config = get_watch_config() + + if specific_playlist_id: + playlist_obj = get_watched_playlist(specific_playlist_id) + if not playlist_obj: + logger.error(f"Playlist Watch Manager: Playlist {specific_playlist_id} not found in watch database.") + return + watched_playlists_to_check = [playlist_obj] + else: + watched_playlists_to_check = get_watched_playlists() + + if not watched_playlists_to_check: + logger.info("Playlist Watch Manager: No playlists to check.") + return + + for playlist_in_db in watched_playlists_to_check: + playlist_spotify_id = playlist_in_db['spotify_id'] + playlist_name = playlist_in_db['name'] + logger.info(f"Playlist Watch Manager: Checking playlist '{playlist_name}' ({playlist_spotify_id})...") + + try: + # For playlists, we fetch all tracks in one go usually (Spotify API limit permitting) + current_playlist_data_from_api = get_spotify_info(playlist_spotify_id, "playlist") + if not current_playlist_data_from_api or 'tracks' not in current_playlist_data_from_api: + logger.error(f"Playlist Watch Manager: Failed to fetch data or tracks from Spotify for playlist {playlist_spotify_id}.") + continue + + api_snapshot_id = current_playlist_data_from_api.get('snapshot_id') + api_total_tracks = current_playlist_data_from_api.get('tracks', {}).get('total', 0) + + # Paginate through playlist tracks if necessary + all_api_track_items = [] + offset = 0 + limit = 50 # Spotify API limit for playlist items + + while True: + # Re-fetch with pagination if tracks.next is present, or on first call. + # get_spotify_info for playlist should ideally handle pagination internally if asked for all tracks. + # Assuming get_spotify_info for playlist returns all items or needs to be called iteratively. + # For simplicity, let's assume current_playlist_data_from_api has 'tracks' -> 'items' for the first page. + # And that get_spotify_info with 'playlist' type can take offset. + # Modifying get_spotify_info is outside current scope, so we'll assume it returns ALL items for a playlist. + # If it doesn't, this part would need adjustment for robust pagination. + # For now, we use the items from the initial fetch. + + paginated_playlist_data = get_spotify_info(playlist_spotify_id, "playlist", offset=offset, limit=limit) + if not paginated_playlist_data or 'tracks' not in paginated_playlist_data: + break + + page_items = paginated_playlist_data.get('tracks', {}).get('items', []) + if not page_items: + break + all_api_track_items.extend(page_items) + + if paginated_playlist_data.get('tracks', {}).get('next'): + offset += limit + else: + break + + current_api_track_ids = set() + api_track_id_to_item_map = {} + for item in all_api_track_items: # Use all_api_track_items + track = item.get('track') + if track and track.get('id') and not track.get('is_local'): + track_id = track['id'] + current_api_track_ids.add(track_id) + api_track_id_to_item_map[track_id] = item + + db_track_ids = get_playlist_track_ids_from_db(playlist_spotify_id) + + new_track_ids_for_download = current_api_track_ids - db_track_ids + queued_for_download_count = 0 + if new_track_ids_for_download: + logger.info(f"Playlist Watch Manager: Found {len(new_track_ids_for_download)} new tracks for playlist '{playlist_name}' to download.") + for track_id in new_track_ids_for_download: + api_item = api_track_id_to_item_map.get(track_id) + if not api_item or not api_item.get("track"): + logger.warning(f"Playlist Watch Manager: Missing track details in API map for new track_id {track_id} in playlist {playlist_spotify_id}. Cannot queue.") + continue + + track_to_queue = api_item["track"] + task_payload = { + "download_type": "track", + "url": construct_spotify_url(track_id, "track"), + "name": track_to_queue.get('name', 'Unknown Track'), + "artist": ", ".join([a['name'] for a in track_to_queue.get('artists', []) if a.get('name')]), + "orig_request": { + "source": "playlist_watch", + "playlist_id": playlist_spotify_id, + "playlist_name": playlist_name, + "track_spotify_id": track_id, + "track_item_for_db": api_item # Pass full API item for DB update on completion + } + # "track_details_for_db" was old name, using track_item_for_db consistent with celery_tasks + } + try: + task_id_or_none = download_queue_manager.add_task(task_payload, from_watch_job=True) + if task_id_or_none: # Task was newly queued + logger.info(f"Playlist Watch Manager: Queued download task {task_id_or_none} for new track {track_id} ('{track_to_queue.get('name')}') from playlist '{playlist_name}'.") + queued_for_download_count += 1 + # If task_id_or_none is None, it was a duplicate and not re-queued, Celery manager handles logging. + except Exception as e: + logger.error(f"Playlist Watch Manager: Failed to queue download for new track {track_id} from playlist '{playlist_name}': {e}", exc_info=True) + logger.info(f"Playlist Watch Manager: Attempted to queue {queued_for_download_count} new tracks for playlist '{playlist_name}'.") + else: + logger.info(f"Playlist Watch Manager: No new tracks to download for playlist '{playlist_name}'.") + + # Update DB for tracks that are still present in API (e.g. update 'last_seen_in_spotify') + # add_tracks_to_playlist_db handles INSERT OR REPLACE, updating existing entries. + # We should pass all current API tracks to ensure their `last_seen_in_spotify` and `is_present_in_spotify` are updated. + if all_api_track_items: # If there are any tracks in the API for this playlist + logger.info(f"Playlist Watch Manager: Refreshing {len(all_api_track_items)} tracks from API in local DB for playlist '{playlist_name}'.") + add_tracks_to_playlist_db(playlist_spotify_id, all_api_track_items) + + + removed_db_ids = db_track_ids - current_api_track_ids + if removed_db_ids: + logger.info(f"Playlist Watch Manager: {len(removed_db_ids)} tracks removed from Spotify playlist '{playlist_name}'. Marking in DB.") + mark_tracks_as_not_present_in_spotify(playlist_spotify_id, list(removed_db_ids)) + + update_playlist_snapshot(playlist_spotify_id, api_snapshot_id, api_total_tracks) # api_total_tracks from initial fetch + logger.info(f"Playlist Watch Manager: Finished checking playlist '{playlist_name}'. Snapshot ID updated. API Total Tracks: {api_total_tracks}.") + + except Exception as e: + logger.error(f"Playlist Watch Manager: Error processing playlist {playlist_spotify_id}: {e}", exc_info=True) + + time.sleep(max(1, config.get("delay_between_playlists_seconds", 2))) + + logger.info("Playlist Watch Manager: Finished checking all watched playlists.") + +def check_watched_artists(specific_artist_id: str = None): + """Checks watched artists for new albums and queues downloads.""" + logger.info(f"Artist Watch Manager: Starting check. Specific artist: {specific_artist_id or 'All'}") + config = get_watch_config() + watched_album_groups = [g.lower() for g in config.get("watchedArtistAlbumGroup", ["album", "single"])] + logger.info(f"Artist Watch Manager: Watching for album groups: {watched_album_groups}") + + if specific_artist_id: + artist_obj_in_db = get_watched_artist(specific_artist_id) + if not artist_obj_in_db: + logger.error(f"Artist Watch Manager: Artist {specific_artist_id} not found in watch database.") + return + artists_to_check = [artist_obj_in_db] + else: + artists_to_check = get_watched_artists() + + if not artists_to_check: + logger.info("Artist Watch Manager: No artists to check.") + return + + for artist_in_db in artists_to_check: + artist_spotify_id = artist_in_db['spotify_id'] + artist_name = artist_in_db['name'] + logger.info(f"Artist Watch Manager: Checking artist '{artist_name}' ({artist_spotify_id})...") + + try: + # Spotify API for artist albums is paginated. + # We need to fetch all albums. get_spotify_info with type 'artist-albums' should handle this. + # Let's assume get_spotify_info(artist_id, 'artist-albums') returns a list of all album objects. + # Or we implement pagination here. + + all_artist_albums_from_api = [] + offset = 0 + limit = 50 # Spotify API limit for artist albums + while True: + # 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_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}") + break + + current_page_albums = artist_albums_page.get('items', []) + if not current_page_albums: + logger.info(f"Artist Watch Manager: No more albums on page for artist {artist_spotify_id} (name: '{artist_name}') at offset {offset}. Total fetched so far: {len(all_artist_albums_from_api)}.") + break + + logger.debug(f"Artist Watch Manager: Fetched {len(current_page_albums)} albums on current page for artist '{artist_name}'.") + all_artist_albums_from_api.extend(current_page_albums) + + # Correct pagination: Check if Spotify indicates a next page URL + # The `next` field in Spotify API responses is a URL to the next page or null. + if artist_albums_page.get('next'): + offset += limit # CORRECT: Increment offset by the limit used for the request + else: + logger.info(f"Artist Watch Manager: No 'next' page URL for artist '{artist_name}'. Pagination complete. Total albums fetched: {len(all_artist_albums_from_api)}.") + break + + # total_albums_from_api = len(all_artist_albums_from_api) + # Use the 'total' field from the API response for a more accurate count of all available albums (matching current API filter if any) + api_reported_total_albums = artist_albums_page.get('total', 0) if 'artist_albums_page' in locals() and artist_albums_page else len(all_artist_albums_from_api) + logger.info(f"Artist Watch Manager: Fetched {len(all_artist_albums_from_api)} albums in total from API for artist '{artist_name}'. API reports total: {api_reported_total_albums}.") + + db_album_ids = get_artist_album_ids_from_db(artist_spotify_id) + logger.info(f"Artist Watch Manager: Found {len(db_album_ids)} albums in DB for artist '{artist_name}'. These will be skipped if re-encountered unless logic changes.") + + queued_for_download_count = 0 + processed_album_ids_in_run = set() # To avoid processing duplicate album_ids if API returns them across pages (should not happen with correct pagination) + + for album_data in all_artist_albums_from_api: + album_id = album_data.get('id') + album_name = album_data.get('name', 'Unknown Album') + album_group = album_data.get('album_group', 'N/A').lower() + album_type = album_data.get('album_type', 'N/A').lower() + + if not album_id: + logger.warning(f"Artist Watch Manager: Skipping album without ID for artist '{artist_name}'. Album data: {album_data}") + continue + + if album_id in processed_album_ids_in_run: + logger.debug(f"Artist Watch Manager: Album '{album_name}' ({album_id}) already processed in this run. Skipping.") + continue + processed_album_ids_in_run.add(album_id) + + # Filter based on watchedArtistAlbumGroup + # The album_group field is generally preferred for this type of categorization as per Spotify docs. + is_matching_group = album_group in watched_album_groups + + logger.debug(f"Artist '{artist_name}', Album '{album_name}' ({album_id}): album_group='{album_group}', album_type='{album_type}'. Watched groups: {watched_album_groups}. Match: {is_matching_group}.") + + if not is_matching_group: + logger.debug(f"Artist Watch Manager: Skipping album '{album_name}' ({album_id}) by '{artist_name}' - group '{album_group}' not in watched list: {watched_album_groups}.") + continue + + logger.info(f"Artist Watch Manager: Album '{album_name}' ({album_id}) by '{artist_name}' (group: {album_group}) IS a matching group.") + + if album_id not in db_album_ids: + logger.info(f"Artist Watch Manager: Found NEW matching album '{album_name}' ({album_id}) by '{artist_name}'. Queuing for download.") + + album_artists_list = album_data.get('artists', []) + album_main_artist_name = album_artists_list[0].get('name', 'Unknown Artist') if album_artists_list else 'Unknown Artist' + + task_payload = { + "download_type": "album", # Or "track" if downloading individual tracks of album later + "url": construct_spotify_url(album_id, "album"), + "name": album_name, + "artist": album_main_artist_name, # Primary artist of the album + "orig_request": { + "source": "artist_watch", + "artist_spotify_id": artist_spotify_id, # Watched artist + "artist_name": artist_name, + "album_spotify_id": album_id, + "album_data_for_db": album_data # Pass full API album object for DB update on completion/queuing + } + } + try: + # Add to DB first with task_id, then queue. Or queue and add task_id to DB. + # Let's use add_or_update_album_for_artist to record it with a task_id before queuing. + # The celery_queue_manager.add_task might return None if it's a duplicate. + + # Record the album in DB as being processed for download + # Task_id will be added if successfully queued + + # We should call add_task first, and if it returns a task_id (not a duplicate), then update our DB. + task_id_or_none = download_queue_manager.add_task(task_payload, from_watch_job=True) + + if task_id_or_none: # Task was newly queued + # REMOVED: add_or_update_album_for_artist(artist_spotify_id, album_data, task_id=task_id_or_none, is_download_complete=False) + # The album will be added/updated in the DB by celery_tasks.py upon successful download completion. + logger.info(f"Artist Watch Manager: Queued download task {task_id_or_none} for new album '{album_name}' from artist '{artist_name}'. DB entry will be created/updated on success.") + queued_for_download_count += 1 + # If task_id_or_none is None, it was a duplicate. Celery manager handles logging. + + except Exception as e: + logger.error(f"Artist Watch Manager: Failed to queue download for new album {album_id} ('{album_name}') from artist '{artist_name}': {e}", exc_info=True) + else: + logger.info(f"Artist Watch Manager: Album '{album_name}' ({album_id}) by '{artist_name}' already known in DB (ID found in db_album_ids). Skipping queue.") + # Optionally, update its entry (e.g. last_seen, or if details changed), but for now, we only queue new ones. + # add_or_update_album_for_artist(artist_spotify_id, album_data, task_id=None, is_download_complete=False) # would update added_to_db_at + + logger.info(f"Artist Watch Manager: For artist '{artist_name}', processed {len(all_artist_albums_from_api)} API albums, attempted to queue {queued_for_download_count} new albums.") + + update_artist_metadata_after_check(artist_spotify_id, api_reported_total_albums) + logger.info(f"Artist Watch Manager: Finished checking artist '{artist_name}'. DB metadata updated. API reported total albums (for API filter): {api_reported_total_albums}.") + + except Exception as e: + logger.error(f"Artist Watch Manager: Error processing artist {artist_spotify_id} ('{artist_name}'): {e}", exc_info=True) + + time.sleep(max(1, config.get("delay_between_artists_seconds", 5))) + + logger.info("Artist Watch Manager: Finished checking all watched artists.") + +def playlist_watch_scheduler(): + """Periodically calls check_watched_playlists and check_watched_artists.""" + logger.info("Watch Scheduler: Thread started.") + config = get_watch_config() # Load config once at start, or reload each loop? Reload each loop for dynamic changes. + + while not STOP_EVENT.is_set(): + current_config = get_watch_config() # Get latest config for this run + interval = current_config.get("watchPollIntervalSeconds", 3600) + watch_enabled = current_config.get("enabled", False) # Get enabled status + + if not watch_enabled: + logger.info("Watch Scheduler: Watch feature is disabled in config. Skipping checks.") + STOP_EVENT.wait(interval) # Still respect poll interval for checking config again + continue # Skip to next iteration + + try: + logger.info("Watch Scheduler: Starting playlist check run.") + check_watched_playlists() + logger.info("Watch Scheduler: Playlist check run completed.") + except Exception as e: + logger.error(f"Watch Scheduler: Unhandled exception during check_watched_playlists: {e}", exc_info=True) + + # Add a small delay between playlist and artist checks if desired + # time.sleep(current_config.get("delay_between_check_types_seconds", 10)) + if STOP_EVENT.is_set(): break # Check stop event again before starting artist check + + try: + logger.info("Watch Scheduler: Starting artist check run.") + check_watched_artists() + logger.info("Watch Scheduler: Artist check run completed.") + except Exception as e: + logger.error(f"Watch Scheduler: Unhandled exception during check_watched_artists: {e}", exc_info=True) + + logger.info(f"Watch Scheduler: All checks complete. Next run in {interval} seconds.") + STOP_EVENT.wait(interval) + logger.info("Watch Scheduler: Thread stopped.") + +# --- Global thread for the scheduler --- +_watch_scheduler_thread = None # Renamed from _playlist_watch_thread + +def start_watch_manager(): # Renamed from start_playlist_watch_manager + global _watch_scheduler_thread + if _watch_scheduler_thread is None or not _watch_scheduler_thread.is_alive(): + STOP_EVENT.clear() + # Initialize DBs on start + from routes.utils.watch.db import init_playlists_db, init_artists_db # Updated import + init_playlists_db() # For playlists + init_artists_db() # For artists + + _watch_scheduler_thread = threading.Thread(target=playlist_watch_scheduler, daemon=True) + _watch_scheduler_thread.start() + logger.info("Watch Manager: Background scheduler started (includes playlists and artists).") + else: + logger.info("Watch Manager: Background scheduler already running.") + +def stop_watch_manager(): # Renamed from stop_playlist_watch_manager + global _watch_scheduler_thread + if _watch_scheduler_thread and _watch_scheduler_thread.is_alive(): + logger.info("Watch Manager: Stopping background scheduler...") + STOP_EVENT.set() + _watch_scheduler_thread.join(timeout=10) + if _watch_scheduler_thread.is_alive(): + logger.warning("Watch Manager: Scheduler thread did not stop in time.") + else: + logger.info("Watch Manager: Background scheduler stopped.") + _watch_scheduler_thread = None + else: + logger.info("Watch Manager: Background scheduler not running.") + +# If this module is imported, and you want to auto-start the manager, you could call start_watch_manager() here. +# However, it's usually better to explicitly start it from the main application/__init__.py. diff --git a/src/js/album.ts b/src/js/album.ts new file mode 100644 index 0000000..1d62863 --- /dev/null +++ b/src/js/album.ts @@ -0,0 +1,373 @@ +import { downloadQueue } from './queue.js'; + +// Define interfaces for API data +interface Image { + url: string; + height?: number; + width?: number; +} + +interface Artist { + id: string; + name: string; + external_urls: { + spotify: string; + }; +} + +interface Track { + id: string; + name: string; + artists: Artist[]; + duration_ms: number; + explicit: boolean; + external_urls: { + spotify: string; + }; +} + +interface Album { + id: string; + name: string; + artists: Artist[]; + images: Image[]; + release_date: string; + total_tracks: number; + label: string; + copyrights: { text: string; type: string }[]; + explicit: boolean; + tracks: { + items: Track[]; + // Add other properties from Spotify API if needed (e.g., total, limit, offset) + }; + external_urls: { + spotify: string; + }; + // Add other album properties if available +} + +document.addEventListener('DOMContentLoaded', () => { + const pathSegments = window.location.pathname.split('/'); + const albumId = pathSegments[pathSegments.indexOf('album') + 1]; + + if (!albumId) { + showError('No album ID provided.'); + return; + } + + // Fetch album info directly + fetch(`/api/album/info?id=${encodeURIComponent(albumId)}`) + .then(response => { + if (!response.ok) throw new Error('Network response was not ok'); + return response.json() as Promise; // Add Album type + }) + .then(data => renderAlbum(data)) + .catch(error => { + console.error('Error:', error); + showError('Failed to load album.'); + }); + + const queueIcon = document.getElementById('queueIcon'); + if (queueIcon) { + queueIcon.addEventListener('click', () => { + downloadQueue.toggleVisibility(); + }); + } +}); + +function renderAlbum(album: Album) { + // Hide loading and error messages. + const loadingEl = document.getElementById('loading'); + if (loadingEl) loadingEl.classList.add('hidden'); + + const errorSectionEl = document.getElementById('error'); // Renamed to avoid conflict with error var in catch + if (errorSectionEl) errorSectionEl.classList.add('hidden'); + + // Check if album itself is marked explicit and filter is enabled + const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled(); + if (isExplicitFilterEnabled && album.explicit) { + // Show placeholder for explicit album + const placeholderContent = ` +
+

Explicit Content Filtered

+

This album contains explicit content and has been filtered based on your settings.

+

The explicit content filter is controlled by environment variables.

+
+ `; + + const contentContainer = document.getElementById('album-header'); + if (contentContainer) { + contentContainer.innerHTML = placeholderContent; + contentContainer.classList.remove('hidden'); + } + + return; // Stop rendering the actual album content + } + + const baseUrl = window.location.origin; + + // Set album header info. + const albumNameEl = document.getElementById('album-name'); + if (albumNameEl) { + albumNameEl.innerHTML = + `${album.name || 'Unknown Album'}`; + } + + const albumArtistEl = document.getElementById('album-artist'); + if (albumArtistEl) { + albumArtistEl.innerHTML = + `By ${album.artists?.map(artist => + `${artist?.name || 'Unknown Artist'}` + ).join(', ') || 'Unknown Artist'}`; + } + + const releaseYear = album.release_date ? new Date(album.release_date).getFullYear() : 'N/A'; + const albumStatsEl = document.getElementById('album-stats'); + if (albumStatsEl) { + albumStatsEl.textContent = + `${releaseYear} • ${album.total_tracks || '0'} songs • ${album.label || 'Unknown Label'}`; + } + + const albumCopyrightEl = document.getElementById('album-copyright'); + if (albumCopyrightEl) { + albumCopyrightEl.textContent = + album.copyrights?.map(c => c?.text || '').filter(text => text).join(' • ') || ''; + } + + const imageSrc = album.images?.[0]?.url || '/static/images/placeholder.jpg'; + const albumImageEl = document.getElementById('album-image') as HTMLImageElement | null; + if (albumImageEl) { + albumImageEl.src = imageSrc; + } + + // Create (if needed) the Home Button. + let homeButton = document.getElementById('homeButton') as HTMLButtonElement | null; + if (!homeButton) { + homeButton = document.createElement('button'); + homeButton.id = 'homeButton'; + homeButton.className = 'home-btn'; + + const homeIcon = document.createElement('img'); + homeIcon.src = '/static/images/home.svg'; + homeIcon.alt = 'Home'; + homeButton.appendChild(homeIcon); + + // Insert as first child of album-header. + const headerContainer = document.getElementById('album-header'); + if (headerContainer) { // Null check + headerContainer.insertBefore(homeButton, headerContainer.firstChild); + } + } + if (homeButton) { // Null check + homeButton.addEventListener('click', () => { + window.location.href = window.location.origin; + }); + } + + // Check if any track in the album is explicit when filter is enabled + let hasExplicitTrack = false; + if (isExplicitFilterEnabled && album.tracks?.items) { + hasExplicitTrack = album.tracks.items.some(track => track && track.explicit); + } + + // Create (if needed) the Download Album Button. + let downloadAlbumBtn = document.getElementById('downloadAlbumBtn') as HTMLButtonElement | null; + if (!downloadAlbumBtn) { + downloadAlbumBtn = document.createElement('button'); + downloadAlbumBtn.id = 'downloadAlbumBtn'; + downloadAlbumBtn.textContent = 'Download Full Album'; + downloadAlbumBtn.className = 'download-btn download-btn--main'; + const albumHeader = document.getElementById('album-header'); + if (albumHeader) albumHeader.appendChild(downloadAlbumBtn); // Null check + } + + if (downloadAlbumBtn) { // Null check for downloadAlbumBtn + if (isExplicitFilterEnabled && hasExplicitTrack) { + // Disable the album download button and display a message explaining why + downloadAlbumBtn.disabled = true; + downloadAlbumBtn.classList.add('download-btn--disabled'); + downloadAlbumBtn.innerHTML = `Album Contains Explicit Tracks`; + } else { + // Normal behavior when no explicit tracks are present + downloadAlbumBtn.addEventListener('click', () => { + // Remove any other download buttons (keeping the full-album button in place). + document.querySelectorAll('.download-btn').forEach(btn => { + if (btn.id !== 'downloadAlbumBtn') btn.remove(); + }); + + if (downloadAlbumBtn) { // Inner null check + downloadAlbumBtn.disabled = true; + downloadAlbumBtn.textContent = 'Queueing...'; + } + + downloadWholeAlbum(album) + .then(() => { + if (downloadAlbumBtn) downloadAlbumBtn.textContent = 'Queued!'; // Inner null check + }) + .catch(err => { + showError('Failed to queue album download: ' + (err?.message || 'Unknown error')); + if (downloadAlbumBtn) downloadAlbumBtn.disabled = false; // Inner null check + }); + }); + } + } + + // Render each track. + const tracksList = document.getElementById('tracks-list'); + if (tracksList) { // Null check + tracksList.innerHTML = ''; + + if (album.tracks?.items) { + album.tracks.items.forEach((track, index) => { + if (!track) return; // Skip null or undefined tracks + + // Skip explicit tracks if filter is enabled + if (isExplicitFilterEnabled && track.explicit) { + // Add a placeholder for filtered explicit tracks + const trackElement = document.createElement('div'); + trackElement.className = 'track track-filtered'; + trackElement.innerHTML = ` +
${index + 1}
+
+
Explicit Content Filtered
+
This track is not shown due to explicit content filter settings
+
+
--:--
+ `; + tracksList.appendChild(trackElement); + return; + } + + const trackElement = document.createElement('div'); + trackElement.className = 'track'; + trackElement.innerHTML = ` +
${index + 1}
+
+ +
+ ${track.artists?.map(a => + `${a?.name || 'Unknown Artist'}` + ).join(', ') || 'Unknown Artist'} +
+
+
${msToTime(track.duration_ms || 0)}
+ + `; + tracksList.appendChild(trackElement); + }); + } + } + + // Reveal header and track list. + const albumHeaderEl = document.getElementById('album-header'); + if (albumHeaderEl) albumHeaderEl.classList.remove('hidden'); + + const tracksContainerEl = document.getElementById('tracks-container'); + if (tracksContainerEl) tracksContainerEl.classList.remove('hidden'); + attachDownloadListeners(); + + // If on a small screen, re-arrange the action buttons. + if (window.innerWidth <= 480) { + let actionsContainer = document.getElementById('album-actions'); + if (!actionsContainer) { + actionsContainer = document.createElement('div'); + actionsContainer.id = 'album-actions'; + const albumHeader = document.getElementById('album-header'); + if (albumHeader) albumHeader.appendChild(actionsContainer); // Null check + } + if (actionsContainer) { // Null check for actionsContainer + actionsContainer.innerHTML = ''; // Clear any previous content + const homeBtn = document.getElementById('homeButton'); + if (homeBtn) actionsContainer.appendChild(homeBtn); // Null check + + const dlAlbumBtn = document.getElementById('downloadAlbumBtn'); + if (dlAlbumBtn) actionsContainer.appendChild(dlAlbumBtn); // Null check + + const queueToggle = document.querySelector('.queue-toggle'); + if (queueToggle) { + actionsContainer.appendChild(queueToggle); + } + } + } +} + +async function downloadWholeAlbum(album: Album) { + const albumIdToDownload = album.id || ''; + if (!albumIdToDownload) { + throw new Error('Missing album ID'); + } + + try { + // Use the centralized downloadQueue.download method + await downloadQueue.download(albumIdToDownload, 'album', { name: album.name || 'Unknown Album' }); + // Make the queue visible after queueing + downloadQueue.toggleVisibility(true); + } catch (error: any) { // Add type for error + showError('Album download failed: ' + (error?.message || 'Unknown error')); + throw error; + } +} + +function msToTime(duration: number): string { + const minutes = Math.floor(duration / 60000); + const seconds = ((duration % 60000) / 1000).toFixed(0); + return `${minutes}:${seconds.padStart(2, '0')}`; +} + +function showError(message: string) { + const errorEl = document.getElementById('error'); + if (errorEl) { // Null check + errorEl.textContent = message || 'An error occurred'; + errorEl.classList.remove('hidden'); + } +} + +function attachDownloadListeners() { + document.querySelectorAll('.download-btn').forEach((btn) => { + const button = btn as HTMLButtonElement; // Cast to HTMLButtonElement + if (button.id === 'downloadAlbumBtn') return; + button.addEventListener('click', (e) => { + e.stopPropagation(); + const currentTarget = e.currentTarget as HTMLButtonElement | null; // Cast currentTarget + if (!currentTarget) return; + + const itemId = currentTarget.dataset.id || ''; + const type = currentTarget.dataset.type || ''; + const name = currentTarget.dataset.name || 'Unknown'; + + if (!itemId) { + showError('Missing item ID for download in album page'); + return; + } + // Remove the button immediately after click. + currentTarget.remove(); + startDownload(itemId, type, { name }); // albumType will be undefined + }); + }); +} + +async function startDownload(itemId: string, type: string, item: { name: string }, albumType?: string) { // Add types and make albumType optional + if (!itemId || !type) { + showError('Missing ID or type for download'); + return Promise.reject(new Error('Missing ID or type for download')); // Return a rejected promise + } + + try { + // Use the centralized downloadQueue.download method + await downloadQueue.download(itemId, type, item, albumType); + + // Make the queue visible after queueing + downloadQueue.toggleVisibility(true); + } catch (error: any) { // Add type for error + showError('Download failed: ' + (error?.message || 'Unknown error')); + throw error; + } +} diff --git a/src/js/artist.ts b/src/js/artist.ts new file mode 100644 index 0000000..83d9a76 --- /dev/null +++ b/src/js/artist.ts @@ -0,0 +1,765 @@ +// Import the downloadQueue singleton +import { downloadQueue } from './queue.js'; + +// Define interfaces for API data +interface Image { + url: string; + height?: number; + width?: number; +} + +interface Artist { + id: string; + name: string; + external_urls: { + spotify: string; + }; +} + +interface Album { + id: string; + name: string; + artists: Artist[]; + images: Image[]; + album_type: string; // "album", "single", "compilation" + album_group?: string; // "album", "single", "compilation", "appears_on" + external_urls: { + spotify: string; + }; + explicit?: boolean; // Added to handle explicit filter + total_tracks?: number; + release_date?: string; + is_locally_known?: boolean; // Added for local DB status +} + +interface ArtistData { + items: Album[]; + total: number; + // Add other properties if available from the API + // For watch status, the artist object itself might have `is_watched` if we extend API + // For now, we fetch status separately. +} + +// Interface for watch status response +interface WatchStatusResponse { + is_watched: boolean; + artist_data?: any; // The artist data from DB if watched +} + +document.addEventListener('DOMContentLoaded', () => { + const pathSegments = window.location.pathname.split('/'); + const artistId = pathSegments[pathSegments.indexOf('artist') + 1]; + + if (!artistId) { + showError('No artist ID provided.'); + return; + } + + // Fetch artist info directly + fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}`) + .then(response => { + if (!response.ok) throw new Error('Network response was not ok'); + return response.json() as Promise; + }) + .then(data => renderArtist(data, artistId)) + .catch(error => { + console.error('Error:', error); + showError('Failed to load artist info.'); + }); + + const queueIcon = document.getElementById('queueIcon'); + if (queueIcon) { + queueIcon.addEventListener('click', () => downloadQueue.toggleVisibility()); + } + + // Initialize the watch button after main artist rendering + // This is done inside renderArtist after button element is potentially created. +}); + +async function renderArtist(artistData: ArtistData, artistId: string) { + const loadingEl = document.getElementById('loading'); + if (loadingEl) loadingEl.classList.add('hidden'); + + const errorEl = document.getElementById('error'); + if (errorEl) errorEl.classList.add('hidden'); + + // Fetch watch status upfront to avoid race conditions for album button rendering + const isArtistActuallyWatched = await getArtistWatchStatus(artistId); + + // Check if explicit filter is enabled + const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled(); + + const firstAlbum = artistData.items?.[0]; + const artistName = firstAlbum?.artists?.[0]?.name || 'Unknown Artist'; + const artistImageSrc = firstAlbum?.images?.[0]?.url || '/static/images/placeholder.jpg'; + + const artistNameEl = document.getElementById('artist-name'); + if (artistNameEl) { + artistNameEl.innerHTML = + `${artistName}`; + } + const artistStatsEl = document.getElementById('artist-stats'); + if (artistStatsEl) { + artistStatsEl.textContent = `${artistData.total || '0'} albums`; + } + const artistImageEl = document.getElementById('artist-image') as HTMLImageElement | null; + if (artistImageEl) { + artistImageEl.src = artistImageSrc; + } + + // Initialize Watch Button after other elements are rendered + const watchArtistBtn = document.getElementById('watchArtistBtn') as HTMLButtonElement | null; + if (watchArtistBtn) { + initializeWatchButton(artistId, isArtistActuallyWatched); + } else { + console.warn("Watch artist button not found in HTML."); + } + + // Define the artist URL (used by both full-discography and group downloads) + // const artistUrl = `https://open.spotify.com/artist/${artistId}`; // Not directly used here anymore + + // Home Button + let homeButton = document.getElementById('homeButton') as HTMLButtonElement | null; + if (!homeButton) { + homeButton = document.createElement('button'); + homeButton.id = 'homeButton'; + homeButton.className = 'home-btn'; + homeButton.innerHTML = `Home`; + const artistHeader = document.getElementById('artist-header'); + if (artistHeader) artistHeader.prepend(homeButton); + } + if (homeButton) { + homeButton.addEventListener('click', () => window.location.href = window.location.origin); + } + + // Download Whole Artist Button using the new artist API endpoint + let downloadArtistBtn = document.getElementById('downloadArtistBtn') as HTMLButtonElement | null; + if (!downloadArtistBtn) { + downloadArtistBtn = document.createElement('button'); + downloadArtistBtn.id = 'downloadArtistBtn'; + downloadArtistBtn.className = 'download-btn download-btn--main'; + downloadArtistBtn.textContent = 'Download All Discography'; + const artistHeader = document.getElementById('artist-header'); + if (artistHeader) artistHeader.appendChild(downloadArtistBtn); + } + + // When explicit filter is enabled, disable all download buttons + if (isExplicitFilterEnabled) { + if (downloadArtistBtn) { + downloadArtistBtn.disabled = true; + downloadArtistBtn.classList.add('download-btn--disabled'); + downloadArtistBtn.innerHTML = `Downloads Restricted`; + } + } else { + if (downloadArtistBtn) { + downloadArtistBtn.addEventListener('click', () => { + document.querySelectorAll('.download-btn:not(#downloadArtistBtn)').forEach(btn => btn.remove()); + if (downloadArtistBtn) { + downloadArtistBtn.disabled = true; + downloadArtistBtn.textContent = 'Queueing...'; + } + startDownload( + artistId, + 'artist', + { name: artistName, artist: artistName }, + 'album,single,compilation,appears_on' + ) + .then((taskIds) => { + if (downloadArtistBtn) { + downloadArtistBtn.textContent = 'Artist queued'; + downloadQueue.toggleVisibility(true); + if (Array.isArray(taskIds)) { + downloadArtistBtn.title = `${taskIds.length} albums queued for download`; + } + } + }) + .catch(err => { + if (downloadArtistBtn) { + downloadArtistBtn.textContent = 'Download All Discography'; + downloadArtistBtn.disabled = false; + } + showError('Failed to queue artist download: ' + (err?.message || 'Unknown error')); + }); + }); + } + } + + const albumGroups: Record = {}; + const appearingAlbums: Album[] = []; + + (artistData.items || []).forEach(album => { + if (!album) return; + if (isExplicitFilterEnabled && album.explicit) { + return; + } + if (album.album_group === 'appears_on') { + appearingAlbums.push(album); + } else { + const type = (album.album_type || 'unknown').toLowerCase(); + if (!albumGroups[type]) albumGroups[type] = []; + albumGroups[type].push(album); + } + }); + + const groupsContainer = document.getElementById('album-groups'); + if (groupsContainer) { + groupsContainer.innerHTML = ''; + + // Use the definitively fetched watch status for rendering album buttons + // const isArtistWatched = watchArtistBtn && watchArtistBtn.dataset.watching === 'true'; // Old way + const useThisWatchStatusForAlbums = isArtistActuallyWatched; // New way + + for (const [groupType, albums] of Object.entries(albumGroups)) { + const groupSection = document.createElement('section'); + groupSection.className = 'album-group'; + + const groupHeaderHTML = isExplicitFilterEnabled ? + `
+

${capitalize(groupType)}s

+
Visit album pages to download content
+
` : + `
+

${capitalize(groupType)}s

+ +
`; + + groupSection.innerHTML = groupHeaderHTML; + const albumsListContainer = document.createElement('div'); + albumsListContainer.className = 'albums-list'; + + albums.forEach(album => { + if (!album) return; + const albumElement = document.createElement('div'); + albumElement.className = 'album-card'; + albumElement.dataset.albumId = album.id; + + let albumCardHTML = ` + + Album cover + +
+
${album.name || 'Unknown Album'}
+
${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}
+
+ `; + albumElement.innerHTML = albumCardHTML; + + const albumCardActions = document.createElement('div'); + albumCardActions.className = 'album-card-actions'; + + // Persistent Mark as Known/Missing button (if artist is watched) - Appears first (left) + if (useThisWatchStatusForAlbums && album.id) { + const toggleKnownBtn = document.createElement('button'); + toggleKnownBtn.className = 'toggle-known-status-btn persistent-album-action-btn'; + toggleKnownBtn.dataset.albumId = album.id; + + if (album.is_locally_known) { + toggleKnownBtn.dataset.status = 'known'; + toggleKnownBtn.innerHTML = 'Mark as missing'; + toggleKnownBtn.title = 'Mark album as not in local library (Missing)'; + toggleKnownBtn.classList.add('status-known'); // Green + } else { + toggleKnownBtn.dataset.status = 'missing'; + toggleKnownBtn.innerHTML = 'Mark as known'; + toggleKnownBtn.title = 'Mark album as in local library (Known)'; + toggleKnownBtn.classList.add('status-missing'); // Red + } + albumCardActions.appendChild(toggleKnownBtn); // Add to actions container + } + + // Persistent Download Button (if not explicit filter) - Appears second (right) + if (!isExplicitFilterEnabled) { + const downloadBtn = document.createElement('button'); + downloadBtn.className = 'download-btn download-btn--circle persistent-download-btn'; + downloadBtn.innerHTML = 'Download album'; + downloadBtn.title = 'Download this album'; + downloadBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + downloadBtn.disabled = true; + downloadBtn.innerHTML = 'Queueing...'; + startDownload(album.id, 'album', { name: album.name, artist: album.artists?.[0]?.name || 'Unknown Artist', type: 'album' }) + .then(() => { + downloadBtn.innerHTML = 'Queued'; + showNotification(`Album '${album.name}' queued for download.`); + downloadQueue.toggleVisibility(true); + }) + .catch(err => { + downloadBtn.disabled = false; + downloadBtn.innerHTML = 'Download album'; + showError(`Failed to queue album: ${err?.message || 'Unknown error'}`); + }); + }); + albumCardActions.appendChild(downloadBtn); // Add to actions container + } + + // Only append albumCardActions if it has any buttons + if (albumCardActions.hasChildNodes()) { + albumElement.appendChild(albumCardActions); + } + + albumsListContainer.appendChild(albumElement); + }); + groupSection.appendChild(albumsListContainer); + groupsContainer.appendChild(groupSection); + } + + if (appearingAlbums.length > 0) { + const featuringSection = document.createElement('section'); + featuringSection.className = 'album-group'; + const featuringHeaderHTML = isExplicitFilterEnabled ? + `
+

Featuring

+
Visit album pages to download content
+
` : + `
+

Featuring

+ +
`; + featuringSection.innerHTML = featuringHeaderHTML; + const appearingAlbumsListContainer = document.createElement('div'); + appearingAlbumsListContainer.className = 'albums-list'; + + appearingAlbums.forEach(album => { + if (!album) return; + const albumElement = document.createElement('div'); + albumElement.className = 'album-card'; + albumElement.dataset.albumId = album.id; // Set dataset for appears_on albums too + + let albumCardHTML = ` + + Album cover + +
+
${album.name || 'Unknown Album'}
+
${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}
+
+ `; + albumElement.innerHTML = albumCardHTML; + + const albumCardActions_AppearsOn = document.createElement('div'); + albumCardActions_AppearsOn.className = 'album-card-actions'; + + // Persistent Mark as Known/Missing button for appearing_on albums (if artist is watched) - Appears first (left) + if (useThisWatchStatusForAlbums && album.id) { + const toggleKnownBtn = document.createElement('button'); + toggleKnownBtn.className = 'toggle-known-status-btn persistent-album-action-btn'; + toggleKnownBtn.dataset.albumId = album.id; + if (album.is_locally_known) { + toggleKnownBtn.dataset.status = 'known'; + toggleKnownBtn.innerHTML = 'Mark as missing'; + toggleKnownBtn.title = 'Mark album as not in local library (Missing)'; + toggleKnownBtn.classList.add('status-known'); // Green + } else { + toggleKnownBtn.dataset.status = 'missing'; + toggleKnownBtn.innerHTML = 'Mark as known'; + toggleKnownBtn.title = 'Mark album as in local library (Known)'; + toggleKnownBtn.classList.add('status-missing'); // Red + } + albumCardActions_AppearsOn.appendChild(toggleKnownBtn); // Add to actions container + } + + // Persistent Download Button for appearing_on albums (if not explicit filter) - Appears second (right) + if (!isExplicitFilterEnabled) { + const downloadBtn = document.createElement('button'); + downloadBtn.className = 'download-btn download-btn--circle persistent-download-btn'; + downloadBtn.innerHTML = 'Download album'; + downloadBtn.title = 'Download this album'; + downloadBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + downloadBtn.disabled = true; + downloadBtn.innerHTML = 'Queueing...'; + startDownload(album.id, 'album', { name: album.name, artist: album.artists?.[0]?.name || 'Unknown Artist', type: 'album' }) + .then(() => { + downloadBtn.innerHTML = 'Queued'; + showNotification(`Album '${album.name}' queued for download.`); + downloadQueue.toggleVisibility(true); + }) + .catch(err => { + downloadBtn.disabled = false; + downloadBtn.innerHTML = 'Download album'; + showError(`Failed to queue album: ${err?.message || 'Unknown error'}`); + }); + }); + albumCardActions_AppearsOn.appendChild(downloadBtn); // Add to actions container + } + + // Only append albumCardActions_AppearsOn if it has any buttons + if (albumCardActions_AppearsOn.hasChildNodes()) { + albumElement.appendChild(albumCardActions_AppearsOn); + } + + appearingAlbumsListContainer.appendChild(albumElement); + }); + featuringSection.appendChild(appearingAlbumsListContainer); + groupsContainer.appendChild(featuringSection); + } + } + + const artistHeaderEl = document.getElementById('artist-header'); + if (artistHeaderEl) artistHeaderEl.classList.remove('hidden'); + const albumsContainerEl = document.getElementById('albums-container'); + if (albumsContainerEl) albumsContainerEl.classList.remove('hidden'); + + if (!isExplicitFilterEnabled) { + attachAlbumActionListeners(artistId); + attachGroupDownloadListeners(artistId, artistName); + } +} + +function attachGroupDownloadListeners(artistId: string, artistName: string) { + document.querySelectorAll('.group-download-btn').forEach(btn => { + const button = btn as HTMLButtonElement; + button.addEventListener('click', async (e) => { + const target = e.target as HTMLButtonElement | null; + if (!target) return; + const groupType = target.dataset.groupType || 'album'; + target.disabled = true; + const displayType = groupType === 'appears_on' ? 'Featuring Albums' : `${capitalize(groupType)}s`; + target.textContent = `Queueing all ${displayType}...`; + try { + const taskIds = await startDownload( + artistId, + 'artist', + { name: artistName || 'Unknown Artist', artist: artistName || 'Unknown Artist' }, + groupType + ); + const totalQueued = Array.isArray(taskIds) ? taskIds.length : 0; + target.textContent = `Queued all ${displayType}`; + target.title = `${totalQueued} albums queued for download`; + downloadQueue.toggleVisibility(true); + } catch (error: any) { + target.textContent = `Download All ${displayType}`; + target.disabled = false; + showError(`Failed to queue download for all ${groupType}: ${error?.message || 'Unknown error'}`); + } + }); + }); +} + +function attachAlbumActionListeners(artistIdForContext: string) { + const groupsContainer = document.getElementById('album-groups'); + if (!groupsContainer) return; + + groupsContainer.addEventListener('click', async (event) => { + const target = event.target as HTMLElement; + const button = target.closest('.toggle-known-status-btn') as HTMLButtonElement | null; + + if (button && button.dataset.albumId) { + const albumId = button.dataset.albumId; + const currentStatus = button.dataset.status; + + // Optimistic UI update + button.disabled = true; + const originalIcon = button.innerHTML; // Save original icon + button.innerHTML = 'Updating...'; + + try { + if (currentStatus === 'known') { + await handleMarkAlbumAsMissing(artistIdForContext, albumId); + button.dataset.status = 'missing'; + button.innerHTML = 'Mark as known'; // Update to missing.svg + button.title = 'Mark album as in local library (Known)'; + button.classList.remove('status-known'); + button.classList.add('status-missing'); + const albumCard = button.closest('.album-card') as HTMLElement | null; + if (albumCard) { + const coverImg = albumCard.querySelector('.album-cover') as HTMLImageElement | null; + if (coverImg) coverImg.classList.add('album-missing-in-db'); + } + showNotification(`Album marked as missing from local library.`); + } else { + await handleMarkAlbumAsKnown(artistIdForContext, albumId); + button.dataset.status = 'known'; + button.innerHTML = 'Mark as missing'; // Update to check.svg + button.title = 'Mark album as not in local library (Missing)'; + button.classList.remove('status-missing'); + button.classList.add('status-known'); + const albumCard = button.closest('.album-card') as HTMLElement | null; + if (albumCard) { + const coverImg = albumCard.querySelector('.album-cover') as HTMLImageElement | null; + if (coverImg) coverImg.classList.remove('album-missing-in-db'); + } + showNotification(`Album marked as present in local library.`); + } + } catch (error) { + console.error('Failed to update album status:', error); + showError('Failed to update album status. Please try again.'); + // Revert UI on error + button.dataset.status = currentStatus; // Revert status + button.innerHTML = originalIcon; // Revert icon + // Revert card style if needed (though if API failed, actual state is unchanged) + } finally { + button.disabled = false; // Re-enable button + } + } + }); +} + +async function handleMarkAlbumAsKnown(artistId: string, albumId: string) { + // Ensure albumId is a string and not undefined. + if (!albumId || typeof albumId !== 'string') { + console.error('Invalid albumId provided to handleMarkAlbumAsKnown:', albumId); + throw new Error('Invalid album ID.'); + } + const response = await fetch(`/api/artist/watch/${artistId}/albums`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify([albumId]) // API expects an array of album IDs + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: 'Failed to mark album as known.' })); + throw new Error(errorData.message || `HTTP error! status: ${response.status}`); + } + return response.json(); +} + +async function handleMarkAlbumAsMissing(artistId: string, albumId: string) { + // Ensure albumId is a string and not undefined. + if (!albumId || typeof albumId !== 'string') { + console.error('Invalid albumId provided to handleMarkAlbumAsMissing:', albumId); + throw new Error('Invalid album ID.'); + } + const response = await fetch(`/api/artist/watch/${artistId}/albums`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify([albumId]) // API expects an array of album IDs + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: 'Failed to mark album as missing.' })); + throw new Error(errorData.message || `HTTP error! status: ${response.status}`); + } + // For DELETE, Spotify often returns 204 No Content, or we might return custom JSON. + // If expecting JSON: + // return response.json(); + // If handling 204 or simple success message: + const result = await response.json(); // Assuming the backend sends a JSON response + console.log('Mark as missing result:', result); + return result; +} + +// Add startDownload function (similar to track.js and main.js) +/** + * Starts the download process via centralized download queue + */ +async function startDownload(itemId: string, type: string, item: { name: string, artist?: string, type?: string }, albumType?: string) { + if (!itemId || !type) { + showError('Missing ID or type for download'); + return Promise.reject(new Error('Missing ID or type for download')); // Return a rejected promise + } + + try { + // Use the centralized downloadQueue.download method for all downloads including artist downloads + const result = await downloadQueue.download(itemId, type, item, albumType); + + // Make the queue visible after queueing + downloadQueue.toggleVisibility(true); + + // Return the result for tracking + return result; + } catch (error: any) { // Add type for error + showError('Download failed: ' + (error?.message || 'Unknown error')); + throw error; + } +} + +// UI Helpers +function showError(message: string) { + const errorEl = document.getElementById('error'); + if (errorEl) { + errorEl.textContent = message || 'An error occurred'; + errorEl.classList.remove('hidden'); + } +} + +function capitalize(str: string) { + return str ? str.charAt(0).toUpperCase() + str.slice(1) : ''; +} + +async function getArtistWatchStatus(artistId: string): Promise { + try { + const response = await fetch(`/api/artist/watch/${artistId}/status`); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); // Catch if res not json + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + const data: WatchStatusResponse = await response.json(); + return data.is_watched; + } catch (error) { + console.error('Error fetching artist watch status:', error); + showError('Could not fetch watch status.'); + return false; // Assume not watching on error + } +} + +async function watchArtist(artistId: string): Promise { + try { + const response = await fetch(`/api/artist/watch/${artistId}`, { + method: 'PUT', + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + // Optionally handle success message from response.json() + await response.json(); + } catch (error) { + console.error('Error watching artist:', error); + showError('Failed to watch artist.'); + throw error; // Re-throw to allow caller to handle UI update failure + } +} + +async function unwatchArtist(artistId: string): Promise { + try { + const response = await fetch(`/api/artist/watch/${artistId}`, { + method: 'DELETE', + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + // Optionally handle success message + await response.json(); + } catch (error) { + console.error('Error unwatching artist:', error); + showError('Failed to unwatch artist.'); + throw error; // Re-throw + } +} + +function updateWatchButton(artistId: string, isWatching: boolean) { + const watchArtistBtn = document.getElementById('watchArtistBtn') as HTMLButtonElement | null; + const syncArtistBtn = document.getElementById('syncArtistBtn') as HTMLButtonElement | null; + + if (watchArtistBtn) { + const img = watchArtistBtn.querySelector('img'); + if (isWatching) { + if (img) img.src = '/static/images/eye-crossed.svg'; + watchArtistBtn.innerHTML = `Unwatch Unwatch Artist`; + watchArtistBtn.classList.add('watching'); + watchArtistBtn.title = "Stop watching this artist"; + if (syncArtistBtn) syncArtistBtn.classList.remove('hidden'); + } else { + if (img) img.src = '/static/images/eye.svg'; + watchArtistBtn.innerHTML = `Watch Watch Artist`; + watchArtistBtn.classList.remove('watching'); + watchArtistBtn.title = "Watch this artist for new releases"; + if (syncArtistBtn) syncArtistBtn.classList.add('hidden'); + } + watchArtistBtn.dataset.watching = isWatching ? 'true' : 'false'; + } +} + +async function initializeWatchButton(artistId: string, initialIsWatching: boolean) { + const watchArtistBtn = document.getElementById('watchArtistBtn') as HTMLButtonElement | null; + const syncArtistBtn = document.getElementById('syncArtistBtn') as HTMLButtonElement | null; + + if (!watchArtistBtn) return; + + try { + watchArtistBtn.disabled = true; + if (syncArtistBtn) syncArtistBtn.disabled = true; + + // const isWatching = await getArtistWatchStatus(artistId); // No longer fetch here, use parameter + updateWatchButton(artistId, initialIsWatching); // Use passed status + watchArtistBtn.disabled = false; + if (syncArtistBtn) syncArtistBtn.disabled = !(watchArtistBtn.dataset.watching === 'true'); + + watchArtistBtn.addEventListener('click', async () => { + const currentlyWatching = watchArtistBtn.dataset.watching === 'true'; + watchArtistBtn.disabled = true; + if (syncArtistBtn) syncArtistBtn.disabled = true; + try { + if (currentlyWatching) { + await unwatchArtist(artistId); + updateWatchButton(artistId, false); + // Re-fetch and re-render artist data + const newArtistData = await (await fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}`)).json() as ArtistData; + renderArtist(newArtistData, artistId); + } else { + await watchArtist(artistId); + updateWatchButton(artistId, true); + // Re-fetch and re-render artist data + const newArtistData = await (await fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}`)).json() as ArtistData; + renderArtist(newArtistData, artistId); + } + } catch (error) { + // On error, revert button to its state before the click attempt + updateWatchButton(artistId, currentlyWatching); + } + watchArtistBtn.disabled = false; + if (syncArtistBtn) syncArtistBtn.disabled = !(watchArtistBtn.dataset.watching === 'true'); + }); + + // Add event listener for the sync button + if (syncArtistBtn) { + syncArtistBtn.addEventListener('click', async () => { + syncArtistBtn.disabled = true; + const originalButtonContent = syncArtistBtn.innerHTML; // Store full HTML + const textNode = Array.from(syncArtistBtn.childNodes).find(node => node.nodeType === Node.TEXT_NODE); + const originalText = textNode ? textNode.nodeValue : 'Sync Watched Artist'; // Fallback text + + syncArtistBtn.innerHTML = `Sync Syncing...`; // Keep icon + try { + await triggerArtistSync(artistId); + showNotification('Artist sync triggered successfully.'); + } catch (error) { + // Error is shown by triggerArtistSync + } + syncArtistBtn.innerHTML = originalButtonContent; // Restore full original HTML + syncArtistBtn.disabled = false; + }); + } + + } catch (error) { + if (watchArtistBtn) watchArtistBtn.disabled = false; + if (syncArtistBtn) syncArtistBtn.disabled = true; + updateWatchButton(artistId, false); // On error fetching initial status (though now it's passed) + // This line might be less relevant if initialIsWatching is guaranteed by caller + // but as a fallback it sets to a non-watching state. + } +} + +// New function to trigger artist sync +async function triggerArtistSync(artistId: string): Promise { + try { + const response = await fetch(`/api/artist/watch/trigger_check/${artistId}`, { + method: 'POST', + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + await response.json(); // Contains success message + } catch (error) { + console.error('Error triggering artist sync:', error); + showError('Failed to trigger artist sync.'); + throw error; // Re-throw + } +} + +/** + * Displays a temporary notification message. + */ +function showNotification(message: string) { + // Basic notification - consider a more robust solution for production + const notificationEl = document.createElement('div'); + notificationEl.className = 'notification'; // Ensure this class is styled + notificationEl.textContent = message; + document.body.appendChild(notificationEl); + setTimeout(() => { + notificationEl.remove(); + }, 3000); +} diff --git a/src/js/config.ts b/src/js/config.ts new file mode 100644 index 0000000..4e73a70 --- /dev/null +++ b/src/js/config.ts @@ -0,0 +1,1046 @@ +import { downloadQueue } from './queue.js'; + +// Interfaces for validator data +interface SpotifyValidatorData { + username: string; + credentials?: string; // Credentials might be optional if only username is used as an identifier +} + +interface SpotifySearchValidatorData { + client_id: string; + client_secret: string; +} + +interface DeezerValidatorData { + arl: string; +} + +const serviceConfig: Record = { + spotify: { + fields: [ + { id: 'username', label: 'Username', type: 'text' }, + { id: 'credentials', label: 'Credentials', type: 'text' } // Assuming this is password/token + ], + validator: (data: SpotifyValidatorData) => ({ // Typed data + username: data.username, + credentials: data.credentials + }), + // Adding search credentials fields + searchFields: [ + { id: 'client_id', label: 'Client ID', type: 'text' }, + { id: 'client_secret', label: 'Client Secret', type: 'password' } + ], + searchValidator: (data: SpotifySearchValidatorData) => ({ // Typed data + client_id: data.client_id, + client_secret: data.client_secret + }) + }, + deezer: { + fields: [ + { id: 'arl', label: 'ARL', type: 'text' } + ], + validator: (data: DeezerValidatorData) => ({ // Typed data + arl: data.arl + }) + } +}; + +let currentService = 'spotify'; +let currentCredential: string | null = null; +let isEditingSearch = false; + +// Global variables to hold the active accounts from the config response. +let activeSpotifyAccount = ''; +let activeDeezerAccount = ''; + +// Reference to the credentials form card and add button +let credentialsFormCard: HTMLElement | null = null; +let showAddAccountFormBtn: HTMLElement | null = null; +let cancelAddAccountBtn: HTMLElement | null = null; + +// Helper function to manage visibility of form and add button +function setFormVisibility(showForm: boolean) { + if (credentialsFormCard && showAddAccountFormBtn) { + credentialsFormCard.style.display = showForm ? 'block' : 'none'; + showAddAccountFormBtn.style.display = showForm ? 'none' : 'flex'; // Assuming flex for styled button + if (showForm) { + resetForm(); // Reset form to "add new" state when showing for add + const credentialNameInput = document.getElementById('credentialName') as HTMLInputElement | null; + if(credentialNameInput) credentialNameInput.focus(); + } + } +} + +async function loadConfig() { + try { + const response = await fetch('/api/config'); + if (!response.ok) throw new Error('Failed to load config'); + + const savedConfig = await response.json(); + + // Set default service selection + const defaultServiceSelect = document.getElementById('defaultServiceSelect') as HTMLSelectElement | null; + if (defaultServiceSelect) defaultServiceSelect.value = savedConfig.service || 'spotify'; + + // Update the service-specific options based on selected service + updateServiceSpecificOptions(); + + // Use the "spotify" and "deezer" properties from the API response to set the active accounts. + activeSpotifyAccount = savedConfig.spotify || ''; + activeDeezerAccount = savedConfig.deezer || ''; + + // (Optionally, if the account selects already exist you can set their values here, + // but updateAccountSelectors() will rebuild the options and set the proper values.) + const spotifySelect = document.getElementById('spotifyAccountSelect') as HTMLSelectElement | null; + const deezerSelect = document.getElementById('deezerAccountSelect') as HTMLSelectElement | null; + const spotifyMessage = document.getElementById('spotifyAccountMessage') as HTMLElement | null; + const deezerMessage = document.getElementById('deezerAccountMessage') as HTMLElement | null; + if (spotifySelect) spotifySelect.value = activeSpotifyAccount; + if (deezerSelect) deezerSelect.value = activeDeezerAccount; + + // Update other configuration fields. + const fallbackToggle = document.getElementById('fallbackToggle') as HTMLInputElement | null; + if (fallbackToggle) fallbackToggle.checked = !!savedConfig.fallback; + const spotifyQualitySelect = document.getElementById('spotifyQualitySelect') as HTMLSelectElement | null; + if (spotifyQualitySelect) spotifyQualitySelect.value = savedConfig.spotifyQuality || 'NORMAL'; + const deezerQualitySelect = document.getElementById('deezerQualitySelect') as HTMLSelectElement | null; + if (deezerQualitySelect) deezerQualitySelect.value = savedConfig.deezerQuality || 'MP3_128'; + const realTimeToggle = document.getElementById('realTimeToggle') as HTMLInputElement | null; + if (realTimeToggle) realTimeToggle.checked = !!savedConfig.realTime; + const customDirFormat = document.getElementById('customDirFormat') as HTMLInputElement | null; + if (customDirFormat) customDirFormat.value = savedConfig.customDirFormat || '%ar_album%/%album%'; + const customTrackFormat = document.getElementById('customTrackFormat') as HTMLInputElement | null; + if (customTrackFormat) customTrackFormat.value = savedConfig.customTrackFormat || '%tracknum%. %music%'; + const maxConcurrentDownloads = document.getElementById('maxConcurrentDownloads') as HTMLInputElement | null; + if (maxConcurrentDownloads) maxConcurrentDownloads.value = savedConfig.maxConcurrentDownloads || '3'; + const maxRetries = document.getElementById('maxRetries') as HTMLInputElement | null; + if (maxRetries) maxRetries.value = savedConfig.maxRetries || '3'; + const retryDelaySeconds = document.getElementById('retryDelaySeconds') as HTMLInputElement | null; + if (retryDelaySeconds) retryDelaySeconds.value = savedConfig.retryDelaySeconds || '5'; + const retryDelayIncrease = document.getElementById('retryDelayIncrease') as HTMLInputElement | null; + if (retryDelayIncrease) retryDelayIncrease.value = savedConfig.retry_delay_increase || '5'; + const tracknumPaddingToggle = document.getElementById('tracknumPaddingToggle') as HTMLInputElement | null; + if (tracknumPaddingToggle) tracknumPaddingToggle.checked = savedConfig.tracknum_padding === undefined ? true : !!savedConfig.tracknum_padding; + + // Update explicit filter status + updateExplicitFilterStatus(savedConfig.explicitFilter); + + // Load watch config + await loadWatchConfig(); + } catch (error: any) { + showConfigError('Error loading config: ' + error.message); + } +} + +document.addEventListener('DOMContentLoaded', async () => { + try { + await initConfig(); + setupServiceTabs(); + setupEventListeners(); + + // Setup for the collapsable "Add Account" form + credentialsFormCard = document.querySelector('.credentials-form.card'); + showAddAccountFormBtn = document.getElementById('showAddAccountFormBtn'); + cancelAddAccountBtn = document.getElementById('cancelAddAccountBtn'); + + if (credentialsFormCard && showAddAccountFormBtn) { + // Initially hide form, show add button (default state handled by setFormVisibility if called) + credentialsFormCard.style.display = 'none'; + showAddAccountFormBtn.style.display = 'flex'; // Assuming styled button uses flex + } + + if (showAddAccountFormBtn) { + showAddAccountFormBtn.addEventListener('click', () => { + setFormVisibility(true); + }); + } + + if (cancelAddAccountBtn && credentialsFormCard && showAddAccountFormBtn) { + cancelAddAccountBtn.addEventListener('click', () => { + setFormVisibility(false); + resetForm(); // Also reset form state on cancel + }); + } + + const queueIcon = document.getElementById('queueIcon'); + if (queueIcon) { + queueIcon.addEventListener('click', () => { + downloadQueue.toggleVisibility(); + }); + } + } catch (error: any) { + showConfigError(error.message); + } +}); + +async function initConfig() { + await loadConfig(); + await updateAccountSelectors(); + loadCredentials(currentService); + updateFormFields(); +} + +function setupServiceTabs() { + const serviceTabs = document.querySelectorAll('.tab-button'); + serviceTabs.forEach(tab => { + tab.addEventListener('click', () => { + serviceTabs.forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + currentService = (tab as HTMLElement).dataset.service || 'spotify'; + loadCredentials(currentService); + updateFormFields(); + }); + }); +} + +function setupEventListeners() { + (document.getElementById('credentialForm') as HTMLFormElement | null)?.addEventListener('submit', handleCredentialSubmit); + + // Config change listeners + (document.getElementById('defaultServiceSelect') as HTMLSelectElement | null)?.addEventListener('change', function() { + updateServiceSpecificOptions(); + saveConfig(); + }); + (document.getElementById('fallbackToggle') as HTMLInputElement | null)?.addEventListener('change', saveConfig); + (document.getElementById('realTimeToggle') as HTMLInputElement | null)?.addEventListener('change', saveConfig); + (document.getElementById('spotifyQualitySelect') as HTMLSelectElement | null)?.addEventListener('change', saveConfig); + (document.getElementById('deezerQualitySelect') as HTMLSelectElement | null)?.addEventListener('change', saveConfig); + (document.getElementById('tracknumPaddingToggle') as HTMLInputElement | null)?.addEventListener('change', saveConfig); + (document.getElementById('maxRetries') as HTMLInputElement | null)?.addEventListener('change', saveConfig); + (document.getElementById('retryDelaySeconds') as HTMLInputElement | null)?.addEventListener('change', saveConfig); + + // Update active account globals when the account selector is changed. + (document.getElementById('spotifyAccountSelect') as HTMLSelectElement | null)?.addEventListener('change', (e: Event) => { + activeSpotifyAccount = (e.target as HTMLSelectElement).value; + saveConfig(); + }); + (document.getElementById('deezerAccountSelect') as HTMLSelectElement | null)?.addEventListener('change', (e: Event) => { + activeDeezerAccount = (e.target as HTMLSelectElement).value; + saveConfig(); + }); + + // Formatting settings + (document.getElementById('customDirFormat') as HTMLInputElement | null)?.addEventListener('change', saveConfig); + (document.getElementById('customTrackFormat') as HTMLInputElement | null)?.addEventListener('change', saveConfig); + + // Copy to clipboard when selecting placeholders + (document.getElementById('dirFormatHelp') as HTMLSelectElement | null)?.addEventListener('change', function() { + copyPlaceholderToClipboard(this as HTMLSelectElement); + }); + (document.getElementById('trackFormatHelp') as HTMLSelectElement | null)?.addEventListener('change', function() { + copyPlaceholderToClipboard(this as HTMLSelectElement); + }); + + // Max concurrent downloads change listener + (document.getElementById('maxConcurrentDownloads') as HTMLInputElement | null)?.addEventListener('change', saveConfig); + + // Watch options listeners + document.querySelectorAll('#watchedArtistAlbumGroupChecklist input[type="checkbox"]').forEach(checkbox => { + checkbox.addEventListener('change', saveWatchConfig); + }); + (document.getElementById('watchPollIntervalSeconds') as HTMLInputElement | null)?.addEventListener('change', saveWatchConfig); + (document.getElementById('watchEnabledToggle') as HTMLInputElement | null)?.addEventListener('change', () => { + const isEnabling = (document.getElementById('watchEnabledToggle') as HTMLInputElement)?.checked; + const alreadyShownFirstEnableNotice = localStorage.getItem('watchFeatureFirstEnableNoticeShown'); + + if (isEnabling && !alreadyShownFirstEnableNotice) { + const noticeDiv = document.getElementById('watchFeatureFirstEnableNotice'); + if (noticeDiv) noticeDiv.style.display = 'block'; + localStorage.setItem('watchFeatureFirstEnableNoticeShown', 'true'); + // Hide notice after a delay or on click if preferred + setTimeout(() => { + if (noticeDiv) noticeDiv.style.display = 'none'; + }, 15000); // Hide after 15 seconds + } else { + // If disabling, or if notice was already shown, ensure it's hidden + const noticeDiv = document.getElementById('watchFeatureFirstEnableNotice'); + if (noticeDiv) noticeDiv.style.display = 'none'; + } + saveWatchConfig(); + updateWatchWarningDisplay(); // Call this also when the watch enable toggle changes + }); + (document.getElementById('realTimeToggle') as HTMLInputElement | null)?.addEventListener('change', () => { + saveConfig(); + updateWatchWarningDisplay(); // Call this when realTimeToggle changes + }); +} + +function updateServiceSpecificOptions() { + // Get the selected service + const selectedService = (document.getElementById('defaultServiceSelect') as HTMLSelectElement | null)?.value; + + // Handle Spotify specific options + if (selectedService === 'spotify') { + // Highlight Spotify section + (document.getElementById('spotifyQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option'); + (document.getElementById('spotifyAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option'); + + // Remove highlight from Deezer + (document.getElementById('deezerQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option'); + (document.getElementById('deezerAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option'); + } + // Handle Deezer specific options + else if (selectedService === 'deezer') { + // Highlight Deezer section + (document.getElementById('deezerQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option'); + (document.getElementById('deezerAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option'); + + // Remove highlight from Spotify + (document.getElementById('spotifyQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option'); + (document.getElementById('spotifyAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option'); + } +} + +async function updateAccountSelectors() { + try { + const [spotifyResponse, deezerResponse] = await Promise.all([ + fetch('/api/credentials/spotify'), + fetch('/api/credentials/deezer') + ]); + + const spotifyAccounts = await spotifyResponse.json(); + const deezerAccounts = await deezerResponse.json(); + + // Get the select elements + const spotifySelect = document.getElementById('spotifyAccountSelect') as HTMLSelectElement | null; + const deezerSelect = document.getElementById('deezerAccountSelect') as HTMLSelectElement | null; + const spotifyMessage = document.getElementById('spotifyAccountMessage') as HTMLElement | null; + const deezerMessage = document.getElementById('deezerAccountMessage') as HTMLElement | null; + + // Rebuild the Spotify selector options + if (spotifySelect && spotifyMessage) { + if (spotifyAccounts.length > 0) { + spotifySelect.innerHTML = spotifyAccounts + .map((a: string) => ``) + .join(''); + spotifySelect.style.display = ''; + spotifyMessage.style.display = 'none'; + + // Use the active account loaded from the config (activeSpotifyAccount) + if (activeSpotifyAccount && spotifyAccounts.includes(activeSpotifyAccount)) { + spotifySelect.value = activeSpotifyAccount; + } else { + spotifySelect.value = spotifyAccounts[0]; + activeSpotifyAccount = spotifyAccounts[0]; + await saveConfig(); // Save if we defaulted + } + } else { + spotifySelect.innerHTML = ''; + spotifySelect.style.display = 'none'; + spotifyMessage.textContent = 'No Spotify accounts available.'; + spotifyMessage.style.display = ''; + if (activeSpotifyAccount !== '') { // Clear active account if it was set + activeSpotifyAccount = ''; + await saveConfig(); + } + } + } + + // Rebuild the Deezer selector options + if (deezerSelect && deezerMessage) { + if (deezerAccounts.length > 0) { + deezerSelect.innerHTML = deezerAccounts + .map((a: string) => ``) + .join(''); + deezerSelect.style.display = ''; + deezerMessage.style.display = 'none'; + + if (activeDeezerAccount && deezerAccounts.includes(activeDeezerAccount)) { + deezerSelect.value = activeDeezerAccount; + } else { + deezerSelect.value = deezerAccounts[0]; + activeDeezerAccount = deezerAccounts[0]; + await saveConfig(); // Save if we defaulted + } + } else { + deezerSelect.innerHTML = ''; + deezerSelect.style.display = 'none'; + deezerMessage.textContent = 'No Deezer accounts available.'; + deezerMessage.style.display = ''; + if (activeDeezerAccount !== '') { // Clear active account if it was set + activeDeezerAccount = ''; + await saveConfig(); + } + } + } + } catch (error: any) { + showConfigError('Error updating accounts: ' + error.message); + } +} + +async function loadCredentials(service: string) { + try { + const response = await fetch(`/api/credentials/all/${service}`); + if (!response.ok) { + throw new Error(`Failed to load credentials: ${response.statusText}`); + } + + const credentials = await response.json(); + renderCredentialsList(service, credentials); + } catch (error: any) { + showConfigError(error.message); + } +} + +function renderCredentialsList(service: string, credentials: any[]) { + const list = document.querySelector('.credentials-list-items') as HTMLElement | null; + if (!list) return; + list.innerHTML = ''; + + if (!credentials.length) { + list.innerHTML = '
No accounts found. Add a new account below.
'; + return; + } + + credentials.forEach(credData => { + const credItem = document.createElement('div'); + credItem.className = 'credential-item'; + + const hasSearchCreds = credData.search && Object.keys(credData.search).length > 0; + + credItem.innerHTML = ` +
+ ${credData.name} + ${service === 'spotify' ? + `
+ ${hasSearchCreds ? 'API Configured' : 'No API Credentials'} +
` : ''} +
+
+ + ${service === 'spotify' ? + `` : ''} + +
+ `; + + list.appendChild(credItem); + }); + + // Set up event handlers + list.querySelectorAll('.delete-btn').forEach(btn => { + btn.addEventListener('click', handleDeleteCredential as EventListener); + }); + + list.querySelectorAll('.edit-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + isEditingSearch = false; + handleEditCredential(e as MouseEvent); + }); + }); + + if (service === 'spotify') { + list.querySelectorAll('.edit-search-btn').forEach(btn => { + btn.addEventListener('click', handleEditSearchCredential as EventListener); + }); + } +} + +async function handleDeleteCredential(e: Event) { + try { + const target = e.target as HTMLElement; + const service = target.dataset.service; + const name = target.dataset.name; + + if (!service || !name) { + throw new Error('Missing credential information'); + } + + if (!confirm(`Are you sure you want to delete the ${name} account?`)) { + return; + } + + const response = await fetch(`/api/credentials/${service}/${name}`, { + method: 'DELETE' + }); + + if (!response.ok) { + throw new Error('Failed to delete credential'); + } + + // If the deleted credential is the active account, clear the selection. + const accountSelect = document.getElementById(`${service}AccountSelect`) as HTMLSelectElement | null; + if (accountSelect && accountSelect.value === name) { + accountSelect.value = ''; + if (service === 'spotify') { + activeSpotifyAccount = ''; + } else if (service === 'deezer') { + activeDeezerAccount = ''; + } + await saveConfig(); + } + + loadCredentials(service); + await updateAccountSelectors(); + } catch (error: any) { + showConfigError(error.message); + } +} + +async function handleEditCredential(e: MouseEvent) { + const target = e.target as HTMLElement; + const service = target.dataset.service; + const name = target.dataset.name; + + try { + (document.querySelector(`[data-service="${service}"]`) as HTMLElement | null)?.click(); + await new Promise(resolve => setTimeout(resolve, 50)); + + setFormVisibility(true); // Show form for editing, will hide add button + + const response = await fetch(`/api/credentials/${service}/${name}`); + if (!response.ok) { + throw new Error(`Failed to load credential: ${response.statusText}`); + } + + const data = await response.json(); + + currentCredential = name ? name : null; + const credentialNameInput = document.getElementById('credentialName') as HTMLInputElement | null; + if (credentialNameInput) { + credentialNameInput.value = name || ''; + credentialNameInput.disabled = true; + } + (document.getElementById('formTitle') as HTMLElement | null)!.textContent = `Edit ${service!.charAt(0).toUpperCase() + service!.slice(1)} Account`; + (document.getElementById('submitCredentialBtn') as HTMLElement | null)!.textContent = 'Update Account'; + + // Show regular fields + populateFormFields(service!, data); + toggleSearchFieldsVisibility(false); + } catch (error: any) { + showConfigError(error.message); + } +} + +async function handleEditSearchCredential(e: Event) { + const target = e.target as HTMLElement; + const service = target.dataset.service; + const name = target.dataset.name; + + try { + if (service !== 'spotify') { + throw new Error('Search credentials are only available for Spotify'); + } + + setFormVisibility(true); // Show form for editing search creds, will hide add button + + (document.querySelector(`[data-service="${service}"]`) as HTMLElement | null)?.click(); + await new Promise(resolve => setTimeout(resolve, 50)); + + isEditingSearch = true; + currentCredential = name ? name : null; + const credentialNameInput = document.getElementById('credentialName') as HTMLInputElement | null; + if (credentialNameInput) { + credentialNameInput.value = name || ''; + credentialNameInput.disabled = true; + } + (document.getElementById('formTitle')as HTMLElement | null)!.textContent = `Spotify API Credentials for ${name}`; + (document.getElementById('submitCredentialBtn') as HTMLElement | null)!.textContent = 'Save API Credentials'; + + // Try to load existing search credentials + try { + const searchResponse = await fetch(`/api/credentials/${service}/${name}?type=search`); + if (searchResponse.ok) { + const searchData = await searchResponse.json(); + // Populate search fields + serviceConfig[service].searchFields.forEach((field: { id: string; }) => { + const element = document.getElementById(field.id) as HTMLInputElement | null; + if (element) element.value = searchData[field.id] || ''; + }); + } else { + // Clear search fields if no existing search credentials + serviceConfig[service].searchFields.forEach((field: { id: string; }) => { + const element = document.getElementById(field.id) as HTMLInputElement | null; + if (element) element.value = ''; + }); + } + } catch (error) { + // Clear search fields if there was an error + serviceConfig[service].searchFields.forEach((field: { id: string; }) => { + const element = document.getElementById(field.id) as HTMLInputElement | null; + if (element) element.value = ''; + }); + } + + // Hide regular account fields, show search fields + toggleSearchFieldsVisibility(true); + } catch (error: any) { + showConfigError(error.message); + } +} + +function toggleSearchFieldsVisibility(showSearchFields: boolean) { + const serviceFieldsDiv = document.getElementById('serviceFields') as HTMLElement | null; + const searchFieldsDiv = document.getElementById('searchFields') as HTMLElement | null; + + if (showSearchFields) { + // Hide regular fields and remove 'required' attribute + if(serviceFieldsDiv) serviceFieldsDiv.style.display = 'none'; + // Remove required attribute from service fields + serviceConfig[currentService].fields.forEach((field: { id: string }) => { + const input = document.getElementById(field.id) as HTMLInputElement | null; + if (input) input.removeAttribute('required'); + }); + + // Show search fields and add 'required' attribute + if(searchFieldsDiv) searchFieldsDiv.style.display = 'block'; + // Make search fields required + if (currentService === 'spotify' && serviceConfig[currentService].searchFields) { + serviceConfig[currentService].searchFields.forEach((field: { id: string }) => { + const input = document.getElementById(field.id) as HTMLInputElement | null; + if (input) input.setAttribute('required', ''); + }); + } + } else { + // Show regular fields and add 'required' attribute + if(serviceFieldsDiv) serviceFieldsDiv.style.display = 'block'; + // Make service fields required + serviceConfig[currentService].fields.forEach((field: { id: string }) => { + const input = document.getElementById(field.id) as HTMLInputElement | null; + if (input) input.setAttribute('required', ''); + }); + + // Hide search fields and remove 'required' attribute + if(searchFieldsDiv) searchFieldsDiv.style.display = 'none'; + // Remove required from search fields + if (currentService === 'spotify' && serviceConfig[currentService].searchFields) { + serviceConfig[currentService].searchFields.forEach((field: { id: string }) => { + const input = document.getElementById(field.id) as HTMLInputElement | null; + if (input) input.removeAttribute('required'); + }); + } + } +} + +function updateFormFields() { + const serviceFieldsDiv = document.getElementById('serviceFields') as HTMLElement | null; + const searchFieldsDiv = document.getElementById('searchFields') as HTMLElement | null; + + // Clear any existing fields + if(serviceFieldsDiv) serviceFieldsDiv.innerHTML = ''; + if(searchFieldsDiv) searchFieldsDiv.innerHTML = ''; + + // Add regular account fields + serviceConfig[currentService].fields.forEach((field: { className: string; label: string; type: string; id: string; }) => { + const fieldDiv = document.createElement('div'); + fieldDiv.className = 'form-group'; + fieldDiv.innerHTML = ` + + + `; + serviceFieldsDiv?.appendChild(fieldDiv); + }); + + // Add search fields for Spotify + if (currentService === 'spotify' && serviceConfig[currentService].searchFields) { + serviceConfig[currentService].searchFields.forEach((field: { className: string; label: string; type: string; id: string; }) => { + const fieldDiv = document.createElement('div'); + fieldDiv.className = 'form-group'; + fieldDiv.innerHTML = ` + + + `; + searchFieldsDiv?.appendChild(fieldDiv); + }); + } + + // Reset form title and button text + (document.getElementById('formTitle') as HTMLElement | null)!.textContent = `Add New ${currentService.charAt(0).toUpperCase() + currentService.slice(1)} Account`; + (document.getElementById('submitCredentialBtn') as HTMLElement | null)!.textContent = 'Save Account'; + + // Initially show regular fields, hide search fields + toggleSearchFieldsVisibility(false); + isEditingSearch = false; +} + +function populateFormFields(service: string, data: Record) { + serviceConfig[service].fields.forEach((field: { id: string; }) => { + const element = document.getElementById(field.id) as HTMLInputElement | null; + if (element) element.value = data[field.id] || ''; + }); +} + +async function handleCredentialSubmit(e: Event) { + e.preventDefault(); + const service = (document.querySelector('.tab-button.active') as HTMLElement | null)?.dataset.service; + const nameInput = document.getElementById('credentialName') as HTMLInputElement | null; + const name = nameInput?.value.trim(); + + try { + if (!currentCredential && !name) { + throw new Error('Credential name is required'); + } + if (!service) { + throw new Error('Service not selected'); + } + + const endpointName = currentCredential || name; + let method: string, data: any, endpoint: string; + + if (isEditingSearch && service === 'spotify') { + // Handle search credentials + const formData: Record = {}; + let isValid = true; + let firstInvalidField: HTMLInputElement | null = null; + + // Manually validate search fields + serviceConfig[service!].searchFields.forEach((field: { id: string; }) => { + const input = document.getElementById(field.id) as HTMLInputElement | null; + const value = input ? input.value.trim() : ''; + formData[field.id] = value; + + if (!value) { + isValid = false; + if (!firstInvalidField && input) firstInvalidField = input; + } + }); + + if (!isValid) { + if (firstInvalidField) (firstInvalidField as HTMLInputElement).focus(); + throw new Error('All fields are required'); + } + + data = serviceConfig[service!].searchValidator(formData); + endpoint = `/api/credentials/${service}/${endpointName}?type=search`; + + // Check if search credentials already exist for this account + const checkResponse = await fetch(endpoint); + method = checkResponse.ok ? 'PUT' : 'POST'; + } else { + // Handle regular account credentials + const formData: Record = {}; + let isValid = true; + let firstInvalidField: HTMLInputElement | null = null; + + // Manually validate account fields + serviceConfig[service!].fields.forEach((field: { id: string; }) => { + const input = document.getElementById(field.id) as HTMLInputElement | null; + const value = input ? input.value.trim() : ''; + formData[field.id] = value; + + if (!value) { + isValid = false; + if (!firstInvalidField && input) firstInvalidField = input; + } + }); + + if (!isValid) { + if (firstInvalidField) (firstInvalidField as HTMLInputElement).focus(); + throw new Error('All fields are required'); + } + + data = serviceConfig[service!].validator(formData); + endpoint = `/api/credentials/${service}/${endpointName}`; + method = currentCredential ? 'PUT' : 'POST'; + } + + const response = await fetch(endpoint, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to save credentials'); + } + + await updateAccountSelectors(); + await saveConfig(); + loadCredentials(service!); + + // Show success message + showConfigSuccess(isEditingSearch ? 'API credentials saved successfully' : 'Account saved successfully'); + + // Add a delay before hiding the form + setTimeout(() => { + setFormVisibility(false); // Hide form and show add button on successful submission + }, 2000); // 2 second delay + } catch (error: any) { + showConfigError(error.message); + } +} + +function resetForm() { + currentCredential = null; + isEditingSearch = false; + const nameInput = document.getElementById('credentialName') as HTMLInputElement | null; + if (nameInput) { + nameInput.value = ''; + nameInput.disabled = false; + } + (document.getElementById('credentialForm') as HTMLFormElement | null)?.reset(); + + // Reset form title and button text + const serviceName = currentService.charAt(0).toUpperCase() + currentService.slice(1); + (document.getElementById('formTitle') as HTMLElement | null)!.textContent = `Add New ${serviceName} Account`; + (document.getElementById('submitCredentialBtn') as HTMLElement | null)!.textContent = 'Save Account'; + + // Show regular account fields, hide search fields + toggleSearchFieldsVisibility(false); +} + +async function saveConfig() { + // Read active account values directly from the DOM (or from the globals which are kept in sync) + const config = { + service: (document.getElementById('defaultServiceSelect') as HTMLSelectElement | null)?.value, + spotify: (document.getElementById('spotifyAccountSelect') as HTMLSelectElement | null)?.value, + deezer: (document.getElementById('deezerAccountSelect') as HTMLSelectElement | null)?.value, + fallback: (document.getElementById('fallbackToggle') as HTMLInputElement | null)?.checked, + spotifyQuality: (document.getElementById('spotifyQualitySelect') as HTMLSelectElement | null)?.value, + deezerQuality: (document.getElementById('deezerQualitySelect') as HTMLSelectElement | null)?.value, + realTime: (document.getElementById('realTimeToggle') as HTMLInputElement | null)?.checked, + customDirFormat: (document.getElementById('customDirFormat') as HTMLInputElement | null)?.value, + customTrackFormat: (document.getElementById('customTrackFormat') as HTMLInputElement | null)?.value, + maxConcurrentDownloads: parseInt((document.getElementById('maxConcurrentDownloads') as HTMLInputElement | null)?.value || '3', 10) || 3, + maxRetries: parseInt((document.getElementById('maxRetries') as HTMLInputElement | null)?.value || '3', 10) || 3, + retryDelaySeconds: parseInt((document.getElementById('retryDelaySeconds') as HTMLInputElement | null)?.value || '5', 10) || 5, + retry_delay_increase: parseInt((document.getElementById('retryDelayIncrease') as HTMLInputElement | null)?.value || '5', 10) || 5, + tracknum_padding: (document.getElementById('tracknumPaddingToggle') as HTMLInputElement | null)?.checked + }; + + try { + const response = await fetch('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to save config'); + } + + const savedConfig = await response.json(); + + // Set default service selection + const defaultServiceSelect = document.getElementById('defaultServiceSelect') as HTMLSelectElement | null; + if (defaultServiceSelect) defaultServiceSelect.value = savedConfig.service || 'spotify'; + + // Update the service-specific options based on selected service + updateServiceSpecificOptions(); + + // Use the "spotify" and "deezer" properties from the API response to set the active accounts. + activeSpotifyAccount = savedConfig.spotify || ''; + activeDeezerAccount = savedConfig.deezer || ''; + + // (Optionally, if the account selects already exist you can set their values here, + // but updateAccountSelectors() will rebuild the options and set the proper values.) + const spotifySelect = document.getElementById('spotifyAccountSelect') as HTMLSelectElement | null; + const deezerSelect = document.getElementById('deezerAccountSelect') as HTMLSelectElement | null; + if (spotifySelect) spotifySelect.value = activeSpotifyAccount; + if (deezerSelect) deezerSelect.value = activeDeezerAccount; + + // Update other configuration fields. + const fallbackToggle = document.getElementById('fallbackToggle') as HTMLInputElement | null; + if (fallbackToggle) fallbackToggle.checked = !!savedConfig.fallback; + const spotifyQualitySelect = document.getElementById('spotifyQualitySelect') as HTMLSelectElement | null; + if (spotifyQualitySelect) spotifyQualitySelect.value = savedConfig.spotifyQuality || 'NORMAL'; + const deezerQualitySelect = document.getElementById('deezerQualitySelect') as HTMLSelectElement | null; + if (deezerQualitySelect) deezerQualitySelect.value = savedConfig.deezerQuality || 'MP3_128'; + const realTimeToggle = document.getElementById('realTimeToggle') as HTMLInputElement | null; + if (realTimeToggle) realTimeToggle.checked = !!savedConfig.realTime; + const customDirFormat = document.getElementById('customDirFormat') as HTMLInputElement | null; + if (customDirFormat) customDirFormat.value = savedConfig.customDirFormat || '%ar_album%/%album%'; + const customTrackFormat = document.getElementById('customTrackFormat') as HTMLInputElement | null; + if (customTrackFormat) customTrackFormat.value = savedConfig.customTrackFormat || '%tracknum%. %music%'; + const maxConcurrentDownloads = document.getElementById('maxConcurrentDownloads') as HTMLInputElement | null; + if (maxConcurrentDownloads) maxConcurrentDownloads.value = savedConfig.maxConcurrentDownloads || '3'; + const maxRetries = document.getElementById('maxRetries') as HTMLInputElement | null; + if (maxRetries) maxRetries.value = savedConfig.maxRetries || '3'; + const retryDelaySeconds = document.getElementById('retryDelaySeconds') as HTMLInputElement | null; + if (retryDelaySeconds) retryDelaySeconds.value = savedConfig.retryDelaySeconds || '5'; + const retryDelayIncrease = document.getElementById('retryDelayIncrease') as HTMLInputElement | null; + if (retryDelayIncrease) retryDelayIncrease.value = savedConfig.retry_delay_increase || '5'; + const tracknumPaddingToggle = document.getElementById('tracknumPaddingToggle') as HTMLInputElement | null; + if (tracknumPaddingToggle) tracknumPaddingToggle.checked = savedConfig.tracknum_padding === undefined ? true : !!savedConfig.tracknum_padding; + + // Update explicit filter status + updateExplicitFilterStatus(savedConfig.explicitFilter); + + // Load watch config + await loadWatchConfig(); + } catch (error: any) { + showConfigError('Error loading config: ' + error.message); + } +} + +function updateExplicitFilterStatus(isEnabled: boolean) { + const statusElement = document.getElementById('explicitFilterStatus'); + if (statusElement) { + // Remove existing classes + statusElement.classList.remove('enabled', 'disabled'); + + // Add appropriate class and text based on whether filter is enabled + if (isEnabled) { + statusElement.textContent = 'Enabled'; + statusElement.classList.add('enabled'); + } else { + statusElement.textContent = 'Disabled'; + statusElement.classList.add('disabled'); + } + } +} + +function showConfigError(message: string) { + const errorDiv = document.getElementById('configError'); + if (errorDiv) errorDiv.textContent = message; + setTimeout(() => { if (errorDiv) errorDiv.textContent = '' }, 5000); +} + +function showConfigSuccess(message: string) { + const successDiv = document.getElementById('configSuccess'); + if (successDiv) successDiv.textContent = message; + setTimeout(() => { if (successDiv) successDiv.textContent = '' }, 5000); +} + +// Function to copy the selected placeholder to clipboard +function copyPlaceholderToClipboard(select: HTMLSelectElement) { + const placeholder = select.value; + + if (!placeholder) return; // If nothing selected + + // Copy to clipboard + navigator.clipboard.writeText(placeholder) + .then(() => { + // Show success notification + showCopyNotification(`Copied ${placeholder} to clipboard`); + + // Reset select to default after a short delay + setTimeout(() => { + select.selectedIndex = 0; + }, 500); + }) + .catch(err => { + console.error('Failed to copy: ', err); + }); +} + +// Function to show a notification when copying +function showCopyNotification(message: string) { + // Check if notification container exists, create if not + let notificationContainer = document.getElementById('copyNotificationContainer'); + if (!notificationContainer) { + notificationContainer = document.createElement('div'); + notificationContainer.id = 'copyNotificationContainer'; + document.body.appendChild(notificationContainer); + } + + // Create notification element + const notification = document.createElement('div'); + notification.className = 'copy-notification'; + notification.textContent = message; + + // Add to container + notificationContainer.appendChild(notification); + + // Trigger animation + setTimeout(() => { + notification.classList.add('show'); + }, 10); + + // Remove after animation completes + setTimeout(() => { + notification.classList.remove('show'); + setTimeout(() => { + notificationContainer.removeChild(notification); + }, 300); + }, 2000); +} + +async function loadWatchConfig() { + try { + const response = await fetch('/api/config/watch'); + if (!response.ok) throw new Error('Failed to load watch config'); + const watchConfig = await response.json(); + + const checklistContainer = document.getElementById('watchedArtistAlbumGroupChecklist'); + if (checklistContainer && watchConfig.watchedArtistAlbumGroup) { + const checkboxes = checklistContainer.querySelectorAll('input[type="checkbox"]') as NodeListOf; + checkboxes.forEach(checkbox => { + checkbox.checked = watchConfig.watchedArtistAlbumGroup.includes(checkbox.value); + }); + } + + const watchPollIntervalSecondsInput = document.getElementById('watchPollIntervalSeconds') as HTMLInputElement | null; + if (watchPollIntervalSecondsInput) { + watchPollIntervalSecondsInput.value = watchConfig.watchPollIntervalSeconds || '3600'; + } + + const watchEnabledToggle = document.getElementById('watchEnabledToggle') as HTMLInputElement | null; + if (watchEnabledToggle) { + watchEnabledToggle.checked = !!watchConfig.enabled; + } + + // Call this after the state of the toggles has been set based on watchConfig + updateWatchWarningDisplay(); + + } catch (error: any) { + showConfigError('Error loading watch config: ' + error.message); + } +} + +async function saveWatchConfig() { + const checklistContainer = document.getElementById('watchedArtistAlbumGroupChecklist'); + const selectedGroups: string[] = []; + if (checklistContainer) { + const checkedBoxes = checklistContainer.querySelectorAll('input[type="checkbox"]:checked') as NodeListOf; + checkedBoxes.forEach(checkbox => selectedGroups.push(checkbox.value)); + } + + const watchConfig = { + enabled: (document.getElementById('watchEnabledToggle') as HTMLInputElement | null)?.checked, + watchedArtistAlbumGroup: selectedGroups, + watchPollIntervalSeconds: parseInt((document.getElementById('watchPollIntervalSeconds') as HTMLInputElement | null)?.value || '3600', 10) || 3600, + }; + + try { + const response = await fetch('/api/config/watch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(watchConfig) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to save watch config'); + } + showConfigSuccess('Watch settings saved successfully.'); + } catch (error: any) { + showConfigError('Error saving watch config: ' + error.message); + } +} + +// New function to manage the warning display +function updateWatchWarningDisplay() { + const watchEnabledToggle = document.getElementById('watchEnabledToggle') as HTMLInputElement | null; + const realTimeToggle = document.getElementById('realTimeToggle') as HTMLInputElement | null; + const warningDiv = document.getElementById('watchEnabledWarning') as HTMLElement | null; + + if (watchEnabledToggle && realTimeToggle && warningDiv) { + const isWatchEnabled = watchEnabledToggle.checked; + const isRealTimeEnabled = realTimeToggle.checked; + + if (isWatchEnabled && !isRealTimeEnabled) { + warningDiv.style.display = 'block'; + } else { + warningDiv.style.display = 'none'; + } + } + // Hide the first-enable notice if watch is disabled or if it was already dismissed by timeout/interaction + // The primary logic for showing first-enable notice is in the event listener for watchEnabledToggle + const firstEnableNoticeDiv = document.getElementById('watchFeatureFirstEnableNotice'); + if (firstEnableNoticeDiv && watchEnabledToggle && !watchEnabledToggle.checked) { + firstEnableNoticeDiv.style.display = 'none'; + } +} 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/main.ts b/src/js/main.ts new file mode 100644 index 0000000..bc14a91 --- /dev/null +++ b/src/js/main.ts @@ -0,0 +1,590 @@ +// main.ts +import { downloadQueue } from './queue.js'; + +// Define interfaces for API data and search results +interface Image { + url: string; + height?: number; + width?: number; +} + +interface Artist { + id?: string; // Artist ID might not always be present in search results for track artists + name: string; + external_urls?: { spotify?: string }; + genres?: string[]; // For artist type results +} + +interface Album { + id?: string; // Album ID might not always be present + name: string; + images?: Image[]; + album_type?: string; // Used in startDownload + artists?: Artist[]; // Album can have artists too + total_tracks?: number; + release_date?: string; + external_urls?: { spotify?: string }; +} + +interface Track { + id: string; + name: string; + artists: Artist[]; + album: Album; + duration_ms?: number; + explicit?: boolean; + external_urls: { spotify: string }; + href?: string; // Some spotify responses use href +} + +interface Playlist { + id: string; + name: string; + owner: { display_name?: string; id?: string }; + images?: Image[]; + tracks: { total: number }; // Simplified for search results + external_urls: { spotify: string }; + href?: string; // Some spotify responses use href + explicit?: boolean; // Playlists themselves aren't explicit, but items can be +} + +// Specific item types for search results +interface TrackResultItem extends Track {} +interface AlbumResultItem extends Album { id: string; images?: Image[]; explicit?: boolean; external_urls: { spotify: string }; href?: string; } +interface PlaylistResultItem extends Playlist {} +interface ArtistResultItem extends Artist { id: string; images?: Image[]; explicit?: boolean; external_urls: { spotify: string }; href?: string; followers?: { total: number }; } + +// Union type for any search result item +type SearchResultItem = TrackResultItem | AlbumResultItem | PlaylistResultItem | ArtistResultItem; + +// Interface for the API response structure +interface SearchResponse { + items: SearchResultItem[]; + // Add other top-level properties from the search API if needed (e.g., total, limit, offset) +} + +// Interface for the item passed to downloadQueue.download +interface DownloadQueueItem { + name: string; + artist?: string; + album?: { name: string; album_type?: string }; +} + +document.addEventListener('DOMContentLoaded', function() { + // DOM elements + const searchInput = document.getElementById('searchInput') as HTMLInputElement | null; + const searchButton = document.getElementById('searchButton') as HTMLButtonElement | null; + const searchType = document.getElementById('searchType') as HTMLSelectElement | null; + const resultsContainer = document.getElementById('resultsContainer'); + const queueIcon = document.getElementById('queueIcon'); + const emptyState = document.getElementById('emptyState'); + const loadingResults = document.getElementById('loadingResults'); + + // Initialize the queue + if (queueIcon) { + queueIcon.addEventListener('click', () => { + downloadQueue.toggleVisibility(); + }); + } + + // Add event listeners + if (searchButton) { + searchButton.addEventListener('click', performSearch); + } + + if (searchInput) { + searchInput.addEventListener('keypress', function(e: KeyboardEvent) { + if (e.key === 'Enter') { + performSearch(); + } + }); + + // Auto-detect and handle pasted Spotify URLs + searchInput.addEventListener('input', function(e: Event) { + const target = e.target as HTMLInputElement; + const inputVal = target.value.trim(); + if (isSpotifyUrl(inputVal)) { + const details = getSpotifyResourceDetails(inputVal); + if (details && searchType) { + searchType.value = details.type; + } + } + }); + } + + // Restore last search type if no URL override + const savedType = localStorage.getItem('lastSearchType'); + if (searchType && savedType && ['track','album','playlist','artist'].includes(savedType)) { + searchType.value = savedType; + } + // Save last selection on change + if (searchType) { + searchType.addEventListener('change', () => { + localStorage.setItem('lastSearchType', searchType.value); + }); + } + + // Check for URL parameters + const urlParams = new URLSearchParams(window.location.search); + const query = urlParams.get('q'); + const type = urlParams.get('type'); + + if (query && searchInput) { + searchInput.value = query; + if (type && searchType && ['track', 'album', 'playlist', 'artist'].includes(type)) { + searchType.value = type; + } + performSearch(); + } else { + // Show empty state if no query + showEmptyState(true); + } + + /** + * Performs the search based on input values + */ + async function performSearch() { + const currentQuery = searchInput?.value.trim(); + if (!currentQuery) return; + + // Handle direct Spotify URLs + if (isSpotifyUrl(currentQuery)) { + const details = getSpotifyResourceDetails(currentQuery); + if (details && details.id) { + // Redirect to the appropriate page + window.location.href = `/${details.type}/${details.id}`; + return; + } + } + + // Update URL without reloading page + const currentSearchType = searchType?.value || 'track'; + const newUrl = `${window.location.pathname}?q=${encodeURIComponent(currentQuery)}&type=${currentSearchType}`; + window.history.pushState({ path: newUrl }, '', newUrl); + + // Show loading state + showEmptyState(false); + showLoading(true); + if(resultsContainer) resultsContainer.innerHTML = ''; + + try { + const url = `/api/search?q=${encodeURIComponent(currentQuery)}&search_type=${currentSearchType}&limit=40`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error('Network response was not ok'); + } + + const data = await response.json() as SearchResponse; // Assert type for API response + + // Hide loading indicator + showLoading(false); + + // Render results + if (data && data.items && data.items.length > 0) { + if(resultsContainer) resultsContainer.innerHTML = ''; + + // Filter out items with null/undefined essential display parameters + const validItems = filterValidItems(data.items, currentSearchType); + + if (validItems.length === 0) { + // No valid items found after filtering + if(resultsContainer) resultsContainer.innerHTML = ` +
+

No valid results found for "${currentQuery}"

+
+ `; + return; + } + + validItems.forEach((item, index) => { + const cardElement = createResultCard(item, currentSearchType, index); + + // Store the item data directly on the button element + const downloadBtn = cardElement.querySelector('.download-btn') as HTMLButtonElement | null; + if (downloadBtn) { + downloadBtn.dataset.itemIndex = index.toString(); + } + + if(resultsContainer) resultsContainer.appendChild(cardElement); + }); + + // Attach download handlers to the newly created cards + attachDownloadListeners(validItems); + } else { + // No results found + if(resultsContainer) resultsContainer.innerHTML = ` +
+

No results found for "${currentQuery}"

+
+ `; + } + } catch (error: any) { + console.error('Error:', error); + showLoading(false); + if(resultsContainer) resultsContainer.innerHTML = ` +
+

Error searching: ${error.message}

+
+ `; + } + } + + /** + * Filters out items with null/undefined essential display parameters based on search type + */ + function filterValidItems(items: SearchResultItem[], type: string): SearchResultItem[] { + if (!items) return []; + + return items.filter(item => { + // Skip null/undefined items + if (!item) return false; + + // Skip explicit content if filter is enabled + if (downloadQueue.isExplicitFilterEnabled() && ('explicit' in item && item.explicit === true)) { + return false; + } + + // Check essential parameters based on search type + switch (type) { + case 'track': + const trackItem = item as TrackResultItem; + return ( + trackItem.name && + trackItem.artists && + trackItem.artists.length > 0 && + trackItem.artists[0] && + trackItem.artists[0].name && + trackItem.album && + trackItem.album.name && + trackItem.external_urls && + trackItem.external_urls.spotify + ); + + case 'album': + const albumItem = item as AlbumResultItem; + return ( + albumItem.name && + albumItem.artists && + albumItem.artists.length > 0 && + albumItem.artists[0] && + albumItem.artists[0].name && + albumItem.external_urls && + albumItem.external_urls.spotify + ); + + case 'playlist': + const playlistItem = item as PlaylistResultItem; + return ( + playlistItem.name && + playlistItem.owner && + playlistItem.owner.display_name && + playlistItem.tracks && + playlistItem.external_urls && + playlistItem.external_urls.spotify + ); + + case 'artist': + const artistItem = item as ArtistResultItem; + return ( + artistItem.name && + artistItem.external_urls && + artistItem.external_urls.spotify + ); + + default: + // Default case - just check if the item exists (already handled by `if (!item) return false;`) + return true; + } + }); + } + + /** + * Attaches download handlers to result cards + */ + function attachDownloadListeners(items: SearchResultItem[]) { + document.querySelectorAll('.download-btn').forEach((btnElm) => { + const btn = btnElm as HTMLButtonElement; + btn.addEventListener('click', (e: Event) => { + e.stopPropagation(); + + // Get the item index from the button's dataset + const itemIndexStr = btn.dataset.itemIndex; + if (!itemIndexStr) return; + const itemIndex = parseInt(itemIndexStr, 10); + + // Get the corresponding item + const item = items[itemIndex]; + if (!item) return; + + const currentSearchType = searchType?.value || 'track'; + let itemId = item.id || ''; // Use item.id directly + + if (!itemId) { // Check if ID was found + showError('Could not determine download ID'); + return; + } + + // Prepare metadata for the download + let metadata: DownloadQueueItem; + if (currentSearchType === 'track') { + const trackItem = item as TrackResultItem; + metadata = { + name: trackItem.name || 'Unknown', + artist: trackItem.artists ? trackItem.artists[0]?.name : undefined, + album: trackItem.album ? { name: trackItem.album.name, album_type: trackItem.album.album_type } : undefined + }; + } else if (currentSearchType === 'album') { + const albumItem = item as AlbumResultItem; + metadata = { + name: albumItem.name || 'Unknown', + artist: albumItem.artists ? albumItem.artists[0]?.name : undefined, + album: { name: albumItem.name, album_type: albumItem.album_type} + }; + } else if (currentSearchType === 'playlist') { + const playlistItem = item as PlaylistResultItem; + metadata = { + name: playlistItem.name || 'Unknown', + // artist for playlist is owner + artist: playlistItem.owner?.display_name + }; + } else if (currentSearchType === 'artist') { + const artistItem = item as ArtistResultItem; + metadata = { + name: artistItem.name || 'Unknown', + artist: artistItem.name // For artist type, artist is the item name itself + }; + } else { + metadata = { name: item.name || 'Unknown' }; // Fallback + } + + // Disable the button and update text + btn.disabled = true; + + // For artist downloads, show a different message since it will queue multiple albums + if (currentSearchType === 'artist') { + btn.innerHTML = 'Queueing albums...'; + } else { + btn.innerHTML = 'Queueing...'; + } + + // Start the download + startDownload(itemId, currentSearchType, metadata, + (item as AlbumResultItem).album_type || ((item as TrackResultItem).album ? (item as TrackResultItem).album.album_type : null)) + .then(() => { + // For artists, show how many albums were queued + if (currentSearchType === 'artist') { + btn.innerHTML = 'Albums queued!'; + // Open the queue automatically for artist downloads + downloadQueue.toggleVisibility(true); + } else { + btn.innerHTML = 'Queued!'; + } + }) + .catch((error: any) => { + btn.disabled = false; + btn.innerHTML = 'Download'; + showError('Failed to queue download: ' + error.message); + }); + }); + }); + } + + /** + * Starts the download process via API + */ + async function startDownload(itemId: string, type: string, item: DownloadQueueItem, albumType: string | null | undefined) { + if (!itemId || !type) { + showError('Missing ID or type for download'); + return; + } + + try { + // Use the centralized downloadQueue.download method + await downloadQueue.download(itemId, type, item, albumType); + + // Make the queue visible after queueing + downloadQueue.toggleVisibility(true); + } catch (error: any) { + showError('Download failed: ' + (error.message || 'Unknown error')); + throw error; + } + } + + /** + * Shows an error message + */ + function showError(message: string) { + const errorDiv = document.createElement('div'); + errorDiv.className = 'error'; + errorDiv.textContent = message; + document.body.appendChild(errorDiv); + + // Auto-remove after 5 seconds + setTimeout(() => errorDiv.remove(), 5000); + } + + /** + * Shows a success message + */ + function showSuccess(message: string) { + const successDiv = document.createElement('div'); + successDiv.className = 'success'; + successDiv.textContent = message; + document.body.appendChild(successDiv); + + // Auto-remove after 5 seconds + setTimeout(() => successDiv.remove(), 5000); + } + + /** + * Checks if a string is a valid Spotify URL + */ + function isSpotifyUrl(url: string): boolean { + return url.includes('open.spotify.com') || + url.includes('spotify:') || + url.includes('link.tospotify.com'); + } + + /** + * Extracts details from a Spotify URL + */ + function getSpotifyResourceDetails(url: string): { type: string; id: string } | null { + // Allow optional path segments (e.g. intl-fr) before resource type + const regex = /spotify\.com\/(?:[^\/]+\/)??(track|album|playlist|artist)\/([a-zA-Z0-9]+)/i; + const match = url.match(regex); + + if (match) { + return { + type: match[1], + id: match[2] + }; + } + return null; + } + + /** + * Formats milliseconds to MM:SS + */ + function msToMinutesSeconds(ms: number | undefined): string { + if (!ms) return '0:00'; + + const minutes = Math.floor(ms / 60000); + const seconds = ((ms % 60000) / 1000).toFixed(0); + return `${minutes}:${seconds.padStart(2, '0')}`; + } + + /** + * Creates a result card element + */ + function createResultCard(item: SearchResultItem, type: string, index: number): HTMLDivElement { + const cardElement = document.createElement('div'); + cardElement.className = 'result-card'; + + // Set cursor to pointer for clickable cards + cardElement.style.cursor = 'pointer'; + + // Get the appropriate image URL + let imageUrl = '/static/images/placeholder.jpg'; + // Type guards to safely access images + if (type === 'album' || type === 'artist') { + const albumOrArtistItem = item as AlbumResultItem | ArtistResultItem; + if (albumOrArtistItem.images && albumOrArtistItem.images.length > 0) { + imageUrl = albumOrArtistItem.images[0].url; + } + } else if (type === 'track') { + const trackItem = item as TrackResultItem; + if (trackItem.album && trackItem.album.images && trackItem.album.images.length > 0) { + imageUrl = trackItem.album.images[0].url; + } + } else if (type === 'playlist') { + const playlistItem = item as PlaylistResultItem; + if (playlistItem.images && playlistItem.images.length > 0) { + imageUrl = playlistItem.images[0].url; + } + } + + // Get the appropriate details based on type + let subtitle = ''; + let details = ''; + + switch (type) { + case 'track': + { + const trackItem = item as TrackResultItem; + subtitle = trackItem.artists ? trackItem.artists.map((a: Artist) => a.name).join(', ') : 'Unknown Artist'; + details = trackItem.album ? `${trackItem.album.name}${msToMinutesSeconds(trackItem.duration_ms)}` : ''; + } + break; + case 'album': + { + const albumItem = item as AlbumResultItem; + subtitle = albumItem.artists ? albumItem.artists.map((a: Artist) => a.name).join(', ') : 'Unknown Artist'; + details = `${albumItem.total_tracks || 0} tracks${albumItem.release_date ? new Date(albumItem.release_date).getFullYear() : ''}`; + } + break; + case 'playlist': + { + const playlistItem = item as PlaylistResultItem; + subtitle = `By ${playlistItem.owner ? playlistItem.owner.display_name : 'Unknown'}`; + details = `${playlistItem.tracks && playlistItem.tracks.total ? playlistItem.tracks.total : 0} tracks`; + } + break; + case 'artist': + { + const artistItem = item as ArtistResultItem; + subtitle = 'Artist'; + details = artistItem.genres ? `${artistItem.genres.slice(0, 2).join(', ')}` : ''; + } + break; + } + + // Build the HTML + cardElement.innerHTML = ` +
+ ${item.name || 'Item'} +
+
${item.name || 'Unknown'}
+
${subtitle}
+
${details}
+ + `; + + // Add click event to navigate to the item's detail page + cardElement.addEventListener('click', (e: MouseEvent) => { + // Don't trigger if the download button was clicked + const target = e.target as HTMLElement; + if (target.classList.contains('download-btn') || + target.parentElement?.classList.contains('download-btn')) { + return; + } + + if (item.id) { + window.location.href = `/${type}/${item.id}`; + } + }); + + return cardElement; + } + + /** + * Show/hide the empty state + */ + function showEmptyState(show: boolean) { + if (emptyState) { + emptyState.style.display = show ? 'flex' : 'none'; + } + } + + /** + * Show/hide the loading indicator + */ + function showLoading(show: boolean) { + if (loadingResults) { + loadingResults.classList.toggle('hidden', !show); + } + } +}); diff --git a/src/js/playlist.ts b/src/js/playlist.ts new file mode 100644 index 0000000..c2873fe --- /dev/null +++ b/src/js/playlist.ts @@ -0,0 +1,749 @@ +// Import the downloadQueue singleton from your working queue.js implementation. +import { downloadQueue } from './queue.js'; + +// Define interfaces for API data +interface Image { + url: string; + height?: number; + width?: number; +} + +interface Artist { + id: string; + name: string; + external_urls?: { spotify?: string }; +} + +interface Album { + id: string; + name: string; + images?: Image[]; + external_urls?: { spotify?: string }; +} + +interface Track { + id: string; + name: string; + artists: Artist[]; + album: Album; + duration_ms: number; + explicit: boolean; + external_urls?: { spotify?: string }; + is_locally_known?: boolean; // Added for local DB status +} + +interface PlaylistItem { + track: Track | null; + // Add other playlist item properties like added_at, added_by if needed +} + +interface Playlist { + id: string; + name: string; + description: string | null; + owner: { + display_name?: string; + id?: string; + }; + images: Image[]; + tracks: { + items: PlaylistItem[]; + total: number; + }; + followers?: { + total: number; + }; + external_urls?: { spotify?: string }; +} + +interface WatchedPlaylistStatus { + is_watched: boolean; + playlist_data?: Playlist; // Optional, present if watched +} + +interface DownloadQueueItem { + name: string; + artist?: string; // Can be a simple string for the queue + album?: { name: string }; // Match QueueItem's album structure + owner?: string; // For playlists, owner can be a string + // Add any other properties your item might have, compatible with QueueItem +} + +document.addEventListener('DOMContentLoaded', () => { + // Parse playlist ID from URL + const pathSegments = window.location.pathname.split('/'); + const playlistId = pathSegments[pathSegments.indexOf('playlist') + 1]; + + if (!playlistId) { + showError('No playlist ID provided.'); + return; + } + + // Fetch playlist info directly + fetch(`/api/playlist/info?id=${encodeURIComponent(playlistId)}`) + .then(response => { + if (!response.ok) throw new Error('Network response was not ok'); + return response.json() as Promise; + }) + .then(data => renderPlaylist(data)) + .catch(error => { + console.error('Error:', error); + showError('Failed to load playlist.'); + }); + + // Fetch initial watch status + fetchWatchStatus(playlistId); + + const queueIcon = document.getElementById('queueIcon'); + if (queueIcon) { + queueIcon.addEventListener('click', () => { + downloadQueue.toggleVisibility(); + }); + } +}); + +/** + * Renders playlist header and tracks. + */ +function renderPlaylist(playlist: Playlist) { + // Hide loading and error messages + const loadingEl = document.getElementById('loading'); + if (loadingEl) loadingEl.classList.add('hidden'); + const errorEl = document.getElementById('error'); + if (errorEl) errorEl.classList.add('hidden'); + + // Check if explicit filter is enabled + const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled(); + + // Update header info + const playlistNameEl = document.getElementById('playlist-name'); + if (playlistNameEl) playlistNameEl.textContent = playlist.name || 'Unknown Playlist'; + const playlistOwnerEl = document.getElementById('playlist-owner'); + if (playlistOwnerEl) playlistOwnerEl.textContent = `By ${playlist.owner?.display_name || 'Unknown User'}`; + const playlistStatsEl = document.getElementById('playlist-stats'); + if (playlistStatsEl) playlistStatsEl.textContent = + `${playlist.followers?.total || '0'} followers • ${playlist.tracks?.total || '0'} songs`; + const playlistDescriptionEl = document.getElementById('playlist-description'); + if (playlistDescriptionEl) playlistDescriptionEl.textContent = playlist.description || ''; + const image = playlist.images?.[0]?.url || '/static/images/placeholder.jpg'; + const playlistImageEl = document.getElementById('playlist-image') as HTMLImageElement; + if (playlistImageEl) playlistImageEl.src = image; + + // --- Add Home Button --- + let homeButton = document.getElementById('homeButton') as HTMLButtonElement; + if (!homeButton) { + homeButton = document.createElement('button'); + homeButton.id = 'homeButton'; + homeButton.className = 'home-btn'; + // Use an tag to display the SVG icon. + homeButton.innerHTML = `Home`; + // Insert the home button at the beginning of the header container. + const headerContainer = document.getElementById('playlist-header'); + if (headerContainer) { + headerContainer.insertBefore(homeButton, headerContainer.firstChild); + } + } + homeButton.addEventListener('click', () => { + // Navigate to the site's base URL. + window.location.href = window.location.origin; + }); + + // Check if any track in the playlist is explicit when filter is enabled + let hasExplicitTrack = false; + if (isExplicitFilterEnabled && playlist.tracks?.items) { + hasExplicitTrack = playlist.tracks.items.some((item: PlaylistItem) => item?.track && item.track.explicit); + } + + // --- Add "Download Whole Playlist" Button --- + let downloadPlaylistBtn = document.getElementById('downloadPlaylistBtn') as HTMLButtonElement; + if (!downloadPlaylistBtn) { + downloadPlaylistBtn = document.createElement('button'); + downloadPlaylistBtn.id = 'downloadPlaylistBtn'; + downloadPlaylistBtn.textContent = 'Download Whole Playlist'; + downloadPlaylistBtn.className = 'download-btn download-btn--main'; + // Insert the button into the header container. + const headerContainer = document.getElementById('playlist-header'); + if (headerContainer) { + headerContainer.appendChild(downloadPlaylistBtn); + } + } + + // --- Add "Download Playlist's Albums" Button --- + let downloadAlbumsBtn = document.getElementById('downloadAlbumsBtn') as HTMLButtonElement; + if (!downloadAlbumsBtn) { + downloadAlbumsBtn = document.createElement('button'); + downloadAlbumsBtn.id = 'downloadAlbumsBtn'; + downloadAlbumsBtn.textContent = "Download Playlist's Albums"; + downloadAlbumsBtn.className = 'download-btn download-btn--main'; + // Insert the new button into the header container. + const headerContainer = document.getElementById('playlist-header'); + if (headerContainer) { + headerContainer.appendChild(downloadAlbumsBtn); + } + } + + if (isExplicitFilterEnabled && hasExplicitTrack) { + // Disable both playlist buttons and display messages explaining why + if (downloadPlaylistBtn) { + downloadPlaylistBtn.disabled = true; + downloadPlaylistBtn.classList.add('download-btn--disabled'); + downloadPlaylistBtn.innerHTML = `Playlist Contains Explicit Tracks`; + } + + if (downloadAlbumsBtn) { + downloadAlbumsBtn.disabled = true; + downloadAlbumsBtn.classList.add('download-btn--disabled'); + downloadAlbumsBtn.innerHTML = `Albums Access Restricted`; + } + } else { + // Normal behavior when no explicit tracks are present + if (downloadPlaylistBtn) { + downloadPlaylistBtn.addEventListener('click', () => { + // Remove individual track download buttons (but leave the whole playlist button). + document.querySelectorAll('.download-btn').forEach(btn => { + if (btn.id !== 'downloadPlaylistBtn') { + btn.remove(); + } + }); + + // Disable the whole playlist button to prevent repeated clicks. + downloadPlaylistBtn.disabled = true; + downloadPlaylistBtn.textContent = 'Queueing...'; + + // Initiate the playlist download. + downloadWholePlaylist(playlist).then(() => { + downloadPlaylistBtn.textContent = 'Queued!'; + }).catch((err: any) => { + showError('Failed to queue playlist download: ' + (err?.message || 'Unknown error')); + if (downloadPlaylistBtn) downloadPlaylistBtn.disabled = false; // Re-enable on error + }); + }); + } + + if (downloadAlbumsBtn) { + downloadAlbumsBtn.addEventListener('click', () => { + // Remove individual track download buttons (but leave this album button). + document.querySelectorAll('.download-btn').forEach(btn => { + if (btn.id !== 'downloadAlbumsBtn') btn.remove(); + }); + + downloadAlbumsBtn.disabled = true; + downloadAlbumsBtn.textContent = 'Queueing...'; + + downloadPlaylistAlbums(playlist) + .then(() => { + if (downloadAlbumsBtn) downloadAlbumsBtn.textContent = 'Queued!'; + }) + .catch((err: any) => { + showError('Failed to queue album downloads: ' + (err?.message || 'Unknown error')); + if (downloadAlbumsBtn) downloadAlbumsBtn.disabled = false; // Re-enable on error + }); + }); + } + } + + // Render tracks list + const tracksList = document.getElementById('tracks-list'); + if (!tracksList) return; + + tracksList.innerHTML = ''; // Clear any existing content + + // Determine if the playlist is being watched to show/hide management buttons + const watchPlaylistButton = document.getElementById('watchPlaylistBtn') as HTMLButtonElement; + const isPlaylistWatched = watchPlaylistButton && watchPlaylistButton.classList.contains('watching'); + + if (playlist.tracks?.items) { + playlist.tracks.items.forEach((item: PlaylistItem, index: number) => { + if (!item || !item.track) return; // Skip null/undefined tracks + + const track = item.track; + + // Skip explicit tracks if filter is enabled + if (isExplicitFilterEnabled && track.explicit) { + // Add a placeholder for filtered explicit tracks + const trackElement = document.createElement('div'); + trackElement.className = 'track track-filtered'; + trackElement.innerHTML = ` +
${index + 1}
+
+
Explicit Content Filtered
+
This track is not shown due to explicit content filter settings
+
+
Not available
+
--:--
+ `; + tracksList.appendChild(trackElement); + return; + } + + const trackLink = `/track/${track.id || ''}`; + const artistLink = `/artist/${track.artists?.[0]?.id || ''}`; + const albumLink = `/album/${track.album?.id || ''}`; + + const trackElement = document.createElement('div'); + trackElement.className = 'track'; + let trackHTML = ` +
${index + 1}
+ + +
${msToTime(track.duration_ms || 0)}
+ `; + + const actionsContainer = document.createElement('div'); + actionsContainer.className = 'track-actions-container'; + + if (!(isExplicitFilterEnabled && hasExplicitTrack)) { + const downloadBtnHTML = ` + + `; + actionsContainer.innerHTML += downloadBtnHTML; + } + + if (isPlaylistWatched) { + // Initial state is set based on track.is_locally_known + const isKnown = track.is_locally_known === true; // Ensure boolean check, default to false if undefined + const initialStatus = isKnown ? "known" : "missing"; + const initialIcon = isKnown ? "/static/images/check.svg" : "/static/images/missing.svg"; + const initialTitle = isKnown ? "Click to mark as missing from DB" : "Click to mark as known in DB"; + + const toggleKnownBtnHTML = ` + + `; + actionsContainer.innerHTML += toggleKnownBtnHTML; + } + + trackElement.innerHTML = trackHTML; + trackElement.appendChild(actionsContainer); + tracksList.appendChild(trackElement); + }); + } + + // Reveal header and tracks container + const playlistHeaderEl = document.getElementById('playlist-header'); + if (playlistHeaderEl) playlistHeaderEl.classList.remove('hidden'); + const tracksContainerEl = document.getElementById('tracks-container'); + if (tracksContainerEl) tracksContainerEl.classList.remove('hidden'); + + // Attach download listeners to newly rendered download buttons + attachTrackActionListeners(); +} + +/** + * Converts milliseconds to minutes:seconds. + */ +function msToTime(duration: number) { + if (!duration || isNaN(duration)) return '0:00'; + + const minutes = Math.floor(duration / 60000); + const seconds = ((duration % 60000) / 1000).toFixed(0); + return `${minutes}:${seconds.padStart(2, '0')}`; +} + +/** + * Displays an error message in the UI. + */ +function showError(message: string) { + const errorEl = document.getElementById('error'); + if (errorEl) { + errorEl.textContent = message || 'An error occurred'; + errorEl.classList.remove('hidden'); + } +} + +/** + * Attaches event listeners to all individual track action buttons (download, mark known, mark missing). + */ +function attachTrackActionListeners() { + document.querySelectorAll('.track-download-btn').forEach((btn) => { + btn.addEventListener('click', (e: Event) => { + e.stopPropagation(); + const currentTarget = e.currentTarget as HTMLButtonElement; + const itemId = currentTarget.dataset.id || ''; + const type = currentTarget.dataset.type || 'track'; + const name = currentTarget.dataset.name || 'Unknown'; + if (!itemId) { + showError('Missing item ID for download on playlist page'); + return; + } + currentTarget.remove(); + startDownload(itemId, type, { name }, ''); + }); + }); + + document.querySelectorAll('.toggle-known-status-btn').forEach((btn) => { + btn.addEventListener('click', async (e: Event) => { + e.stopPropagation(); + const button = e.currentTarget as HTMLButtonElement; + const trackId = button.dataset.id || ''; + const playlistId = button.dataset.playlistId || ''; + const currentStatus = button.dataset.status; + const img = button.querySelector('img'); + + if (!trackId || !playlistId || !img) { + showError('Missing data for toggling track status'); + return; + } + + button.disabled = true; + try { + if (currentStatus === 'missing') { + await handleMarkTrackAsKnown(playlistId, trackId); + button.dataset.status = 'known'; + img.src = '/static/images/check.svg'; + button.title = 'Click to mark as missing from DB'; + } else { + await handleMarkTrackAsMissing(playlistId, trackId); + button.dataset.status = 'missing'; + img.src = '/static/images/missing.svg'; + button.title = 'Click to mark as known in DB'; + } + } catch (error) { + // Revert UI on error if needed, error is shown by handlers + showError('Failed to update track status. Please try again.'); + } + button.disabled = false; + }); + }); +} + +async function handleMarkTrackAsKnown(playlistId: string, trackId: string) { + try { + const response = await fetch(`/api/playlist/watch/${playlistId}/tracks`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify([trackId]), + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + const result = await response.json(); + showNotification(result.message || 'Track marked as known.'); + } catch (error: any) { + showError(`Failed to mark track as known: ${error.message}`); + throw error; // Re-throw for the caller to handle button state if needed + } +} + +async function handleMarkTrackAsMissing(playlistId: string, trackId: string) { + try { + const response = await fetch(`/api/playlist/watch/${playlistId}/tracks`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify([trackId]), + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + const result = await response.json(); + showNotification(result.message || 'Track marked as missing.'); + } catch (error: any) { + showError(`Failed to mark track as missing: ${error.message}`); + throw error; // Re-throw + } +} + +/** + * Initiates the whole playlist download by calling the playlist endpoint. + */ +async function downloadWholePlaylist(playlist: Playlist) { + if (!playlist) { + throw new Error('Invalid playlist data'); + } + + const playlistId = playlist.id || ''; + if (!playlistId) { + throw new Error('Missing playlist ID'); + } + + try { + // Use the centralized downloadQueue.download method + await downloadQueue.download(playlistId, 'playlist', { + name: playlist.name || 'Unknown Playlist', + owner: playlist.owner?.display_name // Pass owner as a string + // total_tracks can also be passed if QueueItem supports it directly + }); + // Make the queue visible after queueing + downloadQueue.toggleVisibility(true); + } catch (error: any) { + showError('Playlist download failed: ' + (error?.message || 'Unknown error')); + throw error; + } +} + +/** + * Initiates album downloads for each unique album in the playlist, + * adding a 20ms delay between each album download and updating the button + * with the progress (queued_albums/total_albums). + */ +async function downloadPlaylistAlbums(playlist: Playlist) { + if (!playlist?.tracks?.items) { + showError('No tracks found in this playlist.'); + return; + } + + // Build a map of unique albums (using album ID as the key). + const albumMap = new Map(); + playlist.tracks.items.forEach((item: PlaylistItem) => { + if (!item?.track?.album) return; + + const album = item.track.album; + if (album && album.id) { + albumMap.set(album.id, album); + } + }); + + const uniqueAlbums = Array.from(albumMap.values()); + const totalAlbums = uniqueAlbums.length; + if (totalAlbums === 0) { + showError('No albums found in this playlist.'); + return; + } + + // Get a reference to the "Download Playlist's Albums" button. + const downloadAlbumsBtn = document.getElementById('downloadAlbumsBtn') as HTMLButtonElement | null; + if (downloadAlbumsBtn) { + // Initialize the progress display. + downloadAlbumsBtn.textContent = `0/${totalAlbums}`; + } + + try { + // Process each album sequentially. + for (let i = 0; i < totalAlbums; i++) { + const album = uniqueAlbums[i]; + if (!album) continue; + + const albumUrl = album.external_urls?.spotify || ''; + if (!albumUrl) continue; + + // Use the centralized downloadQueue.download method + await downloadQueue.download( + album.id, // Pass album ID directly + 'album', + { + name: album.name || 'Unknown Album', + // If artist information is available on album objects from playlist, pass it + // artist: album.artists?.[0]?.name + } + ); + + // Update button text with current progress. + if (downloadAlbumsBtn) { + downloadAlbumsBtn.textContent = `${i + 1}/${totalAlbums}`; + } + + // Wait 20 milliseconds before processing the next album. + await new Promise(resolve => setTimeout(resolve, 20)); + } + + // Once all albums have been queued, update the button text. + if (downloadAlbumsBtn) { + downloadAlbumsBtn.textContent = 'Queued!'; + } + + // Make the queue visible after queueing all albums + downloadQueue.toggleVisibility(true); + } catch (error: any) { + // Propagate any errors encountered. + throw error; + } +} + +/** + * Starts the download process using the centralized download method from the queue. + */ +async function startDownload(itemId: string, type: string, item: DownloadQueueItem, albumType?: string) { + if (!itemId || !type) { + showError('Missing ID or type for download'); + return; + } + + try { + // Use the centralized downloadQueue.download method + await downloadQueue.download(itemId, type, item, albumType); + + // Make the queue visible after queueing + downloadQueue.toggleVisibility(true); + } catch (error: any) { + showError('Download failed: ' + (error?.message || 'Unknown error')); + throw error; + } +} + +/** + * A helper function to extract a display name from the URL. + */ +function extractName(url: string | null): string { + return url || 'Unknown'; +} + +/** + * Fetches the watch status of the current playlist and updates the UI. + */ +async function fetchWatchStatus(playlistId: string) { + if (!playlistId) return; + try { + const response = await fetch(`/api/playlist/watch/${playlistId}/status`); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to fetch watch status'); + } + const data: WatchedPlaylistStatus = await response.json(); + updateWatchButtons(data.is_watched, playlistId); + } catch (error) { + console.error('Error fetching watch status:', error); + // Don't show a blocking error, but maybe a small notification or log + // For now, assume not watched if status fetch fails, or keep buttons in default state + updateWatchButtons(false, playlistId); + } +} + +/** + * Updates the Watch/Unwatch and Sync buttons based on the playlist's watch status. + */ +function updateWatchButtons(isWatched: boolean, playlistId: string) { + const watchBtn = document.getElementById('watchPlaylistBtn') as HTMLButtonElement; + const syncBtn = document.getElementById('syncPlaylistBtn') as HTMLButtonElement; + + if (!watchBtn || !syncBtn) return; + + const watchBtnImg = watchBtn.querySelector('img'); + + if (isWatched) { + watchBtn.innerHTML = `Unwatch Unwatch Playlist`; + watchBtn.classList.add('watching'); + watchBtn.onclick = () => unwatchPlaylist(playlistId); + syncBtn.classList.remove('hidden'); + syncBtn.onclick = () => syncPlaylist(playlistId); + } else { + watchBtn.innerHTML = `Watch Watch Playlist`; + watchBtn.classList.remove('watching'); + watchBtn.onclick = () => watchPlaylist(playlistId); + syncBtn.classList.add('hidden'); + } + watchBtn.disabled = false; // Enable after status is known +} + +/** + * Adds the current playlist to the watchlist. + */ +async function watchPlaylist(playlistId: string) { + const watchBtn = document.getElementById('watchPlaylistBtn') as HTMLButtonElement; + if (watchBtn) watchBtn.disabled = true; + + try { + const response = await fetch(`/api/playlist/watch/${playlistId}`, { method: 'PUT' }); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to watch playlist'); + } + updateWatchButtons(true, playlistId); + // Re-fetch and re-render playlist data + const newPlaylistInfoResponse = await fetch(`/api/playlist/info?id=${encodeURIComponent(playlistId)}`); + if (!newPlaylistInfoResponse.ok) throw new Error('Failed to re-fetch playlist info after watch.'); + const newPlaylistData = await newPlaylistInfoResponse.json() as Playlist; + renderPlaylist(newPlaylistData); + + showNotification(`Playlist added to watchlist. Tracks are being updated.`); + } catch (error: any) { + showError(`Error watching playlist: ${error.message}`); + if (watchBtn) watchBtn.disabled = false; // Re-enable on error before potential UI revert + } +} + +/** + * Removes the current playlist from the watchlist. + */ +async function unwatchPlaylist(playlistId: string) { + const watchBtn = document.getElementById('watchPlaylistBtn') as HTMLButtonElement; + if (watchBtn) watchBtn.disabled = true; + + try { + const response = await fetch(`/api/playlist/watch/${playlistId}`, { method: 'DELETE' }); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to unwatch playlist'); + } + updateWatchButtons(false, playlistId); + // Re-fetch and re-render playlist data + const newPlaylistInfoResponse = await fetch(`/api/playlist/info?id=${encodeURIComponent(playlistId)}`); + if (!newPlaylistInfoResponse.ok) throw new Error('Failed to re-fetch playlist info after unwatch.'); + const newPlaylistData = await newPlaylistInfoResponse.json() as Playlist; + renderPlaylist(newPlaylistData); + + showNotification('Playlist removed from watchlist. Track statuses updated.'); + } catch (error: any) { + showError(`Error unwatching playlist: ${error.message}`); + if (watchBtn) watchBtn.disabled = false; // Re-enable on error before potential UI revert + } +} + +/** + * Triggers a manual sync for the watched playlist. + */ +async function syncPlaylist(playlistId: string) { + const syncBtn = document.getElementById('syncPlaylistBtn') as HTMLButtonElement; + let originalButtonContent = ''; // Define outside + + if (syncBtn) { + syncBtn.disabled = true; + originalButtonContent = syncBtn.innerHTML; // Store full HTML + syncBtn.innerHTML = `Sync Syncing...`; // Keep icon + } + + try { + const response = await fetch(`/api/playlist/watch/trigger_check/${playlistId}`, { method: 'POST' }); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to trigger sync'); + } + showNotification('Playlist sync triggered successfully.'); + } catch (error: any) { + showError(`Error triggering sync: ${error.message}`); + } finally { + if (syncBtn) { + syncBtn.disabled = false; + syncBtn.innerHTML = originalButtonContent; // Restore full original HTML + } + } +} + +/** + * Displays a temporary notification message. + */ +function showNotification(message: string) { + // Basic notification - consider a more robust solution for production + const notificationEl = document.createElement('div'); + notificationEl.className = 'notification'; + notificationEl.textContent = message; + document.body.appendChild(notificationEl); + setTimeout(() => { + notificationEl.remove(); + }, 3000); +} diff --git a/static/js/queue.js b/src/js/queue.ts similarity index 64% rename from static/js/queue.js rename to src/js/queue.ts index d082674..3f0f594 100644 --- a/static/js/queue.js +++ b/src/js/queue.ts @@ -1,28 +1,181 @@ // --- MODIFIED: Custom URLSearchParams class that does not encode anything --- class CustomURLSearchParams { + params: Record; constructor() { this.params = {}; } - append(key, value) { + append(key: string, value: string): void { this.params[key] = value; } - toString() { + toString(): string { return Object.entries(this.params) - .map(([key, value]) => `${key}=${value}`) + .map(([key, value]: [string, string]) => `${key}=${value}`) .join('&'); } } // --- END MODIFIED --- -class DownloadQueue { +// 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?: string; + status?: string; + name?: string; + song?: string; + music?: string; + title?: string; + artist?: string; + artist_name?: string; + album?: string; + owner?: string; + total_tracks?: number | string; + current_track?: number | string; + parsed_current_track?: string; // Make sure these are handled if they are strings + parsed_total_tracks?: string; // Make sure these are handled if they are strings + progress?: number | string; // Can be string initially + percentage?: number | string; // Can be string initially + percent?: number | string; // Can be string initially + time_elapsed?: number; + error?: string; + can_retry?: boolean; + retry_count?: number; + max_retries?: number; // from config potentially + seconds_left?: number; + prg_file?: string; + url?: string; + reason?: string; // for skipped + parent?: ParentInfo; + original_url?: string; + position?: number; // For queued items + 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; // For other potential original_request params + }; + event?: string; // from SSE + overall_progress?: number; + display_type?: string; // from PRG data + [key: string]: any; // Allow other properties +} + +interface QueueEntry { + item: QueueItem; + type: string; + prgFile: 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 = {}; + // this.queueCache = {}; // Already initialized above // Queue entry objects this.queueEntries = {}; @@ -37,19 +190,21 @@ class DownloadQueue { this.eventHandlers = {}; // Configuration - this.config = null; + this.config = {}; // Initialize config - // Load the saved visible count (or default to 10) - const storedVisibleCount = localStorage.getItem("downloadQueueVisibleCount"); - this.visibleCount = storedVisibleCount ? parseInt(storedVisibleCount, 10) : 10; + // 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 prgFile) - this.queueCache = JSON.parse(localStorage.getItem("downloadQueueCache") || "{}"); + // Load the cached status info (object keyed by prgFile) - 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.loadExistingPrgFiles(); + // Start periodic sync + setInterval(() => this.periodicSyncWithServer(), 10000); // Sync every 10 seconds }); } @@ -62,7 +217,7 @@ class DownloadQueue {

Download Queue (0 items)

@@ -79,18 +234,24 @@ class DownloadQueue { // Override the server value with locally persisted queue visibility (if present). const storedVisible = localStorage.getItem("downloadQueueVisible"); if (storedVisible !== null) { - this.config.downloadQueueVisible = storedVisible === "true"; + // Ensure config is not null before assigning + if (this.config) { + this.config.downloadQueueVisible = storedVisible === "true"; + } } const queueSidebar = document.getElementById('downloadQueue'); - queueSidebar.hidden = !this.config.downloadQueueVisible; - queueSidebar.classList.toggle('active', this.config.downloadQueueVisible); + // Ensure config is not null and queueSidebar exists + if (this.config && queueSidebar) { + queueSidebar.hidden = !this.config.downloadQueueVisible; + queueSidebar.classList.toggle('active', !!this.config.downloadQueueVisible); + } // Initialize the queue icon based on sidebar visibility const queueIcon = document.getElementById('queueIcon'); - if (queueIcon) { + if (queueIcon && this.config) { if (this.config.downloadQueueVisible) { - queueIcon.innerHTML = '×'; + queueIcon.innerHTML = 'Close queue'; queueIcon.setAttribute('aria-expanded', 'true'); queueIcon.classList.add('queue-icon-active'); // Add red tint class } else { @@ -104,9 +265,9 @@ class DownloadQueue { /* Event Handling */ initEventListeners() { // Toggle queue visibility via Escape key. - document.addEventListener('keydown', async (e) => { + document.addEventListener('keydown', async (e: KeyboardEvent) => { const queueSidebar = document.getElementById('downloadQueue'); - if (e.key === 'Escape' && queueSidebar.classList.contains('active')) { + if (e.key === 'Escape' && queueSidebar?.classList.contains('active')) { await this.toggleVisibility(); } }); @@ -117,7 +278,7 @@ class DownloadQueue { cancelAllBtn.addEventListener('click', () => { for (const queueId in this.queueEntries) { const entry = this.queueEntries[queueId]; - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; if (entry && !entry.hasEnded && entry.prgFile) { // Mark as cancelling visually if (entry.element) { @@ -135,7 +296,7 @@ class DownloadQueue { if (data.status === "cancelled" || data.status === "cancel") { entry.hasEnded = true; if (entry.intervalId) { - clearInterval(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 @@ -156,8 +317,9 @@ class DownloadQueue { } /* Public API */ - async toggleVisibility(force) { + 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'); @@ -166,10 +328,10 @@ class DownloadQueue { // Update the queue icon to show X when visible or queue icon when hidden const queueIcon = document.getElementById('queueIcon'); - if (queueIcon) { + if (queueIcon && this.config) { if (isVisible) { // Replace the image with an X and add red tint - queueIcon.innerHTML = '×'; + queueIcon.innerHTML = 'Close queue'; queueIcon.setAttribute('aria-expanded', 'true'); queueIcon.classList.add('queue-icon-active'); // Add red tint class } else { @@ -181,7 +343,7 @@ class DownloadQueue { } // Persist the state locally so it survives refreshes. - localStorage.setItem("downloadQueueVisible", isVisible); + localStorage.setItem("downloadQueueVisible", String(isVisible)); try { await this.loadConfig(); @@ -194,34 +356,39 @@ class DownloadQueue { queueSidebar.classList.toggle('active', !isVisible); queueSidebar.hidden = isVisible; // Also revert the icon back - if (queueIcon) { + if (queueIcon && this.config) { if (!isVisible) { - queueIcon.innerHTML = '×'; + 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 + queueIcon.innerHTML = 'Close queue'; + queueIcon.setAttribute('aria-expanded', 'true'); + queueIcon.classList.add('queue-icon-active'); // Add red tint class } } this.dispatchEvent('queueVisibilityChanged', { visible: !isVisible }); this.showError('Failed to save queue visibility'); } + + if (isVisible) { + // If the queue is now visible, ensure all visible items are being polled. + this.startMonitoringActiveEntries(); + } } - showError(message) { + showError(message: string) { const errorDiv = document.createElement('div'); errorDiv.className = 'queue-error'; errorDiv.textContent = message; - document.getElementById('queueItems').prepend(errorDiv); + document.getElementById('queueItems')?.prepend(errorDiv); // Optional chaining setTimeout(() => errorDiv.remove(), 3000); } /** * Adds a new download entry. */ - addDownload(item, type, prgFile, requestUrl = null, startMonitoring = false) { + addDownload(item: QueueItem, type: string, prgFile: string, requestUrl: string | null = null, startMonitoring: boolean = false): string { const queueId = this.generateQueueId(); const entry = this.createQueueEntry(item, type, prgFile, queueId, requestUrl); this.queueEntries[queueId] = entry; @@ -238,7 +405,7 @@ class DownloadQueue { } /* Start processing the entry. Removed visibility check to ensure all entries are monitored. */ - async startDownloadStatusMonitoring(queueId) { + async startDownloadStatusMonitoring(queueId: string) { const entry = this.queueEntries[queueId]; if (!entry || entry.hasEnded) return; @@ -246,11 +413,11 @@ class DownloadQueue { if (this.pollingIntervals[queueId]) return; // Ensure entry has data containers for parent info - entry.parentInfo = entry.parentInfo || {}; + entry.parentInfo = entry.parentInfo || null; // Show a preparing message for new entries if (entry.isNew) { - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; if (logElement) { logElement.textContent = "Initializing download..."; } @@ -262,14 +429,14 @@ class DownloadQueue { try { const response = await fetch(`/api/prgs/${entry.prgFile}`); if (response.ok) { - const data = await response.json(); + 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'); + 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}`; @@ -294,10 +461,10 @@ class DownloadQueue { if (data.last_line) { entry.lastStatus = data.last_line; entry.lastUpdated = Date.now(); - entry.status = data.last_line.status; + 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.prgFile}`); + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; if (logElement) { const statusMessage = this.getStatusMessage(data.last_line); logElement.textContent = statusMessage; @@ -325,7 +492,7 @@ class DownloadQueue { entry.type = parent.type; // Update the type indicator - const typeEl = entry.element.querySelector('.type'); + 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; @@ -333,8 +500,8 @@ class DownloadQueue { } // Update the title and subtitle based on parent type - const titleEl = entry.element.querySelector('.title'); - const artistEl = entry.element.querySelector('.artist'); + 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'; @@ -350,7 +517,7 @@ class DownloadQueue { 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)) { + 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; @@ -373,11 +540,11 @@ class DownloadQueue { /** * Creates a new queue entry. It checks localStorage for any cached info. */ - createQueueEntry(item, type, prgFile, queueId, requestUrl) { + createQueueEntry(item: QueueItem, type: string, prgFile: string, queueId: string, requestUrl: string | null): QueueEntry { console.log(`Creating queue entry with initial type: ${type}`); // Get cached data if it exists - const cachedData = this.queueCache[prgFile]; + const cachedData: StatusData | undefined = this.queueCache[prgFile]; // Add type // If we have cached data, use it to determine the true type and item properties if (cachedData) { @@ -406,19 +573,19 @@ class DownloadQueue { item = { name: cachedData.title || cachedData.album || 'Unknown album', artist: cachedData.artist || 'Unknown artist', - total_tracks: cachedData.total_tracks || 0 + 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: cachedData.total_tracks || 0 + 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 = { + const entry: QueueEntry = { // Add type to entry item, type, prgFile, @@ -432,7 +599,7 @@ class DownloadQueue { artist: item.artist || item.artists?.[0]?.name || '', album: item.album?.name || '', title: item.name || '', - owner: item.owner || item.owner?.display_name || '', + owner: typeof item.owner === 'string' ? item.owner : item.owner?.display_name || '', total_tracks: item.total_tracks || 0 }, lastUpdated: Date.now(), @@ -444,13 +611,14 @@ class DownloadQueue { 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 + 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 PRG file, use it. if (cachedData) { entry.lastStatus = cachedData; - const logEl = entry.element.querySelector('.log'); + const logEl = entry.element.querySelector('.log') as HTMLElement | null; // Store parent information if available if (cachedData.parent) { @@ -458,7 +626,9 @@ class DownloadQueue { } // Render status message for cached data - logEl.textContent = this.getStatusMessage(entry.lastStatus); + if (logEl) { // Check if logEl is not null + logEl.textContent = this.getStatusMessage(entry.lastStatus); + } } // Store it in our queue object @@ -470,7 +640,7 @@ class DownloadQueue { /** * Returns an HTML element for the queue entry with modern UI styling. */ -createQueueItem(item, type, prgFile, queueId) { +createQueueItem(item: QueueItem, type: string, prgFile: 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...'; @@ -480,7 +650,7 @@ createQueueItem(item, type, prgFile, queueId) { const displayArtist = item.artist || ''; const displayType = type.charAt(0).toUpperCase() + type.slice(1); - const div = document.createElement('article'); + 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'); @@ -495,7 +665,7 @@ createQueueItem(item, type, prgFile, queueId) {
${displayType}
@@ -534,7 +704,7 @@ createQueueItem(item, type, prgFile, queueId) { div.innerHTML = innerHtml; - div.querySelector('.cancel-btn').addEventListener('click', (e) => this.handleCancelDownload(e)); + (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(() => { @@ -545,7 +715,7 @@ createQueueItem(item, type, prgFile, queueId) { } // Add a helper method to apply the right CSS classes based on status - applyStatusClasses(entry, status) { + applyStatusClasses(entry: QueueEntry, statusData: StatusData) { // Add types for statusData // If no element, nothing to do if (!entry.element) return; @@ -556,7 +726,7 @@ createQueueItem(item, type, prgFile, queueId) { ); // Handle various status types - switch (status) { + switch (statusData.status) { // Use statusData.status case 'queued': entry.element.classList.add('queued'); break; @@ -575,7 +745,7 @@ createQueueItem(item, type, prgFile, queueId) { 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.prgFile}`); + const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; if (errorDetailsContainer) { errorDetailsContainer.style.display = 'none'; } @@ -585,7 +755,7 @@ createQueueItem(item, type, prgFile, queueId) { entry.element.classList.add('complete'); // Hide error details if present if (entry.element) { - const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`); + const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; if (errorDetailsContainer) { errorDetailsContainer.style.display = 'none'; } @@ -595,7 +765,7 @@ createQueueItem(item, type, prgFile, queueId) { entry.element.classList.add('cancelled'); // Hide error details if present if (entry.element) { - const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`); + const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; if (errorDetailsContainer) { errorDetailsContainer.style.display = 'none'; } @@ -604,10 +774,13 @@ createQueueItem(item, type, prgFile, queueId) { } } - async handleCancelDownload(e) { - const btn = e.target.closest('button'); + 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 { prg, type, queueid } = btn.dataset; + if (!prg || !type || !queueid) return; // Guard against undefined dataset properties + try { // Get the queue item element const entry = this.queueEntries[queueid]; @@ -617,7 +790,7 @@ createQueueItem(item, type, prgFile, queueId) { } // Show cancellation in progress - const logElement = document.getElementById(`log-${queueid}-${prg}`); + const logElement = document.getElementById(`log-${queueid}-${prg}`) as HTMLElement | null; if (logElement) { logElement.textContent = "Cancelling..."; } @@ -634,7 +807,7 @@ createQueueItem(item, type, prgFile, queueId) { this.clearPollingInterval(queueid); if (entry.intervalId) { - clearInterval(entry.intervalId); + clearInterval(entry.intervalId as number); // Cast to number entry.intervalId = null; } @@ -656,11 +829,12 @@ createQueueItem(item, type, prgFile, queueId) { 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, b) => { - const getGroup = (entry) => { + 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") { @@ -684,7 +858,10 @@ createQueueItem(item, type, prgFile, queueId) { }); // Update the header with just the total count - document.getElementById('queueTotalCount').textContent = entries.length; + 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'); @@ -711,7 +888,7 @@ createQueueItem(item, type, prgFile, queueId) { if (visibleItems.length === 0) { // No items in container, append all visible entries container.innerHTML = ''; // Clear any empty state - visibleEntries.forEach(entry => { + 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); @@ -720,17 +897,17 @@ createQueueItem(item, type, prgFile, queueId) { // Container already has items, update more efficiently // Create a map of current DOM elements by queue ID - const existingElementMap = {}; + const existingElementMap: { [key: string]: HTMLElement } = {}; visibleItems.forEach(el => { - const queueId = el.querySelector('.cancel-btn')?.dataset.queueid; - if (queueId) existingElementMap[queueId] = 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 => { + visibleEntries.forEach((entry: QueueEntry) => { // We no longer automatically start monitoring here container.appendChild(entry.element); @@ -743,6 +920,15 @@ createQueueItem(item, type, prgFile, queueId) { // 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.prgFile})`); + this.setupPollingInterval(entry.uniqueId); + } + }); + // Update footer footer.innerHTML = ''; if (entries.length > this.visibleCount) { @@ -751,7 +937,7 @@ createQueueItem(item, type, prgFile, queueId) { showMoreBtn.textContent = `Show ${remaining} more`; showMoreBtn.addEventListener('click', () => { this.visibleCount += 10; - localStorage.setItem("downloadQueueVisibleCount", this.visibleCount); + localStorage.setItem("downloadQueueVisibleCount", this.visibleCount.toString()); // toString this.updateQueueOrder(); }); footer.appendChild(showMoreBtn); @@ -759,10 +945,10 @@ createQueueItem(item, type, prgFile, queueId) { } /* Checks if an entry is visible in the queue display. */ - isEntryVisible(queueId) { + isEntryVisible(queueId: string): boolean { // Add return type const entries = Object.values(this.queueEntries); - entries.sort((a, b) => { - const getGroup = (entry) => { + 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") { @@ -784,11 +970,11 @@ createQueueItem(item, type, prgFile, queueId) { return a.lastUpdated - b.lastUpdated; } }); - const index = entries.findIndex(e => e.uniqueId === queueId); + const index = entries.findIndex((e: QueueEntry) => e.uniqueId === queueId); return index >= 0 && index < this.visibleCount; } - async cleanupEntry(queueId) { + async cleanupEntry(queueId: string) { const entry = this.queueEntries[queueId]; if (entry) { // Close any polling interval @@ -796,10 +982,10 @@ createQueueItem(item, type, prgFile, queueId) { // Clean up any intervals if (entry.intervalId) { - clearInterval(entry.intervalId); + clearInterval(entry.intervalId as number); // Cast to number } if (entry.autoRetryInterval) { - clearInterval(entry.autoRetryInterval); + clearInterval(entry.autoRetryInterval as number); // Cast to number } // Remove from the DOM @@ -814,30 +1000,18 @@ createQueueItem(item, type, prgFile, queueId) { localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); } - // Delete the entry from the server - try { - const response = await fetch(`/api/prgs/delete/${entry.prgFile}`, { method: 'DELETE' }); - if (response.ok) { - console.log(`Successfully deleted task ${entry.prgFile} from server`); - } else { - console.warn(`Failed to delete task ${entry.prgFile}: ${response.status} ${response.statusText}`); - } - } catch (error) { - console.error(`Error deleting task ${entry.prgFile}:`, error); - } - // Update the queue display this.updateQueueOrder(); } } /* Event Dispatching */ - dispatchEvent(name, detail) { + dispatchEvent(name: string, detail: any) { // Add type for name document.dispatchEvent(new CustomEvent(name, { detail })); } /* Status Message Handling */ - getStatusMessage(data) { + 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'; @@ -850,7 +1024,7 @@ createQueueItem(item, type, prgFile, queueId) { } // Find the queue item this status belongs to - let queueItem = null; + let queueItem: QueueEntry | null = null; const prgFile = data.prg_file || Object.keys(this.queueCache).find(key => this.queueCache[key].status === data.status && this.queueCache[key].type === data.type ); @@ -874,7 +1048,7 @@ createQueueItem(item, type, prgFile, queueId) { const playlistName = data.name || data.parent?.name || (queueItem?.item?.name) || ''; const playlistOwner = data.owner || data.parent?.owner || - (queueItem?.item?.owner) || ''; + (queueItem?.item?.owner) || ''; // Add type check if item.owner is object const currentTrack = data.current_track || data.parsed_current_track || ''; const totalTracks = data.total_tracks || data.parsed_total_tracks || data.parent?.total_tracks || (queueItem?.item?.total_tracks) || ''; @@ -882,15 +1056,15 @@ createQueueItem(item, type, prgFile, queueId) { // Format percentage for display when available let formattedPercentage = '0'; if (data.progress !== undefined) { - formattedPercentage = parseFloat(data.progress).toFixed(1); + formattedPercentage = parseFloat(data.progress as string).toFixed(1); // Cast to string } else if (data.percentage) { - formattedPercentage = (parseFloat(data.percentage) * 100).toFixed(1); + formattedPercentage = (parseFloat(data.percentage as string) * 100).toFixed(1); // Cast to string } else if (data.percent) { - formattedPercentage = (parseFloat(data.percent) * 100).toFixed(1); + formattedPercentage = (parseFloat(data.percent as string) * 100).toFixed(1); // Cast to string } // Helper for constructing info about the parent item - const getParentInfo = () => { + const getParentInfo = (): string => { // Add return type if (!data.parent) return ''; if (data.parent.type === 'album') { @@ -1103,16 +1277,16 @@ createQueueItem(item, type, prgFile, queueId) { } /* New Methods to Handle Terminal State, Inactivity and Auto-Retry */ - handleDownloadCompletion(entry, queueId, progress) { + handleDownloadCompletion(entry: QueueEntry, queueId: string, progress: StatusData | number) { // Add types // Mark the entry as ended entry.hasEnded = true; // Update progress bar if available if (typeof progress === 'number') { - const progressBar = entry.element.querySelector('.progress-bar'); + const progressBar = entry.element.querySelector('.progress-bar') as HTMLElement | null; if (progressBar) { progressBar.style.width = '100%'; - progressBar.setAttribute('aria-valuenow', 100); + progressBar.setAttribute('aria-valuenow', "100"); // Use string for aria-valuenow progressBar.classList.add('bg-success'); } } @@ -1120,8 +1294,10 @@ createQueueItem(item, type, prgFile, queueId) { // Stop polling this.clearPollingInterval(queueId); - // Use 10 seconds cleanup delay for all states including errors - const cleanupDelay = 10000; + // 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(() => { @@ -1129,7 +1305,7 @@ createQueueItem(item, type, prgFile, queueId) { }, cleanupDelay); } - handleInactivity(entry, queueId, logElement) { + 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); @@ -1138,8 +1314,8 @@ createQueueItem(item, type, prgFile, queueId) { } const now = Date.now(); if (now - entry.lastUpdated > 300000) { - const progress = { status: 'error', message: 'Inactivity timeout' }; - this.handleDownloadCompletion(entry, queueId, progress); + 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); @@ -1147,25 +1323,24 @@ createQueueItem(item, type, prgFile, queueId) { } } - async retryDownload(queueId, logElement) { + async retryDownload(queueId: string, logElement: HTMLElement | null) { // Add type const entry = this.queueEntries[queueId]; - if (!entry) return; + 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. - // Hide any existing error-details and restore log for retry - const errContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`); - if (errContainer) { errContainer.style.display = 'none'; } - logElement.style.display = ''; - - // Mark the entry as retrying to prevent automatic cleanup - entry.isRetrying = true; - logElement.textContent = 'Retrying download...'; - - // Determine if we should use parent information for retry + // Determine if we should use parent information for retry (existing logic) let useParent = false; - let parentType = null; - let parentUrl = null; - - // Check if we have parent information in the lastStatus + 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) { @@ -1176,124 +1351,109 @@ createQueueItem(item, type, prgFile, queueId) { } } - // Find a retry URL from various possible sources - const getRetryUrl = () => { - // Prefer full original URL from progress API - if (entry.lastStatus && entry.lastStatus.original_url) { - return entry.lastStatus.original_url; - } - // If using parent, return parent URL - if (useParent && parentUrl) { - return parentUrl; - } - - // Otherwise use the standard fallback options + 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 we have lastStatus with original_request, check there 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.original_request.retry_url) return entry.lastStatus.original_request.retry_url; + if (entry.lastStatus.original_request.url) return entry.lastStatus.original_request.url; } - - // Check if there's a URL directly in the lastStatus - if (entry.lastStatus && entry.lastStatus.url) - return entry.lastStatus.url; - - // Fallback to stored requestUrl - if (entry.requestUrl) { - return entry.requestUrl; - } - + if (entry.lastStatus && entry.lastStatus.url) return entry.lastStatus.url; return null; }; const retryUrl = getRetryUrl(); - // If we don't have any retry URL, show error if (!retryUrl) { - logElement.textContent = 'Retry not available: missing URL information.'; - entry.isRetrying = false; // Reset retrying flag + 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; } - try { - // Close any existing polling interval - this.clearPollingInterval(queueId); + // 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}`); - // Determine which type to use for the API endpoint - const apiType = useParent ? parentType : entry.type; - console.log(`Retrying download using type: ${apiType} with URL: ${retryUrl}`); - - // Determine request URL: if retryUrl is already a full API URL, use it directly let fullRetryUrl; - if (retryUrl.startsWith('http')) { + if (retryUrl.startsWith('http') || retryUrl.startsWith('/api/')) { // if it's already a full URL or an API path fullRetryUrl = retryUrl; } else { - const apiUrl = `/api/${apiType}/download?url=${encodeURIComponent(retryUrl)}`; - fullRetryUrl = apiUrl; + // 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 (entry.item && entry.item.name) { - fullRetryUrl += `&name=${encodeURIComponent(entry.item.name)}`; + if (originalItem && originalItem.name) { + fullRetryUrl += `&name=${encodeURIComponent(originalItem.name)}`; } - if (entry.item && entry.item.artist) { - fullRetryUrl += `&artist=${encodeURIComponent(entry.item.artist)}`; + 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); - // Use the stored original request URL to create a new download const retryResponse = await fetch(fullRetryUrl); if (!retryResponse.ok) { - throw new Error(`Server returned ${retryResponse.status}`); + const errorText = await retryResponse.text(); + throw new Error(`Server returned ${retryResponse.status}${errorText ? (': ' + errorText) : ''}`); } - const retryData = await retryResponse.json(); + const retryData: StatusData = await retryResponse.json(); // Add type if (retryData.prg_file) { - // Store the old PRG file for cleanup - const oldPrgFile = entry.prgFile; + const newPrgFile = retryData.prg_file; - // Update the entry with the new PRG file - const logEl = entry.element.querySelector('.log'); - logEl.id = `log-${entry.uniqueId}-${retryData.prg_file}`; - entry.prgFile = retryData.prg_file; - entry.lastStatus = null; - entry.hasEnded = false; - entry.lastUpdated = Date.now(); - entry.retryCount = (entry.retryCount || 0) + 1; - entry.statusCheckFailures = 0; // Reset failure counter - logEl.textContent = 'Retry initiated...'; + // Clean up the old entry from UI, memory, cache, and server (PRG file) + // logElement and retryBtn are part of the old entry's DOM structure and will be removed. + await this.cleanupEntry(queueId); - // Make sure any existing interval is cleared - if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; - } + // Add the new download entry. This will create a new element, start monitoring, etc. + this.addDownload(originalItem, apiTypeForNewEntry, newPrgFile, requestUrlForNewEntry, true); - // Set up a new polling interval for the retried download - this.setupPollingInterval(queueId); - - // Delete the old PRG file after a short delay to ensure the new one is properly set up - if (oldPrgFile) { - setTimeout(async () => { - try { - await fetch(`/api/prgs/delete/${oldPrgFile}`, { method: 'DELETE' }); - console.log(`Cleaned up old PRG file: ${oldPrgFile}`); - } catch (deleteError) { - console.error('Error deleting old PRG file:', deleteError); - } - }, 2000); // Wait 2 seconds before deleting the old file - } + // The old setTimeout block for deleting oldPrgFile is no longer needed as cleanupEntry handles it. } else { - logElement.textContent = 'Retry failed: invalid response from server'; - entry.isRetrying = false; // Reset retrying flag + 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); - logElement.textContent = 'Retry failed: ' + error.message; - entry.isRetrying = false; // Reset retrying flag + // 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'; + } + } } } @@ -1315,25 +1475,39 @@ createQueueItem(item, type, prgFile, queueId) { * This method replaces the individual startTrackDownload, startAlbumDownload, etc. methods. * It will be called by all the other JS files. */ - async download(url, type, item, albumType = null) { - if (!url) { - throw new Error('Missing URL for download'); + 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}`; - // Build the API URL with only the URL parameter as it's all that's needed - let apiUrl = `/api/${type}/download?url=${encodeURIComponent(url)}`; + // 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) { - apiUrl += `&album_type=${encodeURIComponent(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 - if (document.getElementById('queueIcon')) { - document.getElementById('queueIcon').classList.add('queue-icon-active'); + const queueIcon = document.getElementById('queueIcon'); // No direct classList manipulation + if (queueIcon) { + queueIcon.classList.add('queue-icon-active'); } const response = await fetch(apiUrl); @@ -1341,23 +1515,23 @@ createQueueItem(item, type, prgFile, queueId) { throw new Error(`Server returned ${response.status}`); } - const data = await response.json(); + 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 (data.task_ids && Array.isArray(data.task_ids)) { + 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 = []; + 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 = { + const albumItem: QueueItem = { // Add type name: `${item.name || 'Artist'} - Album (loading...)`, artist: item.name || 'Unknown artist', type: 'album' @@ -1370,18 +1544,18 @@ createQueueItem(item, type, prgFile, queueId) { return queueIds; } // Check for older API response format - else if (data.album_prg_files && Array.isArray(data.album_prg_files)) { + 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 = []; + 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 = { + const albumItem: QueueItem = { // Add type name: `${item.name || 'Artist'} - Album (loading...)`, artist: item.name || 'Unknown artist', type: 'album' @@ -1416,8 +1590,8 @@ createQueueItem(item, type, prgFile, queueId) { } // Handle single-file downloads (tracks, albums, playlists) - if (data.prg_file) { - console.log(`Adding ${type} with PRG file: ${data.prg_file}`); + if ('prg_file' in data && data.prg_file) { // Type guard + console.log(`Adding ${type} PRG file: ${data.prg_file}`); // Store the initial metadata in the cache so it's available // even before the first status update @@ -1427,7 +1601,7 @@ createQueueItem(item, type, prgFile, queueId) { name: item.name || 'Unknown', title: item.name || 'Unknown', artist: item.artist || (item.artists && item.artists.length > 0 ? item.artists[0].name : ''), - owner: item.owner || (item.owner ? item.owner.display_name : ''), + owner: typeof item.owner === 'string' ? item.owner : item.owner?.display_name || '', total_tracks: item.total_tracks || 0 }; @@ -1435,7 +1609,7 @@ createQueueItem(item, type, prgFile, queueId) { const queueId = this.addDownload(item, type, data.prg_file, apiUrl, true); // Make queue visible to show progress if not already visible - if (!this.config.downloadQueueVisible) { + if (this.config && !this.config.downloadQueueVisible) { // Add null check for config this.toggleVisibility(true); } @@ -1457,204 +1631,107 @@ createQueueItem(item, type, prgFile, queueId) { // 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 = await response.json(); - - // 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 = await prgResponse.json(); - - // 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 = this.queueCache[prgFile]; - 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 = prgData.last_line || {}; - - // 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 = {}; - - // 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', - total_tracks: parent.total_tracks || 0, - url: parent.url || '', - // Keep track of the current track info for progress display - current_track: lastLineData.current_track, - total_tracks: parent.total_tracks || lastLineData.total_tracks, - // 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', - total_tracks: parent.total_tracks || 0, - url: parent.url || '', - // Keep track of the current track info for progress display - current_track: lastLineData.current_track, - total_tracks: parent.total_tracks || lastLineData.total_tracks, - // 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: 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 = null; - 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); - } - } - - 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'); - if (logElement) { - if (prgData.last_line.song && prgData.last_line.artist && - ['progress', 'real-time', 'real_time', 'processing', 'downloading'].includes(prgData.last_line.status)) { - 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); @@ -1681,11 +1758,17 @@ createQueueItem(item, type, prgFile, queueId) { 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 = {}; + this.config = { // Initialize with a default structure on error + downloadQueueVisible: false, + maxRetries: 3, + retryDelaySeconds: 5, + retry_delay_increase: 5, + explicitFilter: false + }; } } - async saveConfig(updatedConfig) { + async saveConfig(updatedConfig: AppConfig) { // Add type try { const response = await fetch('/api/config', { method: 'POST', @@ -1701,12 +1784,12 @@ createQueueItem(item, type, prgFile, queueId) { } // Add a method to check if explicit filter is enabled - isExplicitFilterEnabled() { + isExplicitFilterEnabled(): boolean { // Add return type return !!this.config.explicitFilter; } /* Sets up a polling interval for real-time status updates */ - setupPollingInterval(queueId) { + setupPollingInterval(queueId: string) { // Add type console.log(`Setting up polling for ${queueId}`); const entry = this.queueEntries[queueId]; if (!entry || !entry.prgFile) { @@ -1727,18 +1810,18 @@ createQueueItem(item, type, prgFile, queueId) { }, 500); // Store the interval ID for later cleanup - this.pollingIntervals[queueId] = intervalId; + 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.prgFile}`); + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; if (logElement) { - logElement.textContent = `Error with download: ${error.message}`; + logElement.textContent = `Error with download: ${(error as Error).message}`; // Cast to Error entry.element.classList.add('error'); } } } - async fetchDownloadStatus(queueId) { + async fetchDownloadStatus(queueId: string) { // Add type const entry = this.queueEntries[queueId]; if (!entry || !entry.prgFile) { console.warn(`No entry or prgFile for ${queueId}`); @@ -1751,7 +1834,7 @@ createQueueItem(item, type, prgFile, queueId) { throw new Error(`HTTP error: ${response.status}`); } - const data = await response.json(); + 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) { @@ -1767,7 +1850,7 @@ createQueueItem(item, type, prgFile, queueId) { data.last_line.artist = entry.item.artists[0].name; } if (!data.last_line.owner && entry.item.owner) { - 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; @@ -1780,7 +1863,7 @@ createQueueItem(item, type, prgFile, queueId) { entry.type = data.type; // Update type display if element exists - const typeElement = entry.element.querySelector('.type'); + 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 @@ -1810,7 +1893,7 @@ createQueueItem(item, type, prgFile, queueId) { this.handleStatusUpdate(queueId, data); // Handle terminal states - if (data.last_line && ['complete', 'error', 'cancelled', 'done'].includes(data.last_line.status)) { + 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}`); entry.hasEnded = true; @@ -1831,13 +1914,14 @@ createQueueItem(item, type, prgFile, queueId) { if (!isRetrying) { setTimeout(() => { // Double-check the entry still exists and has not been retried before cleaning up - if (this.queueEntries[queueId] && - !this.queueEntries[queueId].isRetrying && - this.queueEntries[queueId].hasEnded) { + const currentEntry = this.queueEntries[queueId]; // Get current entry + if (currentEntry && // Check if currentEntry exists + !currentEntry.isRetrying && + currentEntry.hasEnded) { this.clearPollingInterval(queueId); this.cleanupEntry(queueId); } - }, 5000); + }, data.last_line.status === 'complete' || data.last_line.status === 'done' ? 3000 : 5000); // 3s for complete/done, 5s for others } } @@ -1845,18 +1929,18 @@ createQueueItem(item, type, prgFile, queueId) { console.error(`Error fetching status for ${queueId}:`, error); // Show error in log - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; if (logElement) { - logElement.textContent = `Error updating status: ${error.message}`; + logElement.textContent = `Error updating status: ${(error as Error).message}`; // Cast to Error } } } - clearPollingInterval(queueId) { + clearPollingInterval(queueId: string) { // Add type if (this.pollingIntervals[queueId]) { console.log(`Stopping polling for ${queueId}`); try { - clearInterval(this.pollingIntervals[queueId]); + clearInterval(this.pollingIntervals[queueId] as number); // Cast to number } catch (error) { console.error(`Error stopping polling for ${queueId}:`, error); } @@ -1865,7 +1949,7 @@ createQueueItem(item, type, prgFile, queueId) { } /* Handle status updates from the progress API */ - handleStatusUpdate(queueId, data) { + handleStatusUpdate(queueId: string, data: StatusData) { // Add types const entry = this.queueEntries[queueId]; if (!entry) { console.warn(`No entry for ${queueId}`); @@ -1873,7 +1957,7 @@ createQueueItem(item, type, prgFile, queueId) { } // Extract the actual status data from the API response - const statusData = data.last_line || {}; + const statusData: StatusData = data.last_line || {}; // Add type // Special handling for track status updates that are part of an album/playlist // We want to keep these for showing the track-by-track progress @@ -1892,16 +1976,60 @@ createQueueItem(item, type, prgFile, queueId) { } // Get primary status - const status = statusData.status || data.event || 'unknown'; + 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.prgFile}) 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; + 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'); + 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; @@ -1917,7 +2045,7 @@ createQueueItem(item, type, prgFile, queueId) { // 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.prgFile}`); + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; if (logElement && status !== 'error' && !(statusData.type === 'track' && statusData.parent && (entry.type === 'album' || entry.type === 'playlist'))) { logElement.textContent = message; @@ -1938,55 +2066,88 @@ createQueueItem(item, type, prgFile, queueId) { } // Apply appropriate status classes - this.applyStatusClasses(entry, status); + this.applyStatusClasses(entry, statusData); // Pass statusData instead of status string // 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'); + 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.prgFile}`) 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.prgFile}`) as HTMLElement | null; + if (timeElapsedContainer) timeElapsedContainer.style.display = 'none'; + // Extract error details - const errMsg = statusData.error; - const canRetry = Boolean(statusData.can_retry) && statusData.retry_count < statusData.max_retries; - // Determine retry URL + 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; + entry.requestUrl = retryUrl; // Store for retry logic } - console.log(`Error for ${entry.type} download. Can retry: ${canRetry}. Retry URL: ${retryUrl}`); + console.log(`Error for ${entry.type} download. Can retry: ${!!entry.requestUrl}. Retry URL: ${entry.requestUrl}`); - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); - if (logElement) { + const errorLogElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) 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 - logElement.innerHTML = ` + errorLogElement.innerHTML = `
${errMsg}
- +
`; - // Close handler - logElement.querySelector('.close-error-btn').addEventListener('click', () => { + 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); }); - // Always attach manual retry handler - const retryBtn = logElement.querySelector('.retry-btn'); - retryBtn.addEventListener('click', (e) => { + } + + const retryBtnElem = errorLogElement.querySelector('.retry-btn') as HTMLButtonElement | null; + if (retryBtnElem) { + retryBtnElem.addEventListener('click', (e: MouseEvent) => { // Add type for e e.preventDefault(); e.stopPropagation(); - retryBtn.disabled = true; - retryBtn.innerHTML = ' Retrying...'; - this.retryDownload(queueId, logElement); + if (retryBtnElem) { // Check if retryBtnElem is not null + retryBtnElem.disabled = true; + retryBtnElem.innerHTML = ' Retrying...'; + } + this.retryDownload(queueId, errorLogElement); // Pass errorLogElement }); - // Auto cleanup after 15s + } + + // Auto cleanup after 15s - only set this timeout once when error UI is first built setTimeout(() => { - if (this.queueEntries[queueId]?.hasEnded) { + const currentEntryForCleanup = this.queueEntries[queueId]; + if (currentEntryForCleanup && + currentEntryForCleanup.hasEnded && + currentEntryForCleanup.lastStatus?.status === 'error' && + !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) { + errorMessageElement.textContent = errMsg; + } + } } } @@ -2002,13 +2163,13 @@ createQueueItem(item, type, prgFile, queueId) { } // Update item metadata (title, artist, etc.) - updateItemMetadata(entry, statusData, data) { - const titleEl = entry.element.querySelector('.title'); - const artistEl = entry.element.querySelector('.artist'); + 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 = null; + let betterTitle: string | null | undefined = null; // First check the statusData if (statusData.song) { @@ -2049,16 +2210,16 @@ createQueueItem(item, type, prgFile, queueId) { } // Update real-time progress for track downloads - updateRealTimeProgress(entry, statusData) { + updateRealTimeProgress(entry: QueueEntry, statusData: StatusData) { // Add types // Get track progress bar - const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile); - const timeElapsedEl = entry.element.querySelector('#time-elapsed-' + entry.uniqueId + '-' + entry.prgFile); + const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; + const timeElapsedEl = entry.element.querySelector('#time-elapsed-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; if (trackProgressBar && statusData.progress !== undefined) { // Update track progress bar - const progress = parseFloat(statusData.progress); + const progress = parseFloat(statusData.progress as string); // Cast to string trackProgressBar.style.width = `${progress}%`; - trackProgressBar.setAttribute('aria-valuenow', progress); + trackProgressBar.setAttribute('aria-valuenow', progress.toString()); // Use string // Add success class when complete if (progress >= 100) { @@ -2079,12 +2240,13 @@ createQueueItem(item, type, prgFile, queueId) { } // Update progress for single track downloads - updateSingleTrackProgress(entry, statusData) { + 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.prgFile); - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); - const titleElement = entry.element.querySelector('.title'); - const artistElement = entry.element.querySelector('.artist'); + const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) 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 @@ -2096,7 +2258,7 @@ createQueueItem(item, type, prgFile, queueId) { entry.type = statusData.parent.type; // Update UI to reflect the parent type - const typeEl = entry.element.querySelector('.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; @@ -2128,7 +2290,7 @@ createQueueItem(item, type, prgFile, queueId) { } // For individual track downloads, show the parent context if available - if (!['done', 'complete', 'error', 'skipped'].includes(statusData.status)) { + 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 @@ -2161,20 +2323,20 @@ createQueueItem(item, type, prgFile, queueId) { } // Calculate progress based on available data - let progress = 0; + progress = 0; // Real-time progress for direct track download if (statusData.status === 'real-time' && statusData.progress !== undefined) { - progress = parseFloat(statusData.progress); + progress = parseFloat(statusData.progress as string); // Cast to string } else if (statusData.percent !== undefined) { - progress = parseFloat(statusData.percent) * 100; + progress = parseFloat(statusData.percent as string) * 100; // Cast to string } else if (statusData.percentage !== undefined) { - progress = parseFloat(statusData.percentage) * 100; + progress = parseFloat(statusData.percentage as string) * 100; // Cast to string } 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, 10) / parseInt(statusData.total_tracks, 10)) * 100; + 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 @@ -2183,10 +2345,10 @@ createQueueItem(item, type, prgFile, queueId) { const safeProgress = isNaN(progress) ? 0 : Math.max(0, Math.min(100, progress)); trackProgressBar.style.width = `${safeProgress}%`; - trackProgressBar.setAttribute('aria-valuenow', 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.prgFile); + const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; if (trackProgressContainer) { trackProgressContainer.style.display = 'block'; } @@ -2201,14 +2363,15 @@ createQueueItem(item, type, prgFile, queueId) { } // Update progress for multi-track downloads (albums and playlists) - updateMultiTrackProgress(entry, statusData) { + updateMultiTrackProgress(entry: QueueEntry, statusData: StatusData) { // Add types // Get progress elements - const progressCounter = document.getElementById(`progress-count-${entry.uniqueId}-${entry.prgFile}`); - const overallProgressBar = document.getElementById(`overall-bar-${entry.uniqueId}-${entry.prgFile}`); - const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile); - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); - const titleElement = entry.element.querySelector('.title'); - const artistElement = entry.element.querySelector('.artist'); + const progressCounter = document.getElementById(`progress-count-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; + const overallProgressBar = document.getElementById(`overall-bar-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; + const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) 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; @@ -2241,13 +2404,13 @@ createQueueItem(item, type, prgFile, queueId) { // Get current track and total tracks from the status data if (statusData.current_track !== undefined) { - currentTrack = parseInt(statusData.current_track, 10); + currentTrack = parseInt(String(statusData.current_track), 10); // Get total tracks - try from statusData first, then from parent if (statusData.total_tracks !== undefined) { - totalTracks = parseInt(statusData.total_tracks, 10); + totalTracks = parseInt(String(statusData.total_tracks), 10); } else if (statusData.parent && statusData.parent.total_tracks !== undefined) { - totalTracks = parseInt(statusData.parent.total_tracks, 10); + totalTracks = parseInt(String(statusData.parent.total_tracks), 10); } console.log(`Track info: ${currentTrack}/${totalTracks}`); @@ -2255,7 +2418,7 @@ createQueueItem(item, type, prgFile, queueId) { // Get track progress for real-time updates if (statusData.status === 'real-time' && statusData.progress !== undefined) { - trackProgress = parseFloat(statusData.progress); + trackProgress = parseFloat(statusData.progress as string); // Cast to string } // Update the track progress counter display @@ -2290,7 +2453,7 @@ createQueueItem(item, type, prgFile, queueId) { if (overallProgressBar) { const safeProgress = Math.max(0, Math.min(100, overallProgress)); overallProgressBar.style.width = `${safeProgress}%`; - overallProgressBar.setAttribute('aria-valuenow', safeProgress); + overallProgressBar.setAttribute('aria-valuenow', safeProgress.toString()); // Use string if (safeProgress >= 100) { overallProgressBar.classList.add('complete'); @@ -2302,7 +2465,7 @@ createQueueItem(item, type, prgFile, queueId) { // 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.prgFile); + const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; if (trackProgressContainer) { trackProgressContainer.style.display = 'block'; } @@ -2311,7 +2474,7 @@ createQueueItem(item, type, prgFile, queueId) { // Real-time progress for the current track const safeTrackProgress = Math.max(0, Math.min(100, trackProgress)); trackProgressBar.style.width = `${safeTrackProgress}%`; - trackProgressBar.setAttribute('aria-valuenow', safeTrackProgress); + trackProgressBar.setAttribute('aria-valuenow', safeTrackProgress.toString()); // Use string trackProgressBar.classList.add('real-time'); if (safeTrackProgress >= 100) { @@ -2323,7 +2486,7 @@ createQueueItem(item, type, prgFile, queueId) { // Indeterminate progress animation for non-real-time updates trackProgressBar.classList.add('progress-pulse'); trackProgressBar.style.width = '100%'; - trackProgressBar.setAttribute('aria-valuenow', 50); + trackProgressBar.setAttribute('aria-valuenow', "50"); // Use string } } @@ -2354,12 +2517,12 @@ createQueueItem(item, type, prgFile, queueId) { // Extract track counting data from status data if (statusData.current_track && statusData.total_tracks) { - currentTrack = parseInt(statusData.current_track, 10); - totalTracks = parseInt(statusData.total_tracks, 10); + 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, 10); - totalTracks = parseInt(statusData.parsed_total_tracks, 10); - } else if (statusData.current_track && /^\d+\/\d+$/.test(statusData.current_track)) { + 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); @@ -2369,13 +2532,18 @@ createQueueItem(item, type, prgFile, queueId) { // Get track progress for real-time downloads if (statusData.status === 'real-time' && statusData.progress !== undefined) { // For real-time downloads, progress comes as a percentage value (0-100) - trackProgress = parseFloat(statusData.progress); + trackProgress = parseFloat(statusData.progress as string); // Cast to string } else if (statusData.percent !== undefined) { // Handle percent values (0-1) - trackProgress = parseFloat(statusData.percent) * 100; + trackProgress = parseFloat(statusData.percent as string) * 100; // Cast to string } else if (statusData.percentage !== undefined) { // Handle percentage values (0-1) - trackProgress = parseFloat(statusData.percentage) * 100; + trackProgress = parseFloat(statusData.percentage as string) * 100; // Cast to string + } 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 progress counter if available @@ -2388,7 +2556,7 @@ createQueueItem(item, type, prgFile, queueId) { if (totalTracks > 0) { // Use explicit overall_progress if provided if (statusData.overall_progress !== undefined) { - overallProgress = parseFloat(statusData.overall_progress); + 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; @@ -2404,7 +2572,7 @@ createQueueItem(item, type, prgFile, queueId) { // Ensure progress is between 0-100 const safeProgress = Math.max(0, Math.min(100, overallProgress)); overallProgressBar.style.width = `${safeProgress}%`; - overallProgressBar.setAttribute('aria-valuenow', safeProgress); + overallProgressBar.setAttribute('aria-valuenow', String(safeProgress)); // Add success class when complete if (safeProgress >= 100) { @@ -2417,7 +2585,7 @@ createQueueItem(item, type, prgFile, queueId) { // 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); + const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; if (trackProgressContainer) { trackProgressContainer.style.display = 'block'; } @@ -2427,7 +2595,7 @@ createQueueItem(item, type, prgFile, queueId) { // 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', safeProgress); + trackProgressBar.setAttribute('aria-valuenow', String(safeProgress)); trackProgressBar.classList.add('real-time'); if (safeProgress >= 100) { @@ -2435,18 +2603,18 @@ createQueueItem(item, type, prgFile, queueId) { } else { trackProgressBar.classList.remove('complete'); } - } else if (['progress', 'processing'].includes(statusData.status)) { + } else if (['progress', 'processing'].includes(statusData.status || '')) { // For non-real-time progress updates, show an indeterminate-style progress // by using a pulsing animation via CSS trackProgressBar.classList.add('progress-pulse'); trackProgressBar.style.width = '100%'; - trackProgressBar.setAttribute('aria-valuenow', 50); // indicate in-progress + 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', trackPositionPercent); + trackProgressBar.setAttribute('aria-valuenow', String(trackPositionPercent)); } } @@ -2461,7 +2629,127 @@ createQueueItem(item, type, prgFile, queueId) { 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.prgFile)); + 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.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); + } + } + } + } + + // 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 periodic sync with server:", error); + } + } } // Singleton instance -export const downloadQueue = new DownloadQueue(); +export const downloadQueue = new DownloadQueue(); \ No newline at end of file diff --git a/static/js/track.js b/src/js/track.ts similarity index 65% rename from static/js/track.js rename to src/js/track.ts index f62696e..2a5c9fa 100644 --- a/static/js/track.js +++ b/src/js/track.ts @@ -35,15 +35,18 @@ document.addEventListener('DOMContentLoaded', () => { /** * Renders the track header information. */ -function renderTrack(track) { +function renderTrack(track: any) { // Hide the loading and error messages. - document.getElementById('loading').classList.add('hidden'); - document.getElementById('error').classList.add('hidden'); + const loadingEl = document.getElementById('loading'); + if (loadingEl) loadingEl.classList.add('hidden'); + const errorEl = document.getElementById('error'); + if (errorEl) errorEl.classList.add('hidden'); // Check if track is explicit and if explicit filter is enabled if (track.explicit && downloadQueue.isExplicitFilterEnabled()) { // Show placeholder for explicit content - document.getElementById('loading').classList.add('hidden'); + const loadingElExplicit = document.getElementById('loading'); + if (loadingElExplicit) loadingElExplicit.classList.add('hidden'); const placeholderContent = `
@@ -63,30 +66,46 @@ function renderTrack(track) { } // Update track information fields. - document.getElementById('track-name').innerHTML = - `${track.name || 'Unknown Track'}`; + const trackNameEl = document.getElementById('track-name'); + if (trackNameEl) { + trackNameEl.innerHTML = + `${track.name || 'Unknown Track'}`; + } - document.getElementById('track-artist').innerHTML = - `By ${track.artists?.map(a => - `${a?.name || 'Unknown Artist'}` - ).join(', ') || 'Unknown Artist'}`; + const trackArtistEl = document.getElementById('track-artist'); + if (trackArtistEl) { + trackArtistEl.innerHTML = + `By ${track.artists?.map((a: any) => + `${a?.name || 'Unknown Artist'}` + ).join(', ') || 'Unknown Artist'}`; + } - document.getElementById('track-album').innerHTML = - `Album: ${track.album?.name || 'Unknown Album'} (${track.album?.album_type || 'album'})`; + const trackAlbumEl = document.getElementById('track-album'); + if (trackAlbumEl) { + trackAlbumEl.innerHTML = + `Album: ${track.album?.name || 'Unknown Album'} (${track.album?.album_type || 'album'})`; + } - document.getElementById('track-duration').textContent = - `Duration: ${msToTime(track.duration_ms || 0)}`; + const trackDurationEl = document.getElementById('track-duration'); + if (trackDurationEl) { + trackDurationEl.textContent = + `Duration: ${msToTime(track.duration_ms || 0)}`; + } - document.getElementById('track-explicit').textContent = - track.explicit ? 'Explicit' : 'Clean'; + const trackExplicitEl = document.getElementById('track-explicit'); + if (trackExplicitEl) { + trackExplicitEl.textContent = + track.explicit ? 'Explicit' : 'Clean'; + } const imageUrl = (track.album?.images && track.album.images[0]) ? track.album.images[0].url : '/static/images/placeholder.jpg'; - document.getElementById('track-album-image').src = imageUrl; + const trackAlbumImageEl = document.getElementById('track-album-image') as HTMLImageElement; + if (trackAlbumImageEl) trackAlbumImageEl.src = imageUrl; // --- Insert Home Button (if not already present) --- - let homeButton = document.getElementById('homeButton'); + let homeButton = document.getElementById('homeButton') as HTMLButtonElement; if (!homeButton) { homeButton = document.createElement('button'); homeButton.id = 'homeButton'; @@ -103,7 +122,7 @@ function renderTrack(track) { }); // --- Move the Download Button from #actions into #track-header --- - let downloadBtn = document.getElementById('downloadTrackBtn'); + let downloadBtn = document.getElementById('downloadTrackBtn') as HTMLButtonElement; if (downloadBtn) { // Remove the parent container (#actions) if needed. const actionsContainer = document.getElementById('actions'); @@ -131,15 +150,22 @@ function renderTrack(track) { downloadBtn.innerHTML = `Download`; return; } + const trackIdToDownload = track.id || ''; + if (!trackIdToDownload) { + showError('Missing track ID for download'); + downloadBtn.disabled = false; + downloadBtn.innerHTML = `Download`; + return; + } // Use the centralized downloadQueue.download method - downloadQueue.download(trackUrl, 'track', { name: track.name || 'Unknown Track', artist: track.artists?.[0]?.name }) + downloadQueue.download(trackIdToDownload, 'track', { name: track.name || 'Unknown Track', artist: track.artists?.[0]?.name }) .then(() => { downloadBtn.innerHTML = `Queued!`; // Make the queue visible to show the download downloadQueue.toggleVisibility(true); }) - .catch(err => { + .catch((err: any) => { showError('Failed to queue track download: ' + (err?.message || 'Unknown error')); downloadBtn.disabled = false; downloadBtn.innerHTML = `Download`; @@ -148,13 +174,14 @@ function renderTrack(track) { } // Reveal the header now that track info is loaded. - document.getElementById('track-header').classList.remove('hidden'); + const trackHeaderEl = document.getElementById('track-header'); + if (trackHeaderEl) trackHeaderEl.classList.remove('hidden'); } /** * Converts milliseconds to minutes:seconds. */ -function msToTime(duration) { +function msToTime(duration: number) { if (!duration || isNaN(duration)) return '0:00'; const minutes = Math.floor(duration / 60000); @@ -165,7 +192,7 @@ function msToTime(duration) { /** * Displays an error message in the UI. */ -function showError(message) { +function showError(message: string) { const errorEl = document.getElementById('error'); if (errorEl) { errorEl.textContent = message || 'An error occurred'; @@ -176,19 +203,19 @@ function showError(message) { /** * Starts the download process by calling the centralized downloadQueue method */ -async function startDownload(url, type, item) { - if (!url || !type) { - showError('Missing URL or type for download'); +async function startDownload(itemId: string, type: string, item: any) { + if (!itemId || !type) { + showError('Missing ID or type for download'); return; } try { // Use the centralized downloadQueue.download method - await downloadQueue.download(url, type, item); + await downloadQueue.download(itemId, type, item); // Make the queue visible after queueing downloadQueue.toggleVisibility(true); - } catch (error) { + } catch (error: any) { showError('Download failed: ' + (error?.message || 'Unknown error')); throw error; } diff --git a/src/js/watch.ts b/src/js/watch.ts new file mode 100644 index 0000000..b5beb11 --- /dev/null +++ b/src/js/watch.ts @@ -0,0 +1,644 @@ +import { downloadQueue } from './queue.js'; // Assuming queue.js is in the same directory + +// Interfaces for API data +interface Image { + url: string; + height?: number; + width?: number; +} + +// --- Items from the initial /watch/list API calls --- +interface ArtistFromWatchList { + spotify_id: string; // Changed from id to spotify_id + name: string; + images?: Image[]; + total_albums?: number; // Already provided by /api/artist/watch/list +} + +// New interface for artists after initial processing (spotify_id mapped to id) +interface ProcessedArtistFromWatchList extends ArtistFromWatchList { + id: string; // This is the mapped spotify_id +} + +interface WatchedPlaylistOwner { // Kept as is, used by PlaylistFromWatchList + display_name?: string; + id?: string; +} + +interface PlaylistFromWatchList { + spotify_id: string; // Changed from id to spotify_id + name: string; + owner?: WatchedPlaylistOwner; + images?: Image[]; // Ensure images can be part of this initial fetch + total_tracks?: number; +} + +// New interface for playlists after initial processing (spotify_id mapped to id) +interface ProcessedPlaylistFromWatchList extends PlaylistFromWatchList { + id: string; // This is the mapped spotify_id +} +// --- End of /watch/list items --- + + +// --- Responses from /api/{artist|playlist}/info endpoints --- +interface AlbumWithImages { // For items in ArtistInfoResponse.items + images?: Image[]; + // Other album properties like name, id etc., are not strictly needed for this specific change +} + +interface ArtistInfoResponse { + artist_id: string; // Matches key from artist.py + artist_name: string; // Matches key from artist.py + artist_image_url?: string; // Matches key from artist.py + total: number; // This is total_albums, matches key from artist.py + artist_external_url?: string; // Matches key from artist.py + items?: AlbumWithImages[]; // Add album items to get the first album's image +} + +// PlaylistInfoResponse is effectively the Playlist interface from playlist.ts +// For clarity, defining it here based on what's needed for the card. +interface PlaylistInfoResponse { + id: string; + name: string; + description: string | null; + owner: { display_name?: string; id?: string; }; // Matches Playlist.owner + images: Image[]; // Matches Playlist.images + tracks: { total: number; /* items: PlaylistItem[] - not needed for card */ }; // Matches Playlist.tracks + followers?: { total: number; }; // Matches Playlist.followers + external_urls?: { spotify?: string }; // Matches Playlist.external_urls +} +// --- End of /info endpoint responses --- + + +// --- Final combined data structure for rendering cards --- +interface FinalArtistCardItem { + itemType: 'artist'; + id: string; // Spotify ID + name: string; // Best available name (from /info or fallback) + imageUrl?: string; // Best available image URL (from /info or fallback) + total_albums: number;// From /info or fallback + external_urls?: { spotify?: string }; // From /info +} + +interface FinalPlaylistCardItem { + itemType: 'playlist'; + id: string; // Spotify ID + name: string; // Best available name (from /info or fallback) + imageUrl?: string; // Best available image URL (from /info or fallback) + owner_name?: string; // From /info or fallback + total_tracks: number;// From /info or fallback + followers_count?: number; // From /info + description?: string | null; // From /info, for potential use (e.g., tooltip) + external_urls?: { spotify?: string }; // From /info +} + +type FinalCardItem = FinalArtistCardItem | FinalPlaylistCardItem; +// --- End of final card data structure --- + +// The type for items initially fetched from /watch/list, before detailed processing +// Updated to use ProcessedArtistFromWatchList for artists and ProcessedPlaylistFromWatchList for playlists +type InitialWatchedItem = + (ProcessedArtistFromWatchList & { itemType: 'artist' }) | + (ProcessedPlaylistFromWatchList & { itemType: 'playlist' }); + +// Interface for a settled promise (fulfilled) +interface CustomPromiseFulfilledResult { + status: 'fulfilled'; + value: T; +} + +// Interface for a settled promise (rejected) +interface CustomPromiseRejectedResult { + status: 'rejected'; + reason: any; +} + +type CustomSettledPromiseResult = CustomPromiseFulfilledResult | CustomPromiseRejectedResult; + +// Original WatchedItem type, which will be replaced by FinalCardItem for rendering +interface WatchedArtistOriginal { + id: string; + name: string; + images?: Image[]; + total_albums?: number; +} + +interface WatchedPlaylistOriginal { + id: string; + name: string; + owner?: WatchedPlaylistOwner; + images?: Image[]; + total_tracks?: number; +} + +type WatchedItem = (WatchedArtistOriginal & { itemType: 'artist' }) | (WatchedPlaylistOriginal & { itemType: 'playlist' }); + +document.addEventListener('DOMContentLoaded', function() { + const watchedItemsContainer = document.getElementById('watchedItemsContainer'); + const loadingIndicator = document.getElementById('loadingWatchedItems'); + const emptyStateIndicator = document.getElementById('emptyWatchedItems'); + const queueIcon = document.getElementById('queueIcon'); + const checkAllWatchedBtn = document.getElementById('checkAllWatchedBtn') as HTMLButtonElement | null; + + if (queueIcon) { + queueIcon.addEventListener('click', () => { + downloadQueue.toggleVisibility(); + }); + } + + if (checkAllWatchedBtn) { + checkAllWatchedBtn.addEventListener('click', async () => { + checkAllWatchedBtn.disabled = true; + const originalText = checkAllWatchedBtn.innerHTML; + checkAllWatchedBtn.innerHTML = 'Refreshing... Checking...'; + + try { + const artistCheckPromise = fetch('/api/artist/watch/trigger_check', { method: 'POST' }); + const playlistCheckPromise = fetch('/api/playlist/watch/trigger_check', { method: 'POST' }); + + // Use Promise.allSettled-like behavior to handle both responses + const results = await Promise.all([ + artistCheckPromise.then(async res => ({ + ok: res.ok, + data: await res.json().catch(() => ({ error: 'Invalid JSON response' })), + type: 'artist' + })).catch(e => ({ ok: false, data: { error: e.message || 'Request failed' }, type: 'artist' })), + playlistCheckPromise.then(async res => ({ + ok: res.ok, + data: await res.json().catch(() => ({ error: 'Invalid JSON response' })), + type: 'playlist' + })).catch(e => ({ ok: false, data: { error: e.message || 'Request failed' }, type: 'playlist' })) + ]); + + const artistResult = results.find(r => r.type === 'artist'); + const playlistResult = results.find(r => r.type === 'playlist'); + + let successMessages: string[] = []; + let errorMessages: string[] = []; + + if (artistResult) { + if (artistResult.ok) { + successMessages.push(artistResult.data.message || 'Artist check triggered.'); + } else { + errorMessages.push(`Artist check failed: ${artistResult.data.error || 'Unknown error'}`); + } + } + + if (playlistResult) { + if (playlistResult.ok) { + successMessages.push(playlistResult.data.message || 'Playlist check triggered.'); + } else { + errorMessages.push(`Playlist check failed: ${playlistResult.data.error || 'Unknown error'}`); + } + } + + if (errorMessages.length > 0) { + showNotification(errorMessages.join(' '), true); + if (successMessages.length > 0) { // If some succeeded and some failed + // Delay the success message slightly so it doesn't overlap or get missed + setTimeout(() => showNotification(successMessages.join(' ')), 1000); + } + } else if (successMessages.length > 0) { + showNotification(successMessages.join(' ')); + } else { + showNotification('Could not determine check status for artists or playlists.', true); + } + + } catch (error: any) { // Catch for unexpected issues with Promise.all or setup + console.error('Error in checkAllWatchedBtn handler:', error); + showNotification(`An unexpected error occurred: ${error.message}`, true); + } finally { + checkAllWatchedBtn.disabled = false; + checkAllWatchedBtn.innerHTML = originalText; + } + }); + } + + // Initial load + loadWatchedItems(); +}); + +const MAX_NOTIFICATIONS = 3; + +async function loadWatchedItems() { + const watchedItemsContainer = document.getElementById('watchedItemsContainer'); + const loadingIndicator = document.getElementById('loadingWatchedItems'); + const emptyStateIndicator = document.getElementById('emptyWatchedItems'); + + showLoading(true); + showEmptyState(false); + if (watchedItemsContainer) watchedItemsContainer.innerHTML = ''; + + try { + const [artistsResponse, playlistsResponse] = await Promise.all([ + fetch('/api/artist/watch/list'), + fetch('/api/playlist/watch/list') + ]); + + if (!artistsResponse.ok || !playlistsResponse.ok) { + throw new Error('Failed to load initial watched items list'); + } + + const artists: ArtistFromWatchList[] = await artistsResponse.json(); + const playlists: PlaylistFromWatchList[] = await playlistsResponse.json(); + + const initialItems: InitialWatchedItem[] = [ + ...artists.map(artist => ({ + ...artist, + id: artist.spotify_id, // Map spotify_id to id for artists + itemType: 'artist' as const + })), + ...playlists.map(playlist => ({ + ...playlist, + id: playlist.spotify_id, // Map spotify_id to id for playlists + itemType: 'playlist' as const + })) + ]; + + if (initialItems.length === 0) { + showLoading(false); + showEmptyState(true); + return; + } + + // Fetch detailed info for each item + const detailedItemPromises = initialItems.map(async (initialItem) => { + try { + if (initialItem.itemType === 'artist') { + const infoResponse = await fetch(`/api/artist/info?id=${initialItem.id}`); + if (!infoResponse.ok) { + console.warn(`Failed to fetch artist info for ${initialItem.name} (ID: ${initialItem.id}): ${infoResponse.status}`); + // Fallback to initial data if info fetch fails + return { + itemType: 'artist', + id: initialItem.id, + name: initialItem.name, + imageUrl: (initialItem as ArtistFromWatchList).images?.[0]?.url, // Cast to access images + total_albums: (initialItem as ArtistFromWatchList).total_albums || 0, // Cast to access total_albums + } as FinalArtistCardItem; + } + const info: ArtistInfoResponse = await infoResponse.json(); + return { + itemType: 'artist', + id: initialItem.id, // Use the ID from the watch list, as /info might have 'artist_id' + name: info.artist_name || initialItem.name, // Prefer info, fallback to initial + imageUrl: info.items?.[0]?.images?.[0]?.url || info.artist_image_url || (initialItem as ProcessedArtistFromWatchList).images?.[0]?.url, // Prioritize first album image from items + total_albums: info.total, // 'total' from ArtistInfoResponse is total_albums + external_urls: { spotify: info.artist_external_url } + } as FinalArtistCardItem; + } else { // Playlist + const infoResponse = await fetch(`/api/playlist/info?id=${initialItem.id}`); + if (!infoResponse.ok) { + console.warn(`Failed to fetch playlist info for ${initialItem.name} (ID: ${initialItem.id}): ${infoResponse.status}`); + // Fallback to initial data if info fetch fails + return { + itemType: 'playlist', + id: initialItem.id, + name: initialItem.name, + imageUrl: (initialItem as ProcessedPlaylistFromWatchList).images?.[0]?.url, // Cast to access images + owner_name: (initialItem as ProcessedPlaylistFromWatchList).owner?.display_name, // Cast to access owner + total_tracks: (initialItem as ProcessedPlaylistFromWatchList).total_tracks || 0, // Cast to access total_tracks + } as FinalPlaylistCardItem; + } + const info: PlaylistInfoResponse = await infoResponse.json(); + return { + itemType: 'playlist', + id: initialItem.id, // Use ID from watch list + name: info.name || initialItem.name, // Prefer info, fallback to initial + imageUrl: info.images?.[0]?.url || (initialItem as ProcessedPlaylistFromWatchList).images?.[0]?.url, // Prefer info, fallback to initial (ProcessedPlaylistFromWatchList) + owner_name: info.owner?.display_name || (initialItem as ProcessedPlaylistFromWatchList).owner?.display_name, // Prefer info, fallback to initial (ProcessedPlaylistFromWatchList) + total_tracks: info.tracks.total, // 'total' from PlaylistInfoResponse.tracks + followers_count: info.followers?.total, + description: info.description, + external_urls: info.external_urls + } as FinalPlaylistCardItem; + } + } catch (e: any) { + console.error(`Error processing item ${initialItem.name} (ID: ${initialItem.id}):`, e); + // Return a fallback structure if processing fails catastrophically + return { + itemType: initialItem.itemType, + id: initialItem.id, + name: initialItem.name + " (Error loading details)", + imageUrl: initialItem.images?.[0]?.url, + // Add minimal common fields for artists and playlists for fallback + ...(initialItem.itemType === 'artist' ? { total_albums: (initialItem as ProcessedArtistFromWatchList).total_albums || 0 } : {}), + ...(initialItem.itemType === 'playlist' ? { total_tracks: (initialItem as ProcessedPlaylistFromWatchList).total_tracks || 0 } : {}), + } as FinalCardItem; // Cast to avoid TS errors, knowing one of the spreads will match + } + }); + + // Simulating Promise.allSettled behavior for compatibility + const settledResults: CustomSettledPromiseResult[] = await Promise.all( + detailedItemPromises.map(p => + p.then(value => ({ status: 'fulfilled', value } as CustomPromiseFulfilledResult)) + .catch(reason => ({ status: 'rejected', reason } as CustomPromiseRejectedResult)) + ) + ); + + const finalItems: FinalCardItem[] = settledResults + .filter((result): result is CustomPromiseFulfilledResult => result.status === 'fulfilled') + .map(result => result.value) + .filter(item => item !== null) as FinalCardItem[]; // Ensure no nulls from catastrophic failures + + showLoading(false); + + if (finalItems.length === 0) { + showEmptyState(true); + // Potentially show a different message if initialItems existed but all failed to load details + if (initialItems.length > 0 && watchedItemsContainer) { + watchedItemsContainer.innerHTML = `

Could not load details for any watched items. Please check the console for errors.

`; + } + return; + } + + if (watchedItemsContainer) { + // Clear previous content + watchedItemsContainer.innerHTML = ''; + + if (finalItems.length > 8) { + const playlistItems = finalItems.filter(item => item.itemType === 'playlist') as FinalPlaylistCardItem[]; + const artistItems = finalItems.filter(item => item.itemType === 'artist') as FinalArtistCardItem[]; + + // Create and append Playlist section + if (playlistItems.length > 0) { + const playlistSection = document.createElement('div'); + playlistSection.className = 'watched-items-group'; + const playlistHeader = document.createElement('h2'); + playlistHeader.className = 'watched-group-header'; + playlistHeader.textContent = 'Watched Playlists'; + playlistSection.appendChild(playlistHeader); + const playlistGrid = document.createElement('div'); + playlistGrid.className = 'results-grid'; // Use existing grid style + playlistItems.forEach(item => { + const cardElement = createWatchedItemCard(item); + playlistGrid.appendChild(cardElement); + }); + playlistSection.appendChild(playlistGrid); + watchedItemsContainer.appendChild(playlistSection); + } else { + const noPlaylistsMessage = document.createElement('p'); + noPlaylistsMessage.textContent = 'No watched playlists.'; + noPlaylistsMessage.className = 'empty-group-message'; + // Optionally add a header for consistency even if empty + const playlistHeader = document.createElement('h2'); + playlistHeader.className = 'watched-group-header'; + playlistHeader.textContent = 'Watched Playlists'; + watchedItemsContainer.appendChild(playlistHeader); + watchedItemsContainer.appendChild(noPlaylistsMessage); + } + + // Create and append Artist section + if (artistItems.length > 0) { + const artistSection = document.createElement('div'); + artistSection.className = 'watched-items-group'; + const artistHeader = document.createElement('h2'); + artistHeader.className = 'watched-group-header'; + artistHeader.textContent = 'Watched Artists'; + artistSection.appendChild(artistHeader); + const artistGrid = document.createElement('div'); + artistGrid.className = 'results-grid'; // Use existing grid style + artistItems.forEach(item => { + const cardElement = createWatchedItemCard(item); + artistGrid.appendChild(cardElement); + }); + artistSection.appendChild(artistGrid); + watchedItemsContainer.appendChild(artistSection); + } else { + const noArtistsMessage = document.createElement('p'); + noArtistsMessage.textContent = 'No watched artists.'; + noArtistsMessage.className = 'empty-group-message'; + // Optionally add a header for consistency even if empty + const artistHeader = document.createElement('h2'); + artistHeader.className = 'watched-group-header'; + artistHeader.textContent = 'Watched Artists'; + watchedItemsContainer.appendChild(artistHeader); + watchedItemsContainer.appendChild(noArtistsMessage); + } + + } else { // 8 or fewer items, render them directly + finalItems.forEach(item => { + const cardElement = createWatchedItemCard(item); + watchedItemsContainer.appendChild(cardElement); + }); + } + } + + } catch (error: any) { + console.error('Error loading watched items:', error); + showLoading(false); + if (watchedItemsContainer) { + watchedItemsContainer.innerHTML = `

Error loading watched items: ${error.message}

`; + } + } +} + +function createWatchedItemCard(item: FinalCardItem): HTMLDivElement { + const cardElement = document.createElement('div'); + cardElement.className = 'watched-item-card'; + cardElement.dataset.itemId = item.id; + cardElement.dataset.itemType = item.itemType; + + // Check Now button HTML is no longer generated separately here for absolute positioning + + let imageUrl = '/static/images/placeholder.jpg'; + if (item.imageUrl) { + imageUrl = item.imageUrl; + } + + let detailsHtml = ''; + let typeBadgeClass = ''; + let typeName = ''; + + if (item.itemType === 'artist') { + typeName = 'Artist'; + typeBadgeClass = 'artist'; + const artist = item as FinalArtistCardItem; + detailsHtml = artist.total_albums !== undefined ? `${artist.total_albums} albums` : ''; + } else if (item.itemType === 'playlist') { + typeName = 'Playlist'; + typeBadgeClass = 'playlist'; + const playlist = item as FinalPlaylistCardItem; + detailsHtml = playlist.owner_name ? `By: ${playlist.owner_name}` : ''; + detailsHtml += playlist.total_tracks !== undefined ? ` • ${playlist.total_tracks} tracks` : ''; + if (playlist.followers_count !== undefined) { + detailsHtml += ` • ${playlist.followers_count} followers`; + } + } + + cardElement.innerHTML = ` +
+ ${item.name} +
+
${item.name}
+
${detailsHtml}
+ ${typeName} +
+ + +
+ `; + + // Add click event to navigate to the item's detail page + cardElement.addEventListener('click', (e: MouseEvent) => { + const target = e.target as HTMLElement; + // Don't navigate if any button within the card was clicked + if (target.closest('button')) { + return; + } + window.location.href = `/${item.itemType}/${item.id}`; + }); + + // Add event listener for the "Check Now" button + const checkNowBtn = cardElement.querySelector('.check-item-now-btn') as HTMLButtonElement | null; + if (checkNowBtn) { + checkNowBtn.addEventListener('click', (e: MouseEvent) => { + e.stopPropagation(); + const itemId = checkNowBtn.dataset.id; + const itemType = checkNowBtn.dataset.type as 'artist' | 'playlist'; + if (itemId && itemType) { + triggerItemCheck(itemId, itemType, checkNowBtn); + } + }); + } + + // Add event listener for the "Unwatch" button + const unwatchBtn = cardElement.querySelector('.unwatch-item-btn') as HTMLButtonElement | null; + if (unwatchBtn) { + unwatchBtn.addEventListener('click', (e: MouseEvent) => { + e.stopPropagation(); + const itemId = unwatchBtn.dataset.id; + const itemType = unwatchBtn.dataset.type as 'artist' | 'playlist'; + if (itemId && itemType) { + unwatchItem(itemId, itemType, unwatchBtn, cardElement); + } + }); + } + + return cardElement; +} + +function showLoading(show: boolean) { + const loadingIndicator = document.getElementById('loadingWatchedItems'); + if (loadingIndicator) loadingIndicator.classList.toggle('hidden', !show); +} + +function showEmptyState(show: boolean) { + const emptyStateIndicator = document.getElementById('emptyWatchedItems'); + if (emptyStateIndicator) emptyStateIndicator.classList.toggle('hidden', !show); +} + +async function unwatchItem(itemId: string, itemType: 'artist' | 'playlist', buttonElement: HTMLButtonElement, cardElement: HTMLElement) { + const originalButtonContent = buttonElement.innerHTML; + buttonElement.disabled = true; + buttonElement.innerHTML = 'Unwatching...'; // Assuming a small loader icon + + const endpoint = `/api/${itemType}/watch/${itemId}`; + + try { + const response = await fetch(endpoint, { method: 'DELETE' }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `Server error: ${response.status}`); + } + const result = await response.json(); + showNotification(result.message || `${itemType.charAt(0).toUpperCase() + itemType.slice(1)} unwatched successfully.`); + + cardElement.style.transition = 'opacity 0.5s ease, transform 0.5s ease'; + cardElement.style.opacity = '0'; + cardElement.style.transform = 'scale(0.9)'; + setTimeout(() => { + cardElement.remove(); + const watchedItemsContainer = document.getElementById('watchedItemsContainer'); + const playlistGroups = document.querySelectorAll('.watched-items-group .results-grid'); + let totalItemsLeft = 0; + + if (playlistGroups.length > 0) { // Grouped view + playlistGroups.forEach(group => { + totalItemsLeft += group.childElementCount; + }); + // If a group becomes empty, we might want to remove the group header or show an empty message for that group. + // This can be added here if desired. + } else if (watchedItemsContainer) { // Non-grouped view + totalItemsLeft = watchedItemsContainer.childElementCount; + } + + if (totalItemsLeft === 0) { + // If all items are gone (either from groups or directly), reload to show empty state. + // This also correctly handles the case where the initial list had <= 8 items. + loadWatchedItems(); + } + + }, 500); + + } catch (error: any) { + console.error(`Error unwatching ${itemType}:`, error); + showNotification(`Failed to unwatch: ${error.message}`, true); + buttonElement.disabled = false; + buttonElement.innerHTML = originalButtonContent; + } +} + +async function triggerItemCheck(itemId: string, itemType: 'artist' | 'playlist', buttonElement: HTMLButtonElement) { + const originalButtonContent = buttonElement.innerHTML; // Will just be the img + buttonElement.disabled = true; + // Keep the icon, but we can add a class for spinning or use the same icon. + // For simplicity, just using the same icon. Text "Checking..." is removed. + buttonElement.innerHTML = 'Checking...'; + + const endpoint = `/api/${itemType}/watch/trigger_check/${itemId}`; + + try { + const response = await fetch(endpoint, { method: 'POST' }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); // Handle non-JSON error responses + throw new Error(errorData.error || `Server error: ${response.status}`); + } + const result = await response.json(); + showNotification(result.message || `Successfully triggered check for ${itemType}.`); + } catch (error: any) { + console.error(`Error triggering ${itemType} check:`, error); + showNotification(`Failed to trigger check: ${error.message}`, true); + } finally { + buttonElement.disabled = false; + buttonElement.innerHTML = originalButtonContent; + } +} + +// Helper function to show notifications (can be moved to a shared utility file if used elsewhere) +function showNotification(message: string, isError: boolean = false) { + const notificationArea = document.getElementById('notificationArea') || createNotificationArea(); + + // Limit the number of visible notifications + while (notificationArea.childElementCount >= MAX_NOTIFICATIONS) { + const oldestNotification = notificationArea.firstChild; // In column-reverse, firstChild is visually the bottom one + if (oldestNotification) { + oldestNotification.remove(); + } else { + break; // Should not happen if childElementCount > 0 + } + } + + const notification = document.createElement('div'); + notification.className = `notification-toast ${isError ? 'error' : 'success'}`; + notification.textContent = message; + + notificationArea.appendChild(notification); + + // Auto-remove after 5 seconds + setTimeout(() => { + notification.classList.add('hide'); + setTimeout(() => notification.remove(), 500); // Remove from DOM after fade out + }, 5000); +} + +function createNotificationArea(): HTMLElement { + const area = document.createElement('div'); + area.id = 'notificationArea'; + document.body.appendChild(area); + return area; +} \ No newline at end of file diff --git a/static/css/artist/artist.css b/static/css/artist/artist.css index 97887e7..62f6723 100644 --- a/static/css/artist/artist.css +++ b/static/css/artist/artist.css @@ -325,6 +325,53 @@ body { transform: scale(0.98); } +/* Watch Artist Button Styling */ +.watch-btn { + background-color: transparent; + color: #ffffff; + border: 1px solid #ffffff; + border-radius: 4px; + padding: 0.5rem 1rem; + font-size: 0.95rem; + cursor: pointer; + transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, transform 0.2s ease; + display: inline-flex; + align-items: center; + justify-content: center; + margin: 0.5rem; +} + +.watch-btn:hover { + background-color: #ffffff; + color: #121212; + border-color: #ffffff; +} + +.watch-btn.watching { + background-color: #1db954; /* Spotify green for "watching" state */ + color: #ffffff; + border-color: #1db954; +} + +.watch-btn.watching:hover { + background-color: #17a44b; /* Darker green on hover */ + border-color: #17a44b; + color: #ffffff; +} + +.watch-btn:active { + transform: scale(0.98); +} + +/* Styling for icons within watch and sync buttons */ +.watch-btn img, +.sync-btn img { + width: 16px; /* Adjust size as needed */ + height: 16px; /* Adjust size as needed */ + margin-right: 8px; /* Space between icon and text */ + filter: brightness(0) invert(1); /* Make icons white */ +} + /* Responsive Styles */ /* Medium Devices (Tablets) */ @@ -434,3 +481,157 @@ a:focus { color: #1db954; text-decoration: underline; } + +/* Toggle Known Status Button for Tracks/Albums */ +.toggle-known-status-btn { + background-color: transparent; + border: 1px solid var(--color-text-secondary); + color: var(--color-text-secondary); + padding: 5px; + border-radius: 50%; /* Make it circular */ + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + width: 30px; /* Fixed size */ + height: 30px; /* Fixed size */ + transition: background-color 0.2s, border-color 0.2s, color 0.2s, opacity 0.2s; /* Added opacity */ + /* opacity: 0; Initially hidden, JS will make it visible if artist is watched via persistent-album-action-btn */ +} + +.toggle-known-status-btn img { + width: 16px; /* Adjust icon size */ + height: 16px; + filter: brightness(0) invert(1); /* Make icon white consistently */ + margin: 0; /* Ensure no accidental margin for centering */ +} + +.toggle-known-status-btn:hover { + border-color: var(--color-primary); + background-color: rgba(var(--color-primary-rgb), 0.1); +} + +.toggle-known-status-btn[data-status="known"] { + /* Optional: specific styles if it's already known, e.g., a slightly different border */ + border-color: var(--color-success); /* Green border for known items */ +} + +.toggle-known-status-btn[data-status="missing"] { + /* Optional: specific styles if it's missing, e.g., a warning color */ + border-color: var(--color-warning); /* Orange border for missing items */ +} + +.toggle-known-status-btn:active { + transform: scale(0.95); +} + +/* Ensure album download button also fits well within actions container */ +.album-actions-container .album-download-btn { + width: 30px; + height: 30px; + padding: 5px; /* Ensure padding doesn't make it too big */ +} + +.album-actions-container .album-download-btn img { + width: 16px; + height: 16px; +} + +/* Album actions container */ +.album-actions-container { + /* position: absolute; */ /* No longer needed if buttons are positioned individually */ + /* bottom: 8px; */ + /* right: 8px; */ + /* display: flex; */ + /* gap: 8px; */ + /* background-color: rgba(0, 0, 0, 0.6); */ + /* padding: 5px; */ + /* border-radius: var(--radius-sm); */ + /* opacity: 0; */ /* Ensure it doesn't hide buttons if it still wraps them elsewhere */ + /* transition: opacity 0.2s ease-in-out; */ + display: none; /* Hide this container if it solely relied on hover and now buttons are persistent */ +} + +/* .album-card:hover .album-actions-container { */ + /* opacity: 1; */ /* Remove this hover effect */ +/* } */ + +/* Album card actions container - for persistent buttons at the bottom */ +.album-card-actions { + display: flex; + justify-content: space-between; /* Pushes children to ends */ + align-items: center; + padding: 8px; /* Spacing around the buttons */ + border-top: 1px solid var(--color-surface-darker, #2a2a2a); /* Separator line */ + /* Ensure it takes up full width of the card if not already */ + width: 100%; +} + +/* Persistent action button (e.g., toggle known/missing) on album card - BOTTOM-LEFT */ +.persistent-album-action-btn { + /* position: absolute; */ /* No longer absolute */ + /* bottom: 8px; */ + /* left: 8px; */ + /* z-index: 2; */ + opacity: 1; /* Ensure it is visible */ + /* Specific margin if needed, but flexbox space-between should handle it */ + margin: 0; /* Reset any previous margins */ +} + +/* Persistent download button on album card - BOTTOM-RIGHT */ +.persistent-download-btn { + /* position: absolute; */ /* No longer absolute */ + /* bottom: 8px; */ + /* right: 8px; */ + /* z-index: 2; */ + opacity: 1; /* Ensure it is visible */ + /* Specific margin if needed, but flexbox space-between should handle it */ + margin: 0; /* Reset any previous margins */ +} + +.album-cover.album-missing-in-db { + border: 3px dashed var(--color-warning); /* Example: orange dashed border */ + opacity: 0.7; +} + +/* NEW STYLES FOR BUTTON STATES */ +.persistent-album-action-btn.status-missing { + background-color: #d9534f; /* Bootstrap's btn-danger red */ + border-color: #d43f3a; +} + +.persistent-album-action-btn.status-missing:hover { + background-color: #c9302c; + border-color: #ac2925; +} + +/* Ensure icon is white on colored background */ +.persistent-album-action-btn.status-missing img { + filter: brightness(0) invert(1); +} + +.persistent-album-action-btn.status-known { + background-color: #5cb85c; /* Bootstrap's btn-success green */ + border-color: #4cae4c; +} + +.persistent-album-action-btn.status-known:hover { + background-color: #449d44; + border-color: #398439; +} + +/* Ensure icon is white on colored background */ +.persistent-album-action-btn.status-known img { + filter: brightness(0) invert(1); +} +/* END OF NEW STYLES */ + +/* Spinning Icon Animation */ +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(-360deg); } +} + +.icon-spin { + animation: spin 1s linear infinite; +} diff --git a/static/css/config/config.css b/static/css/config/config.css index 23e7b57..f6df3b3 100644 --- a/static/css/config/config.css +++ b/static/css/config/config.css @@ -65,85 +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 button as floating icon */ -.queue-icon.floating-icon { - position: fixed; - width: 56px; - height: 56px; - bottom: 20px; - right: 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; -} - -.queue-icon.floating-icon:hover { - background-color: var(--color-primary-hover); - transform: scale(1.05); -} - -.queue-icon.floating-icon img { - width: 24px; - height: 24px; - filter: brightness(0) invert(1); - margin: 0; -} - -/* Queue Icon Active State */ -.queue-icon.queue-icon-active { - background-color: #d13838 !important; - transition: background-color 0.3s ease; -} - -.queue-icon.queue-icon-active:hover { - background-color: #e04c4c !important; -} - -.queue-icon .queue-x { - font-size: 28px; - font-weight: bold; - color: white; - line-height: 24px; - display: inline-block; - transform: translateY(-2px); -} - /* Queue Sidebar for Config Page */ #downloadQueue { position: fixed; @@ -849,24 +770,6 @@ input:checked + .slider:before { width: 28px; height: 28px; } - - /* Queue icon mobile styles */ - .queue-icon.floating-icon { - width: 50px; - height: 50px; - right: 16px; - bottom: 16px; - } - - .queue-icon.floating-icon img { - width: 22px; - height: 22px; - } - - .queue-icon .queue-x { - font-size: 24px; - line-height: 20px; - } } /* Format help styles */ @@ -928,3 +831,166 @@ input:checked + .slider:before { opacity: 1; transform: translateY(0); } + +/* Credentials List Wrapper */ +.credentials-list-wrapper { + background: #181818; /* Same as original .credentials-list.card */ + border-radius: 12px; + box-shadow: 0 4px 8px rgba(0,0,0,0.15); + padding: 1.5rem; /* Add padding here if you want it around the whole block */ + margin-bottom: 2rem; +} + +/* Where individual credential items will be rendered */ +.credentials-list-items { + /* No specific styles needed here unless items need separation from the add button */ +} + +/* Styling for the Add New Account button to make it look like a list item */ +.add-account-item { + margin-top: 0.75rem; /* Space above the add button if there are items */ +} + +.btn-add-account-styled { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 1.25rem; + background-color: #1db954; /* Green background */ + color: #ffffff; + border: none; + border-radius: 8px; /* Same as credential-item */ + font-size: 1.1rem; /* Similar to credential-name */ + font-weight: 600; + cursor: pointer; + transition: background-color 0.3s ease, transform 0.2s ease; + text-align: center; + opacity: 1; /* Ensure it's not transparent by default */ +} + +.btn-add-account-styled img { + width: 20px; /* Adjust as needed */ + height: 20px; /* Adjust as needed */ + margin-right: 10px; + filter: brightness(0) invert(1); /* Make icon white if it's not already */ +} + +.btn-add-account-styled:hover { + background-color: #1aa34a; /* Darker green on hover */ + transform: translateY(-1px); +} + +/* New styles for the icon-based cancel button */ +.btn-cancel-icon { + background-color: #c0392b !important; /* Red background */ + padding: 0.6rem !important; /* Adjust padding for icon */ + width: auto; /* Allow button to size to icon */ + min-width: 40px; /* Ensure a minimum touch target size */ + height: 40px; /* Ensure a minimum touch target size */ + align-items: center; + justify-content: center; + border-radius: 50% !important; /* Make it circular */ + opacity: 1 !important; /* Ensure it's always visible when its container is */ + visibility: visible !important; /* Ensure it's not hidden by visibility property */ +} + +.btn-cancel-icon img { + width: 16px; /* Adjust icon size as needed */ + height: 16px; + filter: brightness(0) invert(1); /* Make icon white */ +} + +.btn-cancel-icon:hover { + background-color: #e74c3c !important; /* Lighter red on hover */ + transform: translateY(-2px) scale(1.05); +} + +/* Watch Options Config Section */ +.watch-options-config { + background: #181818; + padding: 1.5rem; + border-radius: 12px; + margin-bottom: 2rem; + box-shadow: 0 4px 8px rgba(0,0,0,0.15); + transition: transform 0.3s ease; +} + +.watch-options-config:hover { + transform: translateY(-2px); +} + +/* New Checklist Styles */ +.checklist-container { + background: #2a2a2a; + border: 1px solid #404040; + border-radius: 8px; + padding: 0.8rem; + margin-top: 0.5rem; +} + +.checklist-item { + display: flex; + align-items: center; + margin-bottom: 0.5rem; + padding: 0.3rem 0; +} + +.checklist-item:last-child { + margin-bottom: 0; +} + +.checklist-item input[type="checkbox"] { + margin-right: 0.8rem; + width: 18px; /* Custom size */ + height: 18px; /* Custom size */ + cursor: pointer; + accent-color: #1db954; /* Modern way to color checkboxes */ +} + +.checklist-item label { + color: #ffffff; /* Ensure label text is white */ + font-size: 0.95rem; + cursor: pointer; + /* Reset some global label styles if they interfere */ + display: inline; + margin-bottom: 0; +} + +/* Urgent Warning Message Style */ +.urgent-warning-message { + background-color: rgba(255, 165, 0, 0.1); /* Orange/Amber background */ + border: 1px solid #FFA500; /* Orange/Amber border */ + color: #FFA500; /* Orange/Amber text */ + padding: 1rem; + border-radius: 8px; + display: flex; /* Use flex to align icon and text */ + align-items: center; /* Vertically align icon and text */ + margin-top: 1rem; + margin-bottom: 1rem; +} + +.urgent-warning-message .warning-icon { + margin-right: 0.75rem; /* Space between icon and text */ + min-width: 24px; /* Ensure icon doesn't shrink too much */ + color: #FFA500; /* Match icon color to text/border */ +} + +/* Existing info-message style - ensure it doesn't conflict or adjust if needed */ +.info-message { + background-color: rgba(0, 123, 255, 0.1); + border: 1px solid #007bff; + color: #007bff; + padding: 1rem; + border-radius: 8px; + margin-top: 1rem; + margin-bottom: 1rem; +} + +/* Version text styling */ +.version-text { + font-size: 0.9rem; + color: #888; /* Light grey color */ + margin-left: auto; /* Push it to the right */ + padding-top: 0.5rem; /* Align with title better */ +} \ 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..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 a933b44..12c2968 100644 --- a/static/css/main/base.css +++ b/static/css/main/base.css @@ -27,6 +27,9 @@ --color-primary-hover: #17a44b; --color-error: #c0392b; --color-success: #2ecc71; + /* Adding accent green if not present, or ensuring it is */ + --color-accent-green: #22c55e; /* Example: A Tailwind-like green */ + --color-accent-green-dark: #16a34a; /* Darker shade for hover */ /* Spacing */ --space-xs: 0.25rem; @@ -155,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 */ @@ -218,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
+ + History + + + Watchlist + + + + @@ -46,10 +51,17 @@ + + History + + + Watchlist + + -
+ +
+
+ + +
+ +

Add New Spotify Account

@@ -241,6 +305,9 @@
+
@@ -250,10 +317,17 @@
+ + History + Back + + Watchlist + + + Page 1 of 1 + + + + + + + + Home + + + + + + + + + \ No newline at end of file diff --git a/templates/main.html b/static/html/main.html similarity index 84% rename from templates/main.html rename to static/html/main.html index 6afd394..e9a5b66 100755 --- a/templates/main.html +++ b/static/html/main.html @@ -59,10 +59,17 @@ + + History + Settings + + Watchlist + + + + @@ -50,10 +58,17 @@ + + History + + + Watchlist + + + + Watchlist + + + + +
+ +
+ + + + + + + + + History + + + Home + + + + Watchlist + + + + + + + + + \ No newline at end of file diff --git a/static/images/binoculars.svg b/static/images/binoculars.svg new file mode 100644 index 0000000..553498d --- /dev/null +++ b/static/images/binoculars.svg @@ -0,0 +1,12 @@ + + + + binoculars-filled + + + + + + + + \ No newline at end of file diff --git a/static/images/check.svg b/static/images/check.svg new file mode 100644 index 0000000..01ea137 --- /dev/null +++ b/static/images/check.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/static/images/cross.svg b/static/images/cross.svg new file mode 100644 index 0000000..cc2cd18 --- /dev/null +++ b/static/images/cross.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/static/images/eye-crossed.svg b/static/images/eye-crossed.svg new file mode 100644 index 0000000..ae11bb6 --- /dev/null +++ b/static/images/eye-crossed.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/static/images/eye.svg b/static/images/eye.svg new file mode 100644 index 0000000..fe5537f --- /dev/null +++ b/static/images/eye.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file 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 diff --git a/static/images/missing.svg b/static/images/missing.svg new file mode 100644 index 0000000..4eeb2b8 --- /dev/null +++ b/static/images/missing.svg @@ -0,0 +1,12 @@ + + + + ic_fluent_missing_metadata_24_filled + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/static/images/plus-circle.svg b/static/images/plus-circle.svg new file mode 100644 index 0000000..05357df --- /dev/null +++ b/static/images/plus-circle.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/static/images/refresh-cw.svg b/static/images/refresh-cw.svg new file mode 100644 index 0000000..efd180f --- /dev/null +++ b/static/images/refresh-cw.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/static/images/refresh.svg b/static/images/refresh.svg new file mode 100644 index 0000000..a66e48e --- /dev/null +++ b/static/images/refresh.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/static/images/skull-head.svg b/static/images/skull-head.svg new file mode 100644 index 0000000..96b0922 --- /dev/null +++ b/static/images/skull-head.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/static/js/album.js b/static/js/album.js deleted file mode 100644 index 615ddd3..0000000 --- a/static/js/album.js +++ /dev/null @@ -1,282 +0,0 @@ -import { downloadQueue } from './queue.js'; - -document.addEventListener('DOMContentLoaded', () => { - const pathSegments = window.location.pathname.split('/'); - const albumId = pathSegments[pathSegments.indexOf('album') + 1]; - - if (!albumId) { - showError('No album ID provided.'); - return; - } - - // Fetch album info directly - fetch(`/api/album/info?id=${encodeURIComponent(albumId)}`) - .then(response => { - if (!response.ok) throw new Error('Network response was not ok'); - return response.json(); - }) - .then(data => renderAlbum(data)) - .catch(error => { - console.error('Error:', error); - showError('Failed to load album.'); - }); - - const queueIcon = document.getElementById('queueIcon'); - if (queueIcon) { - queueIcon.addEventListener('click', () => { - downloadQueue.toggleVisibility(); - }); - } -}); - -function renderAlbum(album) { - // Hide loading and error messages. - document.getElementById('loading').classList.add('hidden'); - document.getElementById('error').classList.add('hidden'); - - // Check if album itself is marked explicit and filter is enabled - const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled(); - if (isExplicitFilterEnabled && album.explicit) { - // Show placeholder for explicit album - const placeholderContent = ` -
-

Explicit Content Filtered

-

This album contains explicit content and has been filtered based on your settings.

-

The explicit content filter is controlled by environment variables.

-
- `; - - const contentContainer = document.getElementById('album-header'); - if (contentContainer) { - contentContainer.innerHTML = placeholderContent; - contentContainer.classList.remove('hidden'); - } - - return; // Stop rendering the actual album content - } - - const baseUrl = window.location.origin; - - // Set album header info. - document.getElementById('album-name').innerHTML = - `${album.name || 'Unknown Album'}`; - - document.getElementById('album-artist').innerHTML = - `By ${album.artists?.map(artist => - `${artist?.name || 'Unknown Artist'}` - ).join(', ') || 'Unknown Artist'}`; - - const releaseYear = album.release_date ? new Date(album.release_date).getFullYear() : 'N/A'; - document.getElementById('album-stats').textContent = - `${releaseYear} • ${album.total_tracks || '0'} songs • ${album.label || 'Unknown Label'}`; - - document.getElementById('album-copyright').textContent = - album.copyrights?.map(c => c?.text || '').filter(text => text).join(' • ') || ''; - - const image = album.images?.[0]?.url || '/static/images/placeholder.jpg'; - document.getElementById('album-image').src = image; - - // Create (if needed) the Home Button. - let homeButton = document.getElementById('homeButton'); - if (!homeButton) { - homeButton = document.createElement('button'); - homeButton.id = 'homeButton'; - homeButton.className = 'home-btn'; - - const homeIcon = document.createElement('img'); - homeIcon.src = '/static/images/home.svg'; - homeIcon.alt = 'Home'; - homeButton.appendChild(homeIcon); - - // Insert as first child of album-header. - const headerContainer = document.getElementById('album-header'); - headerContainer.insertBefore(homeButton, headerContainer.firstChild); - } - homeButton.addEventListener('click', () => { - window.location.href = window.location.origin; - }); - - // Check if any track in the album is explicit when filter is enabled - let hasExplicitTrack = false; - if (isExplicitFilterEnabled && album.tracks?.items) { - hasExplicitTrack = album.tracks.items.some(track => track && track.explicit); - } - - // Create (if needed) the Download Album Button. - let downloadAlbumBtn = document.getElementById('downloadAlbumBtn'); - if (!downloadAlbumBtn) { - downloadAlbumBtn = document.createElement('button'); - downloadAlbumBtn.id = 'downloadAlbumBtn'; - downloadAlbumBtn.textContent = 'Download Full Album'; - downloadAlbumBtn.className = 'download-btn download-btn--main'; - document.getElementById('album-header').appendChild(downloadAlbumBtn); - } - - if (isExplicitFilterEnabled && hasExplicitTrack) { - // Disable the album download button and display a message explaining why - downloadAlbumBtn.disabled = true; - downloadAlbumBtn.classList.add('download-btn--disabled'); - downloadAlbumBtn.innerHTML = `Album Contains Explicit Tracks`; - } else { - // Normal behavior when no explicit tracks are present - downloadAlbumBtn.addEventListener('click', () => { - // Remove any other download buttons (keeping the full-album button in place). - document.querySelectorAll('.download-btn').forEach(btn => { - if (btn.id !== 'downloadAlbumBtn') btn.remove(); - }); - - downloadAlbumBtn.disabled = true; - downloadAlbumBtn.textContent = 'Queueing...'; - - downloadWholeAlbum(album) - .then(() => { - downloadAlbumBtn.textContent = 'Queued!'; - }) - .catch(err => { - showError('Failed to queue album download: ' + (err?.message || 'Unknown error')); - downloadAlbumBtn.disabled = false; - }); - }); - } - - // Render each track. - const tracksList = document.getElementById('tracks-list'); - tracksList.innerHTML = ''; - - if (album.tracks?.items) { - album.tracks.items.forEach((track, index) => { - if (!track) return; // Skip null or undefined tracks - - // Skip explicit tracks if filter is enabled - if (isExplicitFilterEnabled && track.explicit) { - // Add a placeholder for filtered explicit tracks - const trackElement = document.createElement('div'); - trackElement.className = 'track track-filtered'; - trackElement.innerHTML = ` -
${index + 1}
-
-
Explicit Content Filtered
-
This track is not shown due to explicit content filter settings
-
-
--:--
- `; - tracksList.appendChild(trackElement); - return; - } - - const trackElement = document.createElement('div'); - trackElement.className = 'track'; - trackElement.innerHTML = ` -
${index + 1}
-
- -
- ${track.artists?.map(a => - `${a?.name || 'Unknown Artist'}` - ).join(', ') || 'Unknown Artist'} -
-
-
${msToTime(track.duration_ms || 0)}
- - `; - tracksList.appendChild(trackElement); - }); - } - - // Reveal header and track list. - document.getElementById('album-header').classList.remove('hidden'); - document.getElementById('tracks-container').classList.remove('hidden'); - attachDownloadListeners(); - - // If on a small screen, re-arrange the action buttons. - if (window.innerWidth <= 480) { - let actionsContainer = document.getElementById('album-actions'); - if (!actionsContainer) { - actionsContainer = document.createElement('div'); - actionsContainer.id = 'album-actions'; - document.getElementById('album-header').appendChild(actionsContainer); - } - // Append in the desired order: Home, Download, then Queue Toggle (if exists). - actionsContainer.innerHTML = ''; // Clear any previous content - actionsContainer.appendChild(document.getElementById('homeButton')); - actionsContainer.appendChild(document.getElementById('downloadAlbumBtn')); - const queueToggle = document.querySelector('.queue-toggle'); - if (queueToggle) { - actionsContainer.appendChild(queueToggle); - } - } -} - -async function downloadWholeAlbum(album) { - const url = album.external_urls?.spotify || ''; - if (!url) { - throw new Error('Missing album URL'); - } - - try { - // Use the centralized downloadQueue.download method - await downloadQueue.download(url, 'album', { name: album.name || 'Unknown Album' }); - // Make the queue visible after queueing - downloadQueue.toggleVisibility(true); - } catch (error) { - showError('Album download failed: ' + (error?.message || 'Unknown error')); - throw error; - } -} - -function msToTime(duration) { - const minutes = Math.floor(duration / 60000); - const seconds = ((duration % 60000) / 1000).toFixed(0); - return `${minutes}:${seconds.padStart(2, '0')}`; -} - -function showError(message) { - const errorEl = document.getElementById('error'); - errorEl.textContent = message || 'An error occurred'; - errorEl.classList.remove('hidden'); -} - -function attachDownloadListeners() { - document.querySelectorAll('.download-btn').forEach((btn) => { - if (btn.id === 'downloadAlbumBtn') return; - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const url = e.currentTarget.dataset.url || ''; - const type = e.currentTarget.dataset.type || ''; - const name = e.currentTarget.dataset.name || extractName(url) || 'Unknown'; - // Remove the button immediately after click. - e.currentTarget.remove(); - startDownload(url, type, { name }); - }); - }); -} - -async function startDownload(url, type, item, albumType) { - if (!url) { - showError('Missing URL for download'); - return; - } - - try { - // Use the centralized downloadQueue.download method - await downloadQueue.download(url, type, item, albumType); - - // Make the queue visible after queueing - downloadQueue.toggleVisibility(true); - } catch (error) { - showError('Download failed: ' + (error?.message || 'Unknown error')); - throw error; - } -} - -function extractName(url) { - return url || 'Unknown'; -} diff --git a/static/js/artist.js b/static/js/artist.js deleted file mode 100644 index 696fb84..0000000 --- a/static/js/artist.js +++ /dev/null @@ -1,381 +0,0 @@ -// Import the downloadQueue singleton -import { downloadQueue } from './queue.js'; - -document.addEventListener('DOMContentLoaded', () => { - const pathSegments = window.location.pathname.split('/'); - const artistId = pathSegments[pathSegments.indexOf('artist') + 1]; - - if (!artistId) { - showError('No artist ID provided.'); - return; - } - - // Fetch artist info directly - fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}`) - .then(response => { - if (!response.ok) throw new Error('Network response was not ok'); - return response.json(); - }) - .then(data => renderArtist(data, artistId)) - .catch(error => { - console.error('Error:', error); - showError('Failed to load artist info.'); - }); - - const queueIcon = document.getElementById('queueIcon'); - if (queueIcon) { - queueIcon.addEventListener('click', () => downloadQueue.toggleVisibility()); - } -}); - -function renderArtist(artistData, artistId) { - document.getElementById('loading').classList.add('hidden'); - document.getElementById('error').classList.add('hidden'); - - // Check if explicit filter is enabled - const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled(); - - const firstAlbum = artistData.items?.[0] || {}; - const artistName = firstAlbum?.artists?.[0]?.name || 'Unknown Artist'; - const artistImage = firstAlbum?.images?.[0]?.url || '/static/images/placeholder.jpg'; - - document.getElementById('artist-name').innerHTML = - `${artistName}`; - document.getElementById('artist-stats').textContent = `${artistData.total || '0'} albums`; - document.getElementById('artist-image').src = artistImage; - - // Define the artist URL (used by both full-discography and group downloads) - const artistUrl = `https://open.spotify.com/artist/${artistId}`; - - // Home Button - let homeButton = document.getElementById('homeButton'); - if (!homeButton) { - homeButton = document.createElement('button'); - homeButton.id = 'homeButton'; - homeButton.className = 'home-btn'; - homeButton.innerHTML = `Home`; - document.getElementById('artist-header').prepend(homeButton); - } - homeButton.addEventListener('click', () => window.location.href = window.location.origin); - - // Download Whole Artist Button using the new artist API endpoint - let downloadArtistBtn = document.getElementById('downloadArtistBtn'); - if (!downloadArtistBtn) { - downloadArtistBtn = document.createElement('button'); - downloadArtistBtn.id = 'downloadArtistBtn'; - downloadArtistBtn.className = 'download-btn download-btn--main'; - downloadArtistBtn.textContent = 'Download All Discography'; - document.getElementById('artist-header').appendChild(downloadArtistBtn); - } - - // When explicit filter is enabled, disable all download buttons - if (isExplicitFilterEnabled) { - // Disable the artist download button and display a message explaining why - downloadArtistBtn.disabled = true; - downloadArtistBtn.classList.add('download-btn--disabled'); - downloadArtistBtn.innerHTML = `Downloads Restricted`; - } else { - // Normal behavior when explicit filter is not enabled - downloadArtistBtn.addEventListener('click', () => { - // Optionally remove other download buttons from individual albums. - document.querySelectorAll('.download-btn:not(#downloadArtistBtn)').forEach(btn => btn.remove()); - downloadArtistBtn.disabled = true; - downloadArtistBtn.textContent = 'Queueing...'; - - // Queue the entire discography (albums, singles, compilations, and appears_on) - // Use our local startDownload function instead of downloadQueue.startArtistDownload - startDownload( - artistUrl, - 'artist', - { name: artistName, artist: artistName }, - 'album,single,compilation,appears_on' - ) - .then((taskIds) => { - downloadArtistBtn.textContent = 'Artist queued'; - // Make the queue visible after queueing - downloadQueue.toggleVisibility(true); - - // Optionally show number of albums queued - if (Array.isArray(taskIds)) { - downloadArtistBtn.title = `${taskIds.length} albums queued for download`; - } - }) - .catch(err => { - downloadArtistBtn.textContent = 'Download All Discography'; - downloadArtistBtn.disabled = false; - showError('Failed to queue artist download: ' + (err?.message || 'Unknown error')); - }); - }); - } - - // Group albums by type (album, single, compilation, etc.) and separate "appears_on" albums - const albumGroups = {}; - const appearingAlbums = []; - - (artistData.items || []).forEach(album => { - if (!album) return; - - // Skip explicit albums if filter is enabled - if (isExplicitFilterEnabled && album.explicit) { - return; - } - - // Check if this is an "appears_on" album - if (album.album_group === 'appears_on') { - appearingAlbums.push(album); - } else { - // Group by album_type for the artist's own releases - const type = (album.album_type || 'unknown').toLowerCase(); - if (!albumGroups[type]) albumGroups[type] = []; - albumGroups[type].push(album); - } - }); - - // Render album groups - const groupsContainer = document.getElementById('album-groups'); - groupsContainer.innerHTML = ''; - - // Render regular album groups first - for (const [groupType, albums] of Object.entries(albumGroups)) { - const groupSection = document.createElement('section'); - groupSection.className = 'album-group'; - - // If explicit filter is enabled, don't show the group download button - const groupHeaderHTML = isExplicitFilterEnabled ? - `
-

${capitalize(groupType)}s

-
Visit album pages to download content
-
` : - `
-

${capitalize(groupType)}s

- -
`; - - groupSection.innerHTML = ` - ${groupHeaderHTML} -
- `; - - const albumsContainer = groupSection.querySelector('.albums-list'); - albums.forEach(album => { - if (!album) return; - - const albumElement = document.createElement('div'); - albumElement.className = 'album-card'; - - // Create album card with or without download button based on explicit filter setting - if (isExplicitFilterEnabled) { - albumElement.innerHTML = ` - - Album cover - -
-
${album.name || 'Unknown Album'}
-
${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}
-
- `; - } else { - albumElement.innerHTML = ` - - Album cover - -
-
${album.name || 'Unknown Album'}
-
${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}
-
- - `; - } - - albumsContainer.appendChild(albumElement); - }); - - groupsContainer.appendChild(groupSection); - } - - // Render "Featuring" section if there are any appearing albums - if (appearingAlbums.length > 0) { - const featuringSection = document.createElement('section'); - featuringSection.className = 'album-group'; - - const featuringHeaderHTML = isExplicitFilterEnabled ? - `
-

Featuring

-
Visit album pages to download content
-
` : - `
-

Featuring

- -
`; - - featuringSection.innerHTML = ` - ${featuringHeaderHTML} -
- `; - - const albumsContainer = featuringSection.querySelector('.albums-list'); - appearingAlbums.forEach(album => { - if (!album) return; - - const albumElement = document.createElement('div'); - albumElement.className = 'album-card'; - - // Create album card with or without download button based on explicit filter setting - if (isExplicitFilterEnabled) { - albumElement.innerHTML = ` - - Album cover - -
-
${album.name || 'Unknown Album'}
-
${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}
-
- `; - } else { - albumElement.innerHTML = ` - - Album cover - -
-
${album.name || 'Unknown Album'}
-
${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}
-
- - `; - } - - albumsContainer.appendChild(albumElement); - }); - - // Add to the end so it appears at the bottom - groupsContainer.appendChild(featuringSection); - } - - document.getElementById('artist-header').classList.remove('hidden'); - document.getElementById('albums-container').classList.remove('hidden'); - - // Only attach download listeners if explicit filter is not enabled - if (!isExplicitFilterEnabled) { - attachDownloadListeners(); - // Pass the artist URL and name so the group buttons can use the artist download function - attachGroupDownloadListeners(artistUrl, artistName); - } -} - -// Event listeners for group downloads using the artist download function -function attachGroupDownloadListeners(artistUrl, artistName) { - document.querySelectorAll('.group-download-btn').forEach(btn => { - btn.addEventListener('click', async (e) => { - const groupType = e.target.dataset.groupType || 'album'; // e.g. "album", "single", "compilation", "appears_on" - e.target.disabled = true; - - // Custom text for the 'appears_on' group - const displayType = groupType === 'appears_on' ? 'Featuring Albums' : `${capitalize(groupType)}s`; - e.target.textContent = `Queueing all ${displayType}...`; - - try { - // Use our local startDownload function with the group type filter - const taskIds = await startDownload( - artistUrl, - 'artist', - { name: artistName || 'Unknown Artist', artist: artistName || 'Unknown Artist' }, - groupType // Only queue releases of this specific type. - ); - - // Optionally show number of albums queued - const totalQueued = Array.isArray(taskIds) ? taskIds.length : 0; - e.target.textContent = `Queued all ${displayType}`; - e.target.title = `${totalQueued} albums queued for download`; - - // Make the queue visible after queueing - downloadQueue.toggleVisibility(true); - } catch (error) { - e.target.textContent = `Download All ${displayType}`; - e.target.disabled = false; - showError(`Failed to queue download for all ${groupType}: ${error?.message || 'Unknown error'}`); - } - }); - }); -} - -// Individual download handlers remain unchanged. -function attachDownloadListeners() { - document.querySelectorAll('.download-btn:not(.group-download-btn)').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const url = e.currentTarget.dataset.url || ''; - const name = e.currentTarget.dataset.name || 'Unknown'; - // Always use 'album' type for individual album downloads regardless of category - const type = 'album'; - - e.currentTarget.remove(); - // Use the centralized downloadQueue.download method - downloadQueue.download(url, type, { name, type }) - .catch(err => showError('Download failed: ' + (err?.message || 'Unknown error'))); - }); - }); -} - -// Add startDownload function (similar to track.js and main.js) -/** - * Starts the download process via centralized download queue - */ -async function startDownload(url, type, item, albumType) { - if (!url || !type) { - showError('Missing URL or type for download'); - return; - } - - try { - // Use the centralized downloadQueue.download method for all downloads including artist downloads - const result = await downloadQueue.download(url, type, item, albumType); - - // Make the queue visible after queueing - downloadQueue.toggleVisibility(true); - - // Return the result for tracking - return result; - } catch (error) { - showError('Download failed: ' + (error?.message || 'Unknown error')); - throw error; - } -} - -// UI Helpers -function showError(message) { - const errorEl = document.getElementById('error'); - if (errorEl) { - errorEl.textContent = message || 'An error occurred'; - errorEl.classList.remove('hidden'); - } -} - -function capitalize(str) { - return str ? str.charAt(0).toUpperCase() + str.slice(1) : ''; -} diff --git a/static/js/config.js b/static/js/config.js deleted file mode 100644 index 87ca997..0000000 --- a/static/js/config.js +++ /dev/null @@ -1,763 +0,0 @@ -import { downloadQueue } from './queue.js'; - -const serviceConfig = { - spotify: { - fields: [ - { id: 'username', label: 'Username', type: 'text' }, - { id: 'credentials', label: 'Credentials', type: 'text' } - ], - validator: (data) => ({ - username: data.username, - credentials: data.credentials - }), - // Adding search credentials fields - searchFields: [ - { id: 'client_id', label: 'Client ID', type: 'text' }, - { id: 'client_secret', label: 'Client Secret', type: 'password' } - ], - searchValidator: (data) => ({ - client_id: data.client_id, - client_secret: data.client_secret - }) - }, - deezer: { - fields: [ - { id: 'arl', label: 'ARL', type: 'text' } - ], - validator: (data) => ({ - arl: data.arl - }) - } -}; - -let currentService = 'spotify'; -let currentCredential = null; -let isEditingSearch = false; - -// Global variables to hold the active accounts from the config response. -let activeSpotifyAccount = ''; -let activeDeezerAccount = ''; - -document.addEventListener('DOMContentLoaded', async () => { - try { - await initConfig(); - setupServiceTabs(); - setupEventListeners(); - - const queueIcon = document.getElementById('queueIcon'); - if (queueIcon) { - queueIcon.addEventListener('click', () => { - downloadQueue.toggleVisibility(); - }); - } - } catch (error) { - showConfigError(error.message); - } -}); - -async function initConfig() { - await loadConfig(); - await updateAccountSelectors(); - loadCredentials(currentService); - updateFormFields(); -} - -function setupServiceTabs() { - const serviceTabs = document.querySelectorAll('.tab-button'); - serviceTabs.forEach(tab => { - tab.addEventListener('click', () => { - serviceTabs.forEach(t => t.classList.remove('active')); - tab.classList.add('active'); - currentService = tab.dataset.service; - loadCredentials(currentService); - updateFormFields(); - }); - }); -} - -function setupEventListeners() { - document.getElementById('credentialForm').addEventListener('submit', handleCredentialSubmit); - - // Config change listeners - document.getElementById('defaultServiceSelect').addEventListener('change', function() { - updateServiceSpecificOptions(); - saveConfig(); - }); - document.getElementById('fallbackToggle').addEventListener('change', saveConfig); - document.getElementById('realTimeToggle').addEventListener('change', saveConfig); - document.getElementById('spotifyQualitySelect').addEventListener('change', saveConfig); - document.getElementById('deezerQualitySelect').addEventListener('change', saveConfig); - document.getElementById('tracknumPaddingToggle').addEventListener('change', saveConfig); - document.getElementById('maxRetries').addEventListener('change', saveConfig); - document.getElementById('retryDelaySeconds').addEventListener('change', saveConfig); - - // Update active account globals when the account selector is changed. - document.getElementById('spotifyAccountSelect').addEventListener('change', (e) => { - activeSpotifyAccount = e.target.value; - saveConfig(); - }); - document.getElementById('deezerAccountSelect').addEventListener('change', (e) => { - activeDeezerAccount = e.target.value; - saveConfig(); - }); - - // Formatting settings - document.getElementById('customDirFormat').addEventListener('change', saveConfig); - document.getElementById('customTrackFormat').addEventListener('change', saveConfig); - - // Copy to clipboard when selecting placeholders - document.getElementById('dirFormatHelp').addEventListener('change', function() { - copyPlaceholderToClipboard(this); - }); - document.getElementById('trackFormatHelp').addEventListener('change', function() { - copyPlaceholderToClipboard(this); - }); - - // Max concurrent downloads change listener - document.getElementById('maxConcurrentDownloads').addEventListener('change', saveConfig); -} - -function updateServiceSpecificOptions() { - // Get the selected service - const selectedService = document.getElementById('defaultServiceSelect').value; - - // Get all service-specific sections - const spotifyOptions = document.querySelectorAll('.config-item.spotify-specific'); - const deezerOptions = document.querySelectorAll('.config-item.deezer-specific'); - - // Handle Spotify specific options - if (selectedService === 'spotify') { - // Highlight Spotify section - document.getElementById('spotifyQualitySelect').closest('.config-item').classList.add('highlighted-option'); - document.getElementById('spotifyAccountSelect').closest('.config-item').classList.add('highlighted-option'); - - // Remove highlight from Deezer - document.getElementById('deezerQualitySelect').closest('.config-item').classList.remove('highlighted-option'); - document.getElementById('deezerAccountSelect').closest('.config-item').classList.remove('highlighted-option'); - } - // Handle Deezer specific options (for future use) - else if (selectedService === 'deezer') { - // Highlight Deezer section - document.getElementById('deezerQualitySelect').closest('.config-item').classList.add('highlighted-option'); - document.getElementById('deezerAccountSelect').closest('.config-item').classList.add('highlighted-option'); - - // Remove highlight from Spotify - document.getElementById('spotifyQualitySelect').closest('.config-item').classList.remove('highlighted-option'); - document.getElementById('spotifyAccountSelect').closest('.config-item').classList.remove('highlighted-option'); - } -} - -async function updateAccountSelectors() { - try { - const [spotifyResponse, deezerResponse] = await Promise.all([ - fetch('/api/credentials/spotify'), - fetch('/api/credentials/deezer') - ]); - - const spotifyAccounts = await spotifyResponse.json(); - const deezerAccounts = await deezerResponse.json(); - - // Get the select elements - const spotifySelect = document.getElementById('spotifyAccountSelect'); - const deezerSelect = document.getElementById('deezerAccountSelect'); - - // Rebuild the Spotify selector options - spotifySelect.innerHTML = spotifyAccounts - .map(a => ``) - .join(''); - - // Use the active account loaded from the config (activeSpotifyAccount) - if (spotifyAccounts.includes(activeSpotifyAccount)) { - spotifySelect.value = activeSpotifyAccount; - } else if (spotifyAccounts.length > 0) { - spotifySelect.value = spotifyAccounts[0]; - activeSpotifyAccount = spotifyAccounts[0]; - await saveConfig(); - } - - // Rebuild the Deezer selector options - deezerSelect.innerHTML = deezerAccounts - .map(a => ``) - .join(''); - - if (deezerAccounts.includes(activeDeezerAccount)) { - deezerSelect.value = activeDeezerAccount; - } else if (deezerAccounts.length > 0) { - deezerSelect.value = deezerAccounts[0]; - activeDeezerAccount = deezerAccounts[0]; - await saveConfig(); - } - - // Handle empty account lists - [spotifySelect, deezerSelect].forEach((select, index) => { - const accounts = index === 0 ? spotifyAccounts : deezerAccounts; - if (accounts.length === 0) { - select.innerHTML = ''; - select.value = ''; - } - }); - } catch (error) { - showConfigError('Error updating accounts: ' + error.message); - } -} - -async function loadCredentials(service) { - try { - const response = await fetch(`/api/credentials/all/${service}`); - if (!response.ok) { - throw new Error(`Failed to load credentials: ${response.statusText}`); - } - - const credentials = await response.json(); - renderCredentialsList(service, credentials); - } catch (error) { - showConfigError(error.message); - } -} - -function renderCredentialsList(service, credentials) { - const list = document.querySelector('.credentials-list'); - list.innerHTML = ''; - - if (!credentials.length) { - list.innerHTML = '
No accounts found. Add a new account below.
'; - return; - } - - credentials.forEach(credData => { - const credItem = document.createElement('div'); - credItem.className = 'credential-item'; - - const hasSearchCreds = credData.search && Object.keys(credData.search).length > 0; - - credItem.innerHTML = ` -
- ${credData.name} - ${service === 'spotify' ? - `
- ${hasSearchCreds ? 'API Configured' : 'No API Credentials'} -
` : ''} -
-
- - ${service === 'spotify' ? - `` : ''} - -
- `; - - list.appendChild(credItem); - }); - - // Set up event handlers - list.querySelectorAll('.delete-btn').forEach(btn => { - btn.addEventListener('click', handleDeleteCredential); - }); - - list.querySelectorAll('.edit-btn').forEach(btn => { - btn.addEventListener('click', (e) => { - isEditingSearch = false; - handleEditCredential(e); - }); - }); - - if (service === 'spotify') { - list.querySelectorAll('.edit-search-btn').forEach(btn => { - btn.addEventListener('click', handleEditSearchCredential); - }); - } -} - -async function handleDeleteCredential(e) { - try { - const service = e.target.dataset.service; - const name = e.target.dataset.name; - - if (!service || !name) { - throw new Error('Missing credential information'); - } - - if (!confirm(`Are you sure you want to delete the ${name} account?`)) { - return; - } - - const response = await fetch(`/api/credentials/${service}/${name}`, { - method: 'DELETE' - }); - - if (!response.ok) { - throw new Error('Failed to delete credential'); - } - - // If the deleted credential is the active account, clear the selection. - const accountSelect = document.getElementById(`${service}AccountSelect`); - if (accountSelect.value === name) { - accountSelect.value = ''; - if (service === 'spotify') { - activeSpotifyAccount = ''; - } else if (service === 'deezer') { - activeDeezerAccount = ''; - } - await saveConfig(); - } - - loadCredentials(service); - await updateAccountSelectors(); - } catch (error) { - showConfigError(error.message); - } -} - -async function handleEditCredential(e) { - const service = e.target.dataset.service; - const name = e.target.dataset.name; - - try { - document.querySelector(`[data-service="${service}"]`).click(); - await new Promise(resolve => setTimeout(resolve, 50)); - - const response = await fetch(`/api/credentials/${service}/${name}`); - if (!response.ok) { - throw new Error(`Failed to load credential: ${response.statusText}`); - } - - const data = await response.json(); - - currentCredential = name; - document.getElementById('credentialName').value = name; - document.getElementById('credentialName').disabled = true; - document.getElementById('formTitle').textContent = `Edit ${service.charAt(0).toUpperCase() + service.slice(1)} Account`; - document.getElementById('submitCredentialBtn').textContent = 'Update Account'; - - // Show regular fields - populateFormFields(service, data); - toggleSearchFieldsVisibility(false); - } catch (error) { - showConfigError(error.message); - } -} - -async function handleEditSearchCredential(e) { - const service = e.target.dataset.service; - const name = e.target.dataset.name; - - try { - if (service !== 'spotify') { - throw new Error('Search credentials are only available for Spotify'); - } - - document.querySelector(`[data-service="${service}"]`).click(); - await new Promise(resolve => setTimeout(resolve, 50)); - - isEditingSearch = true; - currentCredential = name; - document.getElementById('credentialName').value = name; - document.getElementById('credentialName').disabled = true; - document.getElementById('formTitle').textContent = `Spotify API Credentials for ${name}`; - document.getElementById('submitCredentialBtn').textContent = 'Save API Credentials'; - - // Try to load existing search credentials - try { - const searchResponse = await fetch(`/api/credentials/${service}/${name}?type=search`); - if (searchResponse.ok) { - const searchData = await searchResponse.json(); - // Populate search fields - serviceConfig[service].searchFields.forEach(field => { - const element = document.getElementById(field.id); - if (element) element.value = searchData[field.id] || ''; - }); - } else { - // Clear search fields if no existing search credentials - serviceConfig[service].searchFields.forEach(field => { - const element = document.getElementById(field.id); - if (element) element.value = ''; - }); - } - } catch (error) { - // Clear search fields if there was an error - serviceConfig[service].searchFields.forEach(field => { - const element = document.getElementById(field.id); - if (element) element.value = ''; - }); - } - - // Hide regular account fields, show search fields - toggleSearchFieldsVisibility(true); - } catch (error) { - showConfigError(error.message); - } -} - -function toggleSearchFieldsVisibility(showSearchFields) { - const serviceFieldsDiv = document.getElementById('serviceFields'); - const searchFieldsDiv = document.getElementById('searchFields'); - - if (showSearchFields) { - // Hide regular fields and remove 'required' attribute - serviceFieldsDiv.style.display = 'none'; - // Remove required attribute from service fields - serviceConfig[currentService].fields.forEach(field => { - const input = document.getElementById(field.id); - if (input) input.removeAttribute('required'); - }); - - // Show search fields and add 'required' attribute - searchFieldsDiv.style.display = 'block'; - // Make search fields required - if (currentService === 'spotify' && serviceConfig[currentService].searchFields) { - serviceConfig[currentService].searchFields.forEach(field => { - const input = document.getElementById(field.id); - if (input) input.setAttribute('required', ''); - }); - } - } else { - // Show regular fields and add 'required' attribute - serviceFieldsDiv.style.display = 'block'; - // Make service fields required - serviceConfig[currentService].fields.forEach(field => { - const input = document.getElementById(field.id); - if (input) input.setAttribute('required', ''); - }); - - // Hide search fields and remove 'required' attribute - searchFieldsDiv.style.display = 'none'; - // Remove required from search fields - if (currentService === 'spotify' && serviceConfig[currentService].searchFields) { - serviceConfig[currentService].searchFields.forEach(field => { - const input = document.getElementById(field.id); - if (input) input.removeAttribute('required'); - }); - } - } -} - -function updateFormFields() { - const serviceFieldsDiv = document.getElementById('serviceFields'); - const searchFieldsDiv = document.getElementById('searchFields'); - - // Clear any existing fields - serviceFieldsDiv.innerHTML = ''; - searchFieldsDiv.innerHTML = ''; - - // Add regular account fields - serviceConfig[currentService].fields.forEach(field => { - const fieldDiv = document.createElement('div'); - fieldDiv.className = 'form-group'; - fieldDiv.innerHTML = ` - - - `; - serviceFieldsDiv.appendChild(fieldDiv); - }); - - // Add search fields for Spotify - if (currentService === 'spotify' && serviceConfig[currentService].searchFields) { - serviceConfig[currentService].searchFields.forEach(field => { - const fieldDiv = document.createElement('div'); - fieldDiv.className = 'form-group'; - fieldDiv.innerHTML = ` - - - `; - searchFieldsDiv.appendChild(fieldDiv); - }); - } - - // Reset form title and button text - document.getElementById('formTitle').textContent = `Add New ${currentService.charAt(0).toUpperCase() + currentService.slice(1)} Account`; - document.getElementById('submitCredentialBtn').textContent = 'Save Account'; - - // Initially show regular fields, hide search fields - toggleSearchFieldsVisibility(false); - isEditingSearch = false; -} - -function populateFormFields(service, data) { - serviceConfig[service].fields.forEach(field => { - const element = document.getElementById(field.id); - if (element) element.value = data[field.id] || ''; - }); -} - -async function handleCredentialSubmit(e) { - e.preventDefault(); - const service = document.querySelector('.tab-button.active').dataset.service; - const nameInput = document.getElementById('credentialName'); - const name = nameInput.value.trim(); - - try { - if (!currentCredential && !name) { - throw new Error('Credential name is required'); - } - - const endpointName = currentCredential || name; - let method, data, endpoint; - - if (isEditingSearch && service === 'spotify') { - // Handle search credentials - const formData = {}; - let isValid = true; - let firstInvalidField = null; - - // Manually validate search fields - serviceConfig[service].searchFields.forEach(field => { - const input = document.getElementById(field.id); - const value = input ? input.value.trim() : ''; - formData[field.id] = value; - - if (!value) { - isValid = false; - if (!firstInvalidField) firstInvalidField = input; - } - }); - - if (!isValid) { - if (firstInvalidField) firstInvalidField.focus(); - throw new Error('All fields are required'); - } - - data = serviceConfig[service].searchValidator(formData); - endpoint = `/api/credentials/${service}/${endpointName}?type=search`; - - // Check if search credentials already exist for this account - const checkResponse = await fetch(endpoint); - method = checkResponse.ok ? 'PUT' : 'POST'; - } else { - // Handle regular account credentials - const formData = {}; - let isValid = true; - let firstInvalidField = null; - - // Manually validate account fields - serviceConfig[service].fields.forEach(field => { - const input = document.getElementById(field.id); - const value = input ? input.value.trim() : ''; - formData[field.id] = value; - - if (!value) { - isValid = false; - if (!firstInvalidField) firstInvalidField = input; - } - }); - - if (!isValid) { - if (firstInvalidField) firstInvalidField.focus(); - throw new Error('All fields are required'); - } - - data = serviceConfig[service].validator(formData); - endpoint = `/api/credentials/${service}/${endpointName}`; - method = currentCredential ? 'PUT' : 'POST'; - } - - const response = await fetch(endpoint, { - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to save credentials'); - } - - await updateAccountSelectors(); - await saveConfig(); - loadCredentials(service); - resetForm(); - - // Show success message - showConfigSuccess(isEditingSearch ? 'API credentials saved successfully' : 'Account saved successfully'); - } catch (error) { - showConfigError(error.message); - } -} - -function resetForm() { - currentCredential = null; - isEditingSearch = false; - const nameInput = document.getElementById('credentialName'); - nameInput.value = ''; - nameInput.disabled = false; - document.getElementById('credentialForm').reset(); - - // Reset form title and button text - const service = currentService.charAt(0).toUpperCase() + currentService.slice(1); - document.getElementById('formTitle').textContent = `Add New ${service} Account`; - document.getElementById('submitCredentialBtn').textContent = 'Save Account'; - - // Show regular account fields, hide search fields - toggleSearchFieldsVisibility(false); -} - -async function saveConfig() { - // Read active account values directly from the DOM (or from the globals which are kept in sync) - const config = { - service: document.getElementById('defaultServiceSelect').value, - spotify: document.getElementById('spotifyAccountSelect').value, - deezer: document.getElementById('deezerAccountSelect').value, - fallback: document.getElementById('fallbackToggle').checked, - spotifyQuality: document.getElementById('spotifyQualitySelect').value, - deezerQuality: document.getElementById('deezerQualitySelect').value, - realTime: document.getElementById('realTimeToggle').checked, - customDirFormat: document.getElementById('customDirFormat').value, - customTrackFormat: document.getElementById('customTrackFormat').value, - maxConcurrentDownloads: parseInt(document.getElementById('maxConcurrentDownloads').value, 10) || 3, - maxRetries: parseInt(document.getElementById('maxRetries').value, 10) || 3, - retryDelaySeconds: parseInt(document.getElementById('retryDelaySeconds').value, 10) || 5, - retry_delay_increase: parseInt(document.getElementById('retryDelayIncrease').value, 10) || 5, - tracknum_padding: document.getElementById('tracknumPaddingToggle').checked - }; - - try { - const response = await fetch('/api/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(config) - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to save config'); - } - } catch (error) { - showConfigError(error.message); - } -} - -async function loadConfig() { - try { - const response = await fetch('/api/config'); - if (!response.ok) throw new Error('Failed to load config'); - - const savedConfig = await response.json(); - - // Set default service selection - document.getElementById('defaultServiceSelect').value = savedConfig.service || 'spotify'; - - // Update the service-specific options based on selected service - updateServiceSpecificOptions(); - - // Use the "spotify" and "deezer" properties from the API response to set the active accounts. - activeSpotifyAccount = savedConfig.spotify || ''; - activeDeezerAccount = savedConfig.deezer || ''; - - // (Optionally, if the account selects already exist you can set their values here, - // but updateAccountSelectors() will rebuild the options and set the proper values.) - const spotifySelect = document.getElementById('spotifyAccountSelect'); - const deezerSelect = document.getElementById('deezerAccountSelect'); - if (spotifySelect) spotifySelect.value = activeSpotifyAccount; - if (deezerSelect) deezerSelect.value = activeDeezerAccount; - - // Update other configuration fields. - document.getElementById('fallbackToggle').checked = !!savedConfig.fallback; - document.getElementById('spotifyQualitySelect').value = savedConfig.spotifyQuality || 'NORMAL'; - document.getElementById('deezerQualitySelect').value = savedConfig.deezerQuality || 'MP3_128'; - document.getElementById('realTimeToggle').checked = !!savedConfig.realTime; - document.getElementById('customDirFormat').value = savedConfig.customDirFormat || '%ar_album%/%album%'; - document.getElementById('customTrackFormat').value = savedConfig.customTrackFormat || '%tracknum%. %music%'; - document.getElementById('maxConcurrentDownloads').value = savedConfig.maxConcurrentDownloads || '3'; - document.getElementById('maxRetries').value = savedConfig.maxRetries || '3'; - document.getElementById('retryDelaySeconds').value = savedConfig.retryDelaySeconds || '5'; - document.getElementById('retryDelayIncrease').value = savedConfig.retry_delay_increase || '5'; - document.getElementById('tracknumPaddingToggle').checked = savedConfig.tracknum_padding === undefined ? true : !!savedConfig.tracknum_padding; - - // Update explicit filter status - updateExplicitFilterStatus(savedConfig.explicitFilter); - } catch (error) { - showConfigError('Error loading config: ' + error.message); - } -} - -function updateExplicitFilterStatus(isEnabled) { - const statusElement = document.getElementById('explicitFilterStatus'); - if (statusElement) { - // Remove existing classes - statusElement.classList.remove('enabled', 'disabled'); - - // Add appropriate class and text based on whether filter is enabled - if (isEnabled) { - statusElement.textContent = 'Enabled'; - statusElement.classList.add('enabled'); - } else { - statusElement.textContent = 'Disabled'; - statusElement.classList.add('disabled'); - } - } -} - -function showConfigError(message) { - const errorDiv = document.getElementById('configError'); - errorDiv.textContent = message; - setTimeout(() => (errorDiv.textContent = ''), 5000); -} - -function showConfigSuccess(message) { - const successDiv = document.getElementById('configSuccess'); - successDiv.textContent = message; - setTimeout(() => (successDiv.textContent = ''), 5000); -} - -// Function to copy the selected placeholder to clipboard -function copyPlaceholderToClipboard(select) { - const placeholder = select.value; - - if (!placeholder) return; // If nothing selected - - // Copy to clipboard - navigator.clipboard.writeText(placeholder) - .then(() => { - // Show success notification - showCopyNotification(`Copied ${placeholder} to clipboard`); - - // Reset select to default after a short delay - setTimeout(() => { - select.selectedIndex = 0; - }, 500); - }) - .catch(err => { - console.error('Failed to copy: ', err); - }); -} - -// Function to show a notification when copying -function showCopyNotification(message) { - // Check if notification container exists, create if not - let notificationContainer = document.getElementById('copyNotificationContainer'); - if (!notificationContainer) { - notificationContainer = document.createElement('div'); - notificationContainer.id = 'copyNotificationContainer'; - document.body.appendChild(notificationContainer); - } - - // Create notification element - const notification = document.createElement('div'); - notification.className = 'copy-notification'; - notification.textContent = message; - - // Add to container - notificationContainer.appendChild(notification); - - // Trigger animation - setTimeout(() => { - notification.classList.add('show'); - }, 10); - - // Remove after animation completes - setTimeout(() => { - notification.classList.remove('show'); - setTimeout(() => { - notificationContainer.removeChild(notification); - }, 300); - }, 2000); -} diff --git a/static/js/main.js b/static/js/main.js deleted file mode 100644 index b553718..0000000 --- a/static/js/main.js +++ /dev/null @@ -1,468 +0,0 @@ -// main.js -import { downloadQueue } from './queue.js'; - -document.addEventListener('DOMContentLoaded', function() { - // DOM elements - const searchInput = document.getElementById('searchInput'); - const searchButton = document.getElementById('searchButton'); - const searchType = document.getElementById('searchType'); - const resultsContainer = document.getElementById('resultsContainer'); - const queueIcon = document.getElementById('queueIcon'); - const emptyState = document.getElementById('emptyState'); - const loadingResults = document.getElementById('loadingResults'); - - // Initialize the queue - if (queueIcon) { - queueIcon.addEventListener('click', () => { - downloadQueue.toggleVisibility(); - }); - } - - // Add event listeners - if (searchButton) { - searchButton.addEventListener('click', performSearch); - } - - if (searchInput) { - searchInput.addEventListener('keypress', function(e) { - if (e.key === 'Enter') { - performSearch(); - } - }); - - // Auto-detect and handle pasted Spotify URLs - searchInput.addEventListener('input', function(e) { - const inputVal = e.target.value.trim(); - if (isSpotifyUrl(inputVal)) { - const details = getSpotifyResourceDetails(inputVal); - if (details) { - searchType.value = details.type; - } - } - }); - } - - // Restore last search type if no URL override - const savedType = localStorage.getItem('lastSearchType'); - if (savedType && ['track','album','playlist','artist'].includes(savedType)) { - searchType.value = savedType; - } - // Save last selection on change - if (searchType) { - searchType.addEventListener('change', () => { - localStorage.setItem('lastSearchType', searchType.value); - }); - } - - // Check for URL parameters - const urlParams = new URLSearchParams(window.location.search); - const query = urlParams.get('q'); - const type = urlParams.get('type'); - - if (query) { - searchInput.value = query; - if (type && ['track', 'album', 'playlist', 'artist'].includes(type)) { - searchType.value = type; - } - performSearch(); - } else { - // Show empty state if no query - showEmptyState(true); - } - - /** - * Performs the search based on input values - */ - async function performSearch() { - const query = searchInput.value.trim(); - if (!query) return; - - // Handle direct Spotify URLs - if (isSpotifyUrl(query)) { - const details = getSpotifyResourceDetails(query); - if (details && details.id) { - // Redirect to the appropriate page - window.location.href = `/${details.type}/${details.id}`; - return; - } - } - - // Update URL without reloading page - const newUrl = `${window.location.pathname}?q=${encodeURIComponent(query)}&type=${searchType.value}`; - window.history.pushState({ path: newUrl }, '', newUrl); - - // Show loading state - showEmptyState(false); - showLoading(true); - resultsContainer.innerHTML = ''; - - try { - const url = `/api/search?q=${encodeURIComponent(query)}&search_type=${searchType.value}&limit=40`; - const response = await fetch(url); - - if (!response.ok) { - throw new Error('Network response was not ok'); - } - - const data = await response.json(); - - // Hide loading indicator - showLoading(false); - - // Render results - if (data && data.items && data.items.length > 0) { - resultsContainer.innerHTML = ''; - - // Filter out items with null/undefined essential display parameters - const validItems = filterValidItems(data.items, searchType.value); - - if (validItems.length === 0) { - // No valid items found after filtering - resultsContainer.innerHTML = ` -
-

No valid results found for "${query}"

-
- `; - return; - } - - validItems.forEach((item, index) => { - const cardElement = createResultCard(item, searchType.value, index); - - // Store the item data directly on the button element - const downloadBtn = cardElement.querySelector('.download-btn'); - if (downloadBtn) { - downloadBtn.dataset.itemIndex = index; - } - - resultsContainer.appendChild(cardElement); - }); - - // Attach download handlers to the newly created cards - attachDownloadListeners(validItems); - } else { - // No results found - resultsContainer.innerHTML = ` -
-

No results found for "${query}"

-
- `; - } - } catch (error) { - console.error('Error:', error); - showLoading(false); - resultsContainer.innerHTML = ` -
-

Error searching: ${error.message}

-
- `; - } - } - - /** - * Filters out items with null/undefined essential display parameters based on search type - */ - function filterValidItems(items, type) { - if (!items) return []; - - return items.filter(item => { - // Skip null/undefined items - if (!item) return false; - - // Skip explicit content if filter is enabled - if (downloadQueue.isExplicitFilterEnabled() && item.explicit === true) { - return false; - } - - // Check essential parameters based on search type - switch (type) { - case 'track': - // For tracks, we need name, artists, and album - return ( - item.name && - item.artists && - item.artists.length > 0 && - item.artists[0] && - item.artists[0].name && - item.album && - item.album.name && - item.external_urls && - item.external_urls.spotify - ); - - case 'album': - // For albums, we need name, artists, and cover image - return ( - item.name && - item.artists && - item.artists.length > 0 && - item.artists[0] && - item.artists[0].name && - item.external_urls && - item.external_urls.spotify - ); - - case 'playlist': - // For playlists, we need name, owner, and tracks - return ( - item.name && - item.owner && - item.owner.display_name && - item.tracks && - item.external_urls && - item.external_urls.spotify - ); - - case 'artist': - // For artists, we need name - return ( - item.name && - item.external_urls && - item.external_urls.spotify - ); - - default: - // Default case - just check if the item exists - return true; - } - }); - } - - /** - * Attaches download handlers to result cards - */ - function attachDownloadListeners(items) { - document.querySelectorAll('.download-btn').forEach((btn) => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - - // Get the item index from the button's dataset - const itemIndex = parseInt(btn.dataset.itemIndex, 10); - - // Get the corresponding item - const item = items[itemIndex]; - if (!item) return; - - const type = searchType.value; - let url; - - // Determine the URL based on item type - if (item.external_urls && item.external_urls.spotify) { - url = item.external_urls.spotify; - } else if (item.href) { - url = item.href; - } else { - showError('Could not determine download URL'); - return; - } - - // Prepare metadata for the download - const metadata = { - name: item.name || 'Unknown', - artist: item.artists ? item.artists[0]?.name : undefined - }; - - // Disable the button and update text - btn.disabled = true; - - // For artist downloads, show a different message since it will queue multiple albums - if (type === 'artist') { - btn.innerHTML = 'Queueing albums...'; - } else { - btn.innerHTML = 'Queueing...'; - } - - // Start the download - startDownload(url, type, metadata, item.album ? item.album.album_type : null) - .then(() => { - // For artists, show how many albums were queued - if (type === 'artist') { - btn.innerHTML = 'Albums queued!'; - // Open the queue automatically for artist downloads - downloadQueue.toggleVisibility(true); - } else { - btn.innerHTML = 'Queued!'; - } - }) - .catch((error) => { - btn.disabled = false; - btn.innerHTML = 'Download'; - showError('Failed to queue download: ' + error.message); - }); - }); - }); - } - - /** - * Starts the download process via API - */ - async function startDownload(url, type, item, albumType) { - if (!url || !type) { - showError('Missing URL or type for download'); - return; - } - - try { - // Use the centralized downloadQueue.download method - await downloadQueue.download(url, type, item, albumType); - - // Make the queue visible after queueing - downloadQueue.toggleVisibility(true); - } catch (error) { - showError('Download failed: ' + (error.message || 'Unknown error')); - throw error; - } - } - - /** - * Shows an error message - */ - function showError(message) { - const errorDiv = document.createElement('div'); - errorDiv.className = 'error'; - errorDiv.textContent = message; - document.body.appendChild(errorDiv); - - // Auto-remove after 5 seconds - setTimeout(() => errorDiv.remove(), 5000); - } - - /** - * Shows a success message - */ - function showSuccess(message) { - const successDiv = document.createElement('div'); - successDiv.className = 'success'; - successDiv.textContent = message; - document.body.appendChild(successDiv); - - // Auto-remove after 5 seconds - setTimeout(() => successDiv.remove(), 5000); - } - - /** - * Checks if a string is a valid Spotify URL - */ - function isSpotifyUrl(url) { - return url.includes('open.spotify.com') || - url.includes('spotify:') || - url.includes('link.tospotify.com'); - } - - /** - * Extracts details from a Spotify URL - */ - function getSpotifyResourceDetails(url) { - // Allow optional path segments (e.g. intl-fr) before resource type - const regex = /spotify\.com\/(?:[^\/]+\/)??(track|album|playlist|artist)\/([a-zA-Z0-9]+)/i; - const match = url.match(regex); - - if (match) { - return { - type: match[1], - id: match[2] - }; - } - return null; - } - - /** - * Formats milliseconds to MM:SS - */ - function msToMinutesSeconds(ms) { - if (!ms) return '0:00'; - - const minutes = Math.floor(ms / 60000); - const seconds = ((ms % 60000) / 1000).toFixed(0); - return `${minutes}:${seconds.padStart(2, '0')}`; - } - - /** - * Creates a result card element - */ - function createResultCard(item, type, index) { - const cardElement = document.createElement('div'); - cardElement.className = 'result-card'; - - // Set cursor to pointer for clickable cards - cardElement.style.cursor = 'pointer'; - - // Get the appropriate image URL - let imageUrl = '/static/images/placeholder.jpg'; - if (item.album && item.album.images && item.album.images.length > 0) { - imageUrl = item.album.images[0].url; - } else if (item.images && item.images.length > 0) { - imageUrl = item.images[0].url; - } - - // Get the appropriate details based on type - let subtitle = ''; - let details = ''; - - switch (type) { - case 'track': - subtitle = item.artists ? item.artists.map(a => a.name).join(', ') : 'Unknown Artist'; - details = item.album ? `${item.album.name}${msToMinutesSeconds(item.duration_ms)}` : ''; - break; - case 'album': - subtitle = item.artists ? item.artists.map(a => a.name).join(', ') : 'Unknown Artist'; - details = `${item.total_tracks || 0} tracks${item.release_date ? new Date(item.release_date).getFullYear() : ''}`; - break; - case 'playlist': - subtitle = `By ${item.owner ? item.owner.display_name : 'Unknown'}`; - details = `${item.tracks && item.tracks.total ? item.tracks.total : 0} tracks`; - break; - case 'artist': - subtitle = 'Artist'; - details = item.genres ? `${item.genres.slice(0, 2).join(', ')}` : ''; - break; - } - - // Build the HTML - cardElement.innerHTML = ` -
- ${item.name || 'Item'} -
-
${item.name || 'Unknown'}
-
${subtitle}
-
${details}
- - `; - - // Add click event to navigate to the item's detail page - cardElement.addEventListener('click', (e) => { - // Don't trigger if the download button was clicked - if (e.target.classList.contains('download-btn') || - e.target.parentElement.classList.contains('download-btn')) { - return; - } - - if (item.id) { - window.location.href = `/${type}/${item.id}`; - } - }); - - return cardElement; - } - - /** - * Show/hide the empty state - */ - function showEmptyState(show) { - if (emptyState) { - emptyState.style.display = show ? 'flex' : 'none'; - } - } - - /** - * Show/hide the loading indicator - */ - function showLoading(show) { - if (loadingResults) { - loadingResults.classList.toggle('hidden', !show); - } - } -}); diff --git a/static/js/playlist.js b/static/js/playlist.js deleted file mode 100644 index bf57266..0000000 --- a/static/js/playlist.js +++ /dev/null @@ -1,395 +0,0 @@ -// Import the downloadQueue singleton from your working queue.js implementation. -import { downloadQueue } from './queue.js'; - -document.addEventListener('DOMContentLoaded', () => { - // Parse playlist ID from URL - const pathSegments = window.location.pathname.split('/'); - const playlistId = pathSegments[pathSegments.indexOf('playlist') + 1]; - - if (!playlistId) { - showError('No playlist ID provided.'); - return; - } - - // Fetch playlist info directly - fetch(`/api/playlist/info?id=${encodeURIComponent(playlistId)}`) - .then(response => { - if (!response.ok) throw new Error('Network response was not ok'); - return response.json(); - }) - .then(data => renderPlaylist(data)) - .catch(error => { - console.error('Error:', error); - showError('Failed to load playlist.'); - }); - - const queueIcon = document.getElementById('queueIcon'); - if (queueIcon) { - queueIcon.addEventListener('click', () => { - downloadQueue.toggleVisibility(); - }); - } -}); - -/** - * Renders playlist header and tracks. - */ -function renderPlaylist(playlist) { - // Hide loading and error messages - document.getElementById('loading').classList.add('hidden'); - document.getElementById('error').classList.add('hidden'); - - // Check if explicit filter is enabled - const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled(); - - // Update header info - document.getElementById('playlist-name').textContent = playlist.name || 'Unknown Playlist'; - document.getElementById('playlist-owner').textContent = `By ${playlist.owner?.display_name || 'Unknown User'}`; - document.getElementById('playlist-stats').textContent = - `${playlist.followers?.total || '0'} followers • ${playlist.tracks?.total || '0'} songs`; - document.getElementById('playlist-description').textContent = playlist.description || ''; - const image = playlist.images?.[0]?.url || '/static/images/placeholder.jpg'; - document.getElementById('playlist-image').src = image; - - // --- Add Home Button --- - let homeButton = document.getElementById('homeButton'); - if (!homeButton) { - homeButton = document.createElement('button'); - homeButton.id = 'homeButton'; - homeButton.className = 'home-btn'; - // Use an tag to display the SVG icon. - homeButton.innerHTML = `Home`; - // Insert the home button at the beginning of the header container. - const headerContainer = document.getElementById('playlist-header'); - if (headerContainer) { - headerContainer.insertBefore(homeButton, headerContainer.firstChild); - } - } - homeButton.addEventListener('click', () => { - // Navigate to the site's base URL. - window.location.href = window.location.origin; - }); - - // Check if any track in the playlist is explicit when filter is enabled - let hasExplicitTrack = false; - if (isExplicitFilterEnabled && playlist.tracks?.items) { - hasExplicitTrack = playlist.tracks.items.some(item => item?.track && item.track.explicit); - } - - // --- Add "Download Whole Playlist" Button --- - let downloadPlaylistBtn = document.getElementById('downloadPlaylistBtn'); - if (!downloadPlaylistBtn) { - downloadPlaylistBtn = document.createElement('button'); - downloadPlaylistBtn.id = 'downloadPlaylistBtn'; - downloadPlaylistBtn.textContent = 'Download Whole Playlist'; - downloadPlaylistBtn.className = 'download-btn download-btn--main'; - // Insert the button into the header container. - const headerContainer = document.getElementById('playlist-header'); - if (headerContainer) { - headerContainer.appendChild(downloadPlaylistBtn); - } - } - - // --- Add "Download Playlist's Albums" Button --- - let downloadAlbumsBtn = document.getElementById('downloadAlbumsBtn'); - if (!downloadAlbumsBtn) { - downloadAlbumsBtn = document.createElement('button'); - downloadAlbumsBtn.id = 'downloadAlbumsBtn'; - downloadAlbumsBtn.textContent = "Download Playlist's Albums"; - downloadAlbumsBtn.className = 'download-btn download-btn--main'; - // Insert the new button into the header container. - const headerContainer = document.getElementById('playlist-header'); - if (headerContainer) { - headerContainer.appendChild(downloadAlbumsBtn); - } - } - - if (isExplicitFilterEnabled && hasExplicitTrack) { - // Disable both playlist buttons and display messages explaining why - downloadPlaylistBtn.disabled = true; - downloadPlaylistBtn.classList.add('download-btn--disabled'); - downloadPlaylistBtn.innerHTML = `Playlist Contains Explicit Tracks`; - - downloadAlbumsBtn.disabled = true; - downloadAlbumsBtn.classList.add('download-btn--disabled'); - downloadAlbumsBtn.innerHTML = `Albums Access Restricted`; - } else { - // Normal behavior when no explicit tracks are present - downloadPlaylistBtn.addEventListener('click', () => { - // Remove individual track download buttons (but leave the whole playlist button). - document.querySelectorAll('.download-btn').forEach(btn => { - if (btn.id !== 'downloadPlaylistBtn') { - btn.remove(); - } - }); - - // Disable the whole playlist button to prevent repeated clicks. - downloadPlaylistBtn.disabled = true; - downloadPlaylistBtn.textContent = 'Queueing...'; - - // Initiate the playlist download. - downloadWholePlaylist(playlist).then(() => { - downloadPlaylistBtn.textContent = 'Queued!'; - }).catch(err => { - showError('Failed to queue playlist download: ' + (err?.message || 'Unknown error')); - downloadPlaylistBtn.disabled = false; - }); - }); - - downloadAlbumsBtn.addEventListener('click', () => { - // Remove individual track download buttons (but leave this album button). - document.querySelectorAll('.download-btn').forEach(btn => { - if (btn.id !== 'downloadAlbumsBtn') btn.remove(); - }); - - downloadAlbumsBtn.disabled = true; - downloadAlbumsBtn.textContent = 'Queueing...'; - - downloadPlaylistAlbums(playlist) - .then(() => { - downloadAlbumsBtn.textContent = 'Queued!'; - }) - .catch(err => { - showError('Failed to queue album downloads: ' + (err?.message || 'Unknown error')); - downloadAlbumsBtn.disabled = false; - }); - }); - } - - // Render tracks list - const tracksList = document.getElementById('tracks-list'); - if (!tracksList) return; - - tracksList.innerHTML = ''; // Clear any existing content - - if (playlist.tracks?.items) { - playlist.tracks.items.forEach((item, index) => { - if (!item || !item.track) return; // Skip null/undefined tracks - - const track = item.track; - - // Skip explicit tracks if filter is enabled - if (isExplicitFilterEnabled && track.explicit) { - // Add a placeholder for filtered explicit tracks - const trackElement = document.createElement('div'); - trackElement.className = 'track track-filtered'; - trackElement.innerHTML = ` -
${index + 1}
-
-
Explicit Content Filtered
-
This track is not shown due to explicit content filter settings
-
-
Not available
-
--:--
- `; - tracksList.appendChild(trackElement); - return; - } - - // Create links for track, artist, and album using their IDs. - const trackLink = `/track/${track.id || ''}`; - const artistLink = `/artist/${track.artists?.[0]?.id || ''}`; - const albumLink = `/album/${track.album?.id || ''}`; - - const trackElement = document.createElement('div'); - trackElement.className = 'track'; - trackElement.innerHTML = ` -
${index + 1}
- - -
${msToTime(track.duration_ms || 0)}
- - `; - tracksList.appendChild(trackElement); - }); - } - - // Reveal header and tracks container - document.getElementById('playlist-header').classList.remove('hidden'); - document.getElementById('tracks-container').classList.remove('hidden'); - - // Attach download listeners to newly rendered download buttons - attachDownloadListeners(); -} - -/** - * Converts milliseconds to minutes:seconds. - */ -function msToTime(duration) { - if (!duration || isNaN(duration)) return '0:00'; - - const minutes = Math.floor(duration / 60000); - const seconds = ((duration % 60000) / 1000).toFixed(0); - return `${minutes}:${seconds.padStart(2, '0')}`; -} - -/** - * Displays an error message in the UI. - */ -function showError(message) { - const errorEl = document.getElementById('error'); - if (errorEl) { - errorEl.textContent = message || 'An error occurred'; - errorEl.classList.remove('hidden'); - } -} - -/** - * Attaches event listeners to all individual download buttons. - */ -function attachDownloadListeners() { - document.querySelectorAll('.download-btn').forEach((btn) => { - // Skip the whole playlist and album download buttons. - if (btn.id === 'downloadPlaylistBtn' || btn.id === 'downloadAlbumsBtn') return; - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const url = e.currentTarget.dataset.url || ''; - const type = e.currentTarget.dataset.type || ''; - const name = e.currentTarget.dataset.name || extractName(url) || 'Unknown'; - // Remove the button immediately after click. - e.currentTarget.remove(); - startDownload(url, type, { name }); - }); - }); -} - -/** - * Initiates the whole playlist download by calling the playlist endpoint. - */ -async function downloadWholePlaylist(playlist) { - if (!playlist) { - throw new Error('Invalid playlist data'); - } - - const url = playlist.external_urls?.spotify || ''; - if (!url) { - throw new Error('Missing playlist URL'); - } - - try { - // Use the centralized downloadQueue.download method - await downloadQueue.download(url, 'playlist', { name: playlist.name || 'Unknown Playlist' }); - // Make the queue visible after queueing - downloadQueue.toggleVisibility(true); - } catch (error) { - showError('Playlist download failed: ' + (error?.message || 'Unknown error')); - throw error; - } -} - -/** - * Initiates album downloads for each unique album in the playlist, - * adding a 20ms delay between each album download and updating the button - * with the progress (queued_albums/total_albums). - */ -async function downloadPlaylistAlbums(playlist) { - if (!playlist?.tracks?.items) { - showError('No tracks found in this playlist.'); - return; - } - - // Build a map of unique albums (using album ID as the key). - const albumMap = new Map(); - playlist.tracks.items.forEach(item => { - if (!item?.track?.album) return; - - const album = item.track.album; - if (album && album.id) { - albumMap.set(album.id, album); - } - }); - - const uniqueAlbums = Array.from(albumMap.values()); - const totalAlbums = uniqueAlbums.length; - if (totalAlbums === 0) { - showError('No albums found in this playlist.'); - return; - } - - // Get a reference to the "Download Playlist's Albums" button. - const downloadAlbumsBtn = document.getElementById('downloadAlbumsBtn'); - if (downloadAlbumsBtn) { - // Initialize the progress display. - downloadAlbumsBtn.textContent = `0/${totalAlbums}`; - } - - try { - // Process each album sequentially. - for (let i = 0; i < totalAlbums; i++) { - const album = uniqueAlbums[i]; - if (!album) continue; - - const albumUrl = album.external_urls?.spotify || ''; - if (!albumUrl) continue; - - // Use the centralized downloadQueue.download method - await downloadQueue.download( - albumUrl, - 'album', - { name: album.name || 'Unknown Album' } - ); - - // Update button text with current progress. - if (downloadAlbumsBtn) { - downloadAlbumsBtn.textContent = `${i + 1}/${totalAlbums}`; - } - - // Wait 20 milliseconds before processing the next album. - await new Promise(resolve => setTimeout(resolve, 20)); - } - - // Once all albums have been queued, update the button text. - if (downloadAlbumsBtn) { - downloadAlbumsBtn.textContent = 'Queued!'; - } - - // Make the queue visible after queueing all albums - downloadQueue.toggleVisibility(true); - } catch (error) { - // Propagate any errors encountered. - throw error; - } -} - -/** - * Starts the download process using the centralized download method from the queue. - */ -async function startDownload(url, type, item, albumType) { - if (!url || !type) { - showError('Missing URL or type for download'); - return; - } - - try { - // Use the centralized downloadQueue.download method - await downloadQueue.download(url, type, item, albumType); - - // Make the queue visible after queueing - downloadQueue.toggleVisibility(true); - } catch (error) { - showError('Download failed: ' + (error?.message || 'Unknown error')); - throw error; - } -} - -/** - * A helper function to extract a display name from the URL. - */ -function extractName(url) { - return url || 'Unknown'; -} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f147cc1 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2017", // Specify ECMAScript target version + "module": "ES2020", // Specify module code generation + "strict": true, // Enable all strict type-checking options + "esModuleInterop": true, // Enables emit interoperability between CommonJS and ES Modules + "skipLibCheck": true, // Skip type checking of declaration files + "forceConsistentCasingInFileNames": true, // Disallow inconsistently-cased references to the same file. + "outDir": "./static/js", + "rootDir": "./src/js" + }, + "include": [ + "src/js/**/*.ts", + "src/js/album.ts", + "src/js/artist.ts", + "src/js/config.ts", + "src/js/main.ts", + "src/js/playlist.ts", + "src/js/queue.ts", + "src/js/track.ts" + ], + "exclude": [ + "node_modules" // Specifies an array of filenames or patterns that should be skipped when resolving include. + ] +} \ No newline at end of file