diff --git a/.env b/.env index 1ca1a5d..8373b07 100644 --- a/.env +++ b/.env @@ -1,7 +1,7 @@ # Docker Compose environment variables # Redis connection (external or internal) -REDIS_HOST=redis +REDIS_HOST=localhost REDIS_PORT=6379 REDIS_DB=0 REDIS_PASSWORD=CHANGE_ME diff --git a/app.py b/app.py index 27b0b41..133ad18 100755 --- a/app.py +++ b/app.py @@ -38,6 +38,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', @@ -141,10 +145,10 @@ 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') # Serve frontend @app.route('/') @@ -230,6 +234,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/routes/__init__.py b/routes/__init__.py index e69de29..9bde965 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.DEBUG, + 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 0a973ca..e2cf496 100755 --- a/routes/album.py +++ b/routes/album.py @@ -9,13 +9,15 @@ from routes.utils.celery_tasks import store_task_info, store_task_status, Progre 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') + # Construct the URL from album_id + url = f"https://open.spotify.com/album/{album_id}" + # Validate required parameters if not url: return Response( diff --git a/routes/artist.py b/routes/artist.py index b3b747f..f811250 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,7 +58,7 @@ 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 successfully_queued_albums, duplicate_albums = download_artist_albums( @@ -104,6 +125,21 @@ def get_artist_info(): try: from routes.utils.get_info import get_spotify_info artist_info = get_spotify_info(spotify_id, "artist") + + # 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, @@ -118,3 +154,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") + + # Check if we got any data and if it has items + if not artist_album_list_data or not isinstance(artist_album_list_data.get('items'), list): + logger.error(f"Could not fetch album list details for artist {artist_spotify_id} from Spotify using get_spotify_info('artist'). Data: {artist_album_list_data}") + 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 66e27cb..71a47f5 100644 --- a/routes/config.py +++ b/routes/config.py @@ -8,6 +8,7 @@ import os config_bp = Blueprint('config_bp', __name__) 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,39 @@ 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 = { + '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 { + '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() @@ -148,4 +182,39 @@ 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 = { + '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/playlist.py b/routes/playlist.py index 144e461..9cb6f09 100755 --- a/routes/playlist.py +++ b/routes/playlist.py @@ -1,25 +1,43 @@ -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') orig_params = request.args.to_dict() - orig_params["original_url"] = request.url - + + # Construct the URL from playlist_id + url = f"https://open.spotify.com/playlist/{playlist_id}" + orig_params["original_url"] = url # Update original_url to the constructed one + # 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, @@ -104,8 +122,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, @@ -121,3 +154,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/track.py b/routes/track.py index 7fdbfed..b48be7b 100755 --- a/routes/track.py +++ b/routes/track.py @@ -10,14 +10,16 @@ from urllib.parse import urlparse # for URL validation 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') orig_params = request.args.to_dict() - orig_params["original_url"] = request.url + + # 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 # Validate required parameters if not url: diff --git a/routes/utils/celery_queue_manager.py b/routes/utils/celery_queue_manager.py index eb3aafd..362ec48 100644 --- a/routes/utils/celery_queue_manager.py +++ b/routes/utils/celery_queue_manager.py @@ -94,16 +94,20 @@ 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. - If a duplicate active task is found, a new task ID is created and immediately set to an ERROR state. + - 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 (either for a new task or for a new error-state task if duplicate detected). + 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 for duplicate check @@ -111,20 +115,18 @@ class CeleryDownloadQueueManager: incoming_type = task.get("download_type", "unknown") if not incoming_url: - # This should ideally be validated before calling add_task - # For now, let it proceed and potentially fail in Celery task if URL is vital and missing. - # Or, create an error task immediately if URL is strictly required for any task logging. logger.warning("Task being added with no URL. Duplicate check might be unreliable.") - # --- Check for Duplicates --- NON_BLOCKING_STATES = [ ProgressState.COMPLETE, ProgressState.CANCELLED, - ProgressState.ERROR + ProgressState.ERROR, + ProgressState.ERROR_RETRIED, + ProgressState.ERROR_AUTO_CLEANED ] - all_existing_tasks_summary = get_all_tasks() - if incoming_url: # Only check for duplicates if we have a URL + 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: @@ -147,97 +149,65 @@ class CeleryDownloadQueueManager: 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) - # Create a new task_id for this duplicate request and mark it as an error - error_task_id = str(uuid.uuid4()) - - # Store minimal info for this error task - 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) - - # Store error status for this new task_id - error_status_payload = { - "status": ProgressState.ERROR, - "error": message, - "existing_task_id": existing_task_id, # So client knows which task it duplicates - "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 - # --- End Duplicate Check --- + 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 - # Proceed with normal task creation if no duplicate found or no URL to check - download_type = task.get("download_type", "unknown") - - # 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 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(), @@ -245,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 8259b9a..d83185e 100644 --- a/routes/utils/celery_tasks.py +++ b/routes/utils/celery_tasks.py @@ -15,6 +15,8 @@ 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 # Initialize Celery app celery_app = Celery('download_tasks', @@ -826,6 +828,22 @@ def task_postrun_handler(task_id=None, task=None, retval=None, state=None, *args "message": "Download completed successfully." }) logger.info(f"Task {task_id} completed successfully: {task_info.get('name', 'Unknown')}") + + # If from playlist_watch and successful, add track to DB + original_request = task_info.get("original_request", {}) + if original_request.get("source") == "playlist_watch": + 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}") + except Exception as e: logger.error(f"Error in task_postrun_handler: {e}") diff --git a/routes/utils/get_info.py b/routes/utils/get_info.py index 6f35bd1..9b8b49c 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 @@ -51,7 +53,14 @@ def get_spotify_info(spotify_id, spotify_type): elif spotify_type == "playlist": return Spo.get_playlist(spotify_id) elif spotify_type == "artist": - return Spo.get_artist(spotify_id) + if limit is not None and offset is not None: + return Spo.get_artist(spotify_id, limit=limit, offset=offset) + elif limit is not None: + return Spo.get_artist(spotify_id, limit=limit) + elif offset is not None: + return Spo.get_artist(spotify_id, offset=offset) + else: + return Spo.get_artist(spotify_id) elif spotify_type == "episode": return Spo.get_episode(spotify_id) else: diff --git a/routes/utils/watch/db.py b/routes/utils/watch/db.py new file mode 100644 index 0000000..d82129e --- /dev/null +++ b/routes/utils/watch/db.py @@ -0,0 +1,703 @@ +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): + """Adds or updates a list of tracks in the specified playlist's tracks table in playlists.db.""" + table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" + if not tracks_data: + return + + current_time = int(time.time()) + tracks_to_insert = [] + for track_item in tracks_data: + track = track_item.get('track') + if not track or not track.get('id'): + logger.warning(f"Skipping track due to missing data or ID in playlist {playlist_spotify_id}: {track_item}") + continue + + # Ensure 'artists' and 'album' -> 'artists' are lists and extract names + 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'), + track_item.get('added_at'), # From playlist item + current_time, # added_to_db + 1, # is_present_in_spotify + current_time # last_seen_in_spotify + )) + + if not tracks_to_insert: + logger.info(f"No valid tracks to insert 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"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 adding tracks to 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..88947d5 --- /dev/null +++ b/routes/utils/watch/manager.py @@ -0,0 +1,415 @@ +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 = { + "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", 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 + add_or_update_album_for_artist(artist_spotify_id, album_data, task_id=task_id_or_none, is_download_complete=False) + logger.info(f"Artist Watch Manager: Queued download task {task_id_or_none} for new album '{album_name}' from artist '{artist_name}'.") + queued_for_download_count += 1 + # If task_id_or_none is None, it was a duplicate. We can still log/record album_data if needed, but without task_id or as already seen. + # add_or_update_album_for_artist(artist_spotify_id, album_data, task_id=None) # This would just log metadata if not a duplicate. + # The current add_task logic in celery_manager might create an error task for duplicates, + # so we might not need to do anything special here for duplicates apart from not incrementing count. + + except Exception as e: + logger.error(f"Artist Watch Manager: Failed to queue/record 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) + + 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 index 07865b4..1d62863 100644 --- a/src/js/album.ts +++ b/src/js/album.ts @@ -254,7 +254,7 @@ function renderAlbum(album: Album) {
${msToTime(track.duration_ms || 0)}
`; - groupSection.innerHTML = ` - ${groupHeaderHTML} -
- `; + groupSection.innerHTML = groupHeaderHTML; + const albumsListContainer = document.createElement('div'); + albumsListContainer.className = 'albums-list'; - const albumsContainer = groupSection.querySelector('.albums-list'); - if (albumsContainer) { - albums.forEach(album => { - if (!album) return; + albums.forEach(album => { + if (!album) return; + const albumElement = document.createElement('div'); + albumElement.className = 'album-card'; + + let albumCardHTML = ` + + Album cover + +
+
${album.name || 'Unknown Album'}
+
${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}
+
+ `; + + const actionsContainer = document.createElement('div'); + actionsContainer.className = 'album-actions-container'; + + if (!isExplicitFilterEnabled) { + const downloadBtnHTML = ` + + `; + actionsContainer.innerHTML += downloadBtnHTML; + } + + if (isArtistWatched) { + // Initial state is set based on album.is_locally_known + const isKnown = album.is_locally_known === true; + 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 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); - }); - } + const toggleKnownBtnHTML = ` + + `; + actionsContainer.innerHTML += toggleKnownBtnHTML; + } + albumElement.innerHTML = albumCardHTML; + if (actionsContainer.hasChildNodes()) { + albumElement.appendChild(actionsContainer); + } + albumsListContainer.appendChild(albumElement); + }); + groupSection.appendChild(albumsListContainer); 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

@@ -288,109 +303,104 @@ function renderArtist(artistData: ArtistData, artistId: string) { Download All Featuring Albums
`; + featuringSection.innerHTML = featuringHeaderHTML; + const appearingAlbumsListContainer = document.createElement('div'); + appearingAlbumsListContainer.className = 'albums-list'; - featuringSection.innerHTML = ` - ${featuringHeaderHTML} -
- `; + appearingAlbums.forEach(album => { + if (!album) return; + const albumElement = document.createElement('div'); + albumElement.className = 'album-card'; + let albumCardHTML = ` + + Album cover + +
+
${album.name || 'Unknown Album'}
+
${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}
+
+ `; + + const actionsContainer = document.createElement('div'); + actionsContainer.className = 'album-actions-container'; - const albumsContainer = featuringSection.querySelector('.albums-list'); - if (albumsContainer) { - appearingAlbums.forEach(album => { - if (!album) return; + if (!isExplicitFilterEnabled) { + const downloadBtnHTML = ` + + `; + actionsContainer.innerHTML += downloadBtnHTML; + } + + if (isArtistWatched) { + // Initial state is set based on album.is_locally_known + const isKnown = album.is_locally_known === true; + 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 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 + const toggleKnownBtnHTML = ` + + `; + actionsContainer.innerHTML += toggleKnownBtnHTML; + } + albumElement.innerHTML = albumCardHTML; + if (actionsContainer.hasChildNodes()) { + albumElement.appendChild(actionsContainer); + } + 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'); - // 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); + attachAlbumActionListeners(artistId); + attachGroupDownloadListeners(artistId, artistName); } } -// Event listeners for group downloads using the artist download function -function attachGroupDownloadListeners(artistUrl: string, artistName: string) { +function attachGroupDownloadListeners(artistId: string, artistName: string) { document.querySelectorAll('.group-download-btn').forEach(btn => { - const button = btn as HTMLButtonElement; // Cast to HTMLButtonElement + const button = btn as HTMLButtonElement; button.addEventListener('click', async (e) => { - const target = e.target as HTMLButtonElement | null; // Cast target + const target = e.target as HTMLButtonElement | null; if (!target) return; - - const groupType = target.dataset.groupType || 'album'; // e.g. "album", "single", "compilation", "appears_on" + const groupType = target.dataset.groupType || 'album'; target.disabled = true; - - // Custom text for the 'appears_on' group const displayType = groupType === 'appears_on' ? 'Featuring Albums' : `${capitalize(groupType)}s`; target.textContent = `Queueing all ${displayType}...`; - try { - // Use our local startDownload function with the group type filter const taskIds = await startDownload( - artistUrl, + artistId, 'artist', { name: artistName || 'Unknown Artist', artist: artistName || 'Unknown Artist' }, - groupType // Only queue releases of this specific type. + groupType ); - - // Optionally show number of albums queued const totalQueued = Array.isArray(taskIds) ? taskIds.length : 0; target.textContent = `Queued all ${displayType}`; target.title = `${totalQueued} albums queued for download`; - - // Make the queue visible after queueing downloadQueue.toggleVisibility(true); - } catch (error: any) { // Add type for error + } catch (error: any) { target.textContent = `Download All ${displayType}`; target.disabled = false; showError(`Failed to queue download for all ${groupType}: ${error?.message || 'Unknown error'}`); @@ -399,41 +409,116 @@ function attachGroupDownloadListeners(artistUrl: string, artistName: string) { }); } -// Individual download handlers remain unchanged. -function attachDownloadListeners() { - document.querySelectorAll('.download-btn:not(.group-download-btn)').forEach(btn => { - const button = btn as HTMLButtonElement; // Cast to HTMLButtonElement +function attachAlbumActionListeners(artistIdForContext: string) { + document.querySelectorAll('.album-download-btn').forEach(btn => { + const button = btn as HTMLButtonElement; button.addEventListener('click', (e) => { e.stopPropagation(); - const currentTarget = e.currentTarget as HTMLButtonElement | null; // Cast currentTarget + const currentTarget = e.currentTarget as HTMLButtonElement | null; if (!currentTarget) return; - - const url = currentTarget.dataset.url || ''; + const itemId = currentTarget.dataset.id || ''; const name = currentTarget.dataset.name || 'Unknown'; - // Always use 'album' type for individual album downloads regardless of category const type = 'album'; - + if (!itemId) { + showError('Could not get album ID for download'); + return; + } currentTarget.remove(); - // Use the centralized downloadQueue.download method - downloadQueue.download(url, type, { name, type }) - .catch((err: any) => showError('Download failed: ' + (err?.message || 'Unknown error'))); // Add type for err + downloadQueue.download(itemId, type, { name, type }) + .catch((err: any) => showError('Download failed: ' + (err?.message || 'Unknown error'))); }); }); + + document.querySelectorAll('.toggle-known-status-btn').forEach((btn) => { + btn.addEventListener('click', async (e: Event) => { + e.stopPropagation(); + const button = e.currentTarget as HTMLButtonElement; + const albumId = button.dataset.id || ''; + const artistId = button.dataset.artistId || artistIdForContext; + const currentStatus = button.dataset.status; + const img = button.querySelector('img'); + + if (!albumId || !artistId || !img) { + showError('Missing data for toggling album status'); + return; + } + + button.disabled = true; + try { + if (currentStatus === 'missing') { + await handleMarkAlbumAsKnown(artistId, albumId); + button.dataset.status = 'known'; + img.src = '/static/images/check.svg'; + button.title = 'Click to mark as missing from DB'; + } else { + await handleMarkAlbumAsMissing(artistId, albumId); + button.dataset.status = 'missing'; + img.src = '/static/images/missing.svg'; + button.title = 'Click to mark as known in DB'; + } + } catch (error) { + showError('Failed to update album status. Please try again.'); + } + button.disabled = false; + }); + }); +} + +async function handleMarkAlbumAsKnown(artistId: string, albumId: string) { + try { + const response = await fetch(`/api/artist/watch/${artistId}/albums`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify([albumId]), + }); + 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 || 'Album marked as known.'); + } catch (error: any) { + showError(`Failed to mark album as known: ${error.message}`); + throw error; // Re-throw for the caller to handle button state if needed + } +} + +async function handleMarkAlbumAsMissing(artistId: string, albumId: string) { + try { + const response = await fetch(`/api/artist/watch/${artistId}/albums`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify([albumId]), + }); + 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 || 'Album marked as missing.'); + } catch (error: any) { + showError(`Failed to mark album as missing: ${error.message}`); + throw error; // Re-throw + } } // Add startDownload function (similar to track.js and main.js) /** * Starts the download process via centralized download queue */ -async function startDownload(url: string, type: string, item: { name: string, artist?: string, type?: string }, albumType?: string) { - if (!url || !type) { - showError('Missing URL or type for download'); - return Promise.reject(new Error('Missing URL or type for download')); // Return a rejected promise +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(url, type, item, albumType); + const result = await downloadQueue.download(itemId, type, item, albumType); // Make the queue visible after queueing downloadQueue.toggleVisibility(true); @@ -458,3 +543,171 @@ function showError(message: string) { 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) { + const watchArtistBtn = document.getElementById('watchArtistBtn') as HTMLButtonElement | null; + const syncArtistBtn = document.getElementById('syncArtistBtn') as HTMLButtonElement | null; + + if (!watchArtistBtn) return; + + try { + watchArtistBtn.disabled = true; // Disable while fetching status + if (syncArtistBtn) syncArtistBtn.disabled = true; // Also disable sync button initially + + const isWatching = await getArtistWatchStatus(artistId); + updateWatchButton(artistId, isWatching); + watchArtistBtn.disabled = false; + if (syncArtistBtn) syncArtistBtn.disabled = !(watchArtistBtn.dataset.watching === 'true'); // Corrected logic + + 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); + } else { + await watchArtist(artistId); + updateWatchButton(artistId, true); + } + } catch (error) { + updateWatchButton(artistId, currentlyWatching); + } + watchArtistBtn.disabled = false; + if (syncArtistBtn) syncArtistBtn.disabled = !(watchArtistBtn.dataset.watching === 'true'); // Corrected logic + }); + + // 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; // Keep sync disabled on error + updateWatchButton(artistId, false); + } +} + +// 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 index 86556b4..09aaf1f 100644 --- a/src/js/config.ts +++ b/src/js/config.ts @@ -124,6 +124,9 @@ async function loadConfig() { // Update explicit filter status updateExplicitFilterStatus(savedConfig.explicitFilter); + + // Load watch config + await loadWatchConfig(); } catch (error: any) { showConfigError('Error loading config: ' + error.message); } @@ -230,6 +233,12 @@ function setupEventListeners() { // 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); } function updateServiceSpecificOptions() { @@ -834,6 +843,9 @@ async function saveConfig() { // Update explicit filter status updateExplicitFilterStatus(savedConfig.explicitFilter); + + // Load watch config + await loadWatchConfig(); } catch (error: any) { showConfigError('Error loading config: ' + error.message); } @@ -921,3 +933,55 @@ function showCopyNotification(message: string) { }, 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'; + + } 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 = { + 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); + } +} diff --git a/src/js/main.ts b/src/js/main.ts index 14340d1..bc14a91 100644 --- a/src/js/main.ts +++ b/src/js/main.ts @@ -318,15 +318,10 @@ document.addEventListener('DOMContentLoaded', function() { if (!item) return; const currentSearchType = searchType?.value || 'track'; - let url; + let itemId = item.id || ''; // Use item.id directly - // 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'); + if (!itemId) { // Check if ID was found + showError('Could not determine download ID'); return; } @@ -374,7 +369,7 @@ document.addEventListener('DOMContentLoaded', function() { } // Start the download - startDownload(url, currentSearchType, metadata, + 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 @@ -398,15 +393,15 @@ document.addEventListener('DOMContentLoaded', function() { /** * Starts the download process via API */ - async function startDownload(url: string, type: string, item: DownloadQueueItem, albumType: string | null | undefined) { - if (!url || !type) { - showError('Missing URL or type for download'); + 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(url, type, item, albumType); + await downloadQueue.download(itemId, type, item, albumType); // Make the queue visible after queueing downloadQueue.toggleVisibility(true); diff --git a/src/js/playlist.ts b/src/js/playlist.ts index 2a80ca7..322a2d1 100644 --- a/src/js/playlist.ts +++ b/src/js/playlist.ts @@ -29,6 +29,7 @@ interface Track { duration_ms: number; explicit: boolean; external_urls?: { spotify?: string }; + is_locally_known?: boolean; // Added for local DB status } interface PlaylistItem { @@ -55,6 +56,11 @@ interface Playlist { 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 @@ -85,6 +91,9 @@ document.addEventListener('DOMContentLoaded', () => { showError('Failed to load playlist.'); }); + // Fetch initial watch status + fetchWatchStatus(playlistId); + const queueIcon = document.getElementById('queueIcon'); if (queueIcon) { queueIcon.addEventListener('click', () => { @@ -206,7 +215,7 @@ function renderPlaylist(playlist: Playlist) { downloadPlaylistBtn.textContent = 'Queued!'; }).catch((err: any) => { showError('Failed to queue playlist download: ' + (err?.message || 'Unknown error')); - downloadPlaylistBtn.disabled = false; + if (downloadPlaylistBtn) downloadPlaylistBtn.disabled = false; // Re-enable on error }); }); } @@ -227,7 +236,7 @@ function renderPlaylist(playlist: Playlist) { }) .catch((err: any) => { showError('Failed to queue album downloads: ' + (err?.message || 'Unknown error')); - if (downloadAlbumsBtn) downloadAlbumsBtn.disabled = false; + if (downloadAlbumsBtn) downloadAlbumsBtn.disabled = false; // Re-enable on error }); }); } @@ -239,6 +248,10 @@ function renderPlaylist(playlist: Playlist) { 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 @@ -263,14 +276,13 @@ function renderPlaylist(playlist: Playlist) { 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 = ` + let trackHTML = `
${index + 1}
@@ -284,14 +296,45 @@ function renderPlaylist(playlist: Playlist) { ${track.album?.name || 'Unknown Album'}
${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); }); } @@ -303,7 +346,7 @@ function renderPlaylist(playlist: Playlist) { if (tracksContainerEl) tracksContainerEl.classList.remove('hidden'); // Attach download listeners to newly rendered download buttons - attachDownloadListeners(); + attachTrackActionListeners(); } /** @@ -329,26 +372,101 @@ function showError(message: string) { } /** - * Attaches event listeners to all individual download buttons. + * Attaches event listeners to all individual track action buttons (download, mark known, mark missing). */ -function attachDownloadListeners() { - document.querySelectorAll('.download-btn').forEach((btn) => { - // Skip the whole playlist and album download buttons. - if (btn.id === 'downloadPlaylistBtn' || btn.id === 'downloadAlbumsBtn') return; +function attachTrackActionListeners() { + document.querySelectorAll('.track-download-btn').forEach((btn) => { btn.addEventListener('click', (e: Event) => { e.stopPropagation(); const currentTarget = e.currentTarget as HTMLButtonElement; - const url = currentTarget.dataset.url || ''; - const type = currentTarget.dataset.type || ''; - const name = currentTarget.dataset.name || extractName(url) || 'Unknown'; - // Remove the button immediately after click. + 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(); - // For individual track downloads, we might not have album/artist name readily here. - // The queue.ts download method should be robust enough or we might need to fetch more data. - // For now, pass what we have. - startDownload(url, type, { name }, ''); // Pass name, artist/album are optional in DownloadQueueItem + 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 + } } /** @@ -359,14 +477,14 @@ async function downloadWholePlaylist(playlist: Playlist) { throw new Error('Invalid playlist data'); } - const url = playlist.external_urls?.spotify || ''; - if (!url) { - throw new Error('Missing playlist URL'); + const playlistId = playlist.id || ''; + if (!playlistId) { + throw new Error('Missing playlist ID'); } try { // Use the centralized downloadQueue.download method - await downloadQueue.download(url, 'playlist', { + 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 @@ -426,7 +544,7 @@ async function downloadPlaylistAlbums(playlist: Playlist) { // Use the centralized downloadQueue.download method await downloadQueue.download( - albumUrl, + album.id, // Pass album ID directly 'album', { name: album.name || 'Unknown Album', @@ -460,15 +578,15 @@ async function downloadPlaylistAlbums(playlist: Playlist) { /** * Starts the download process using the centralized download method from the queue. */ -async function startDownload(url: string, type: string, item: DownloadQueueItem, albumType?: string) { - if (!url || !type) { - showError('Missing URL or type for download'); +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(url, type, item, albumType); + await downloadQueue.download(itemId, type, item, albumType); // Make the queue visible after queueing downloadQueue.toggleVisibility(true); @@ -484,3 +602,136 @@ async function startDownload(url: string, type: string, item: DownloadQueueItem, 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); + showNotification(`Playlist added to watchlist. It will be synced shortly.`); + } catch (error: any) { + showError(`Error watching playlist: ${error.message}`); + if (watchBtn) watchBtn.disabled = false; + } +} + +/** + * 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); + showNotification('Playlist removed from watchlist.'); + } catch (error: any) { + showError(`Error unwatching playlist: ${error.message}`); + if (watchBtn) watchBtn.disabled = false; + } +} + +/** + * 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/src/js/queue.ts b/src/js/queue.ts index 828124e..5fcb546 100644 --- a/src/js/queue.ts +++ b/src/js/queue.ts @@ -368,6 +368,11 @@ export class DownloadQueue { 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: string) { @@ -913,6 +918,15 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) // 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) { @@ -1469,21 +1483,34 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) * This method replaces the individual startTrackDownload, startAlbumDownload, etc. methods. * It will be called by all the other JS files. */ - async download(url: string, type: string, item: QueueItem, albumType: string | null = null): Promise { // Add types and return type - 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(); + // Add item.name and item.artist only if they are not empty or undefined + if (item.name && item.name.trim() !== '') queryParams.append('name', item.name); + if (item.artist && item.artist.trim() !== '') queryParams.append('artist', item.artist); // 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 const queueIcon = document.getElementById('queueIcon'); // No direct classList manipulation diff --git a/src/js/track.ts b/src/js/track.ts index 044b3dc..2a5c9fa 100644 --- a/src/js/track.ts +++ b/src/js/track.ts @@ -150,9 +150,16 @@ function renderTrack(track: any) { 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 @@ -196,15 +203,15 @@ function showError(message: string) { /** * Starts the download process by calling the centralized downloadQueue method */ -async function startDownload(url: string, type: string, item: any) { - 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); diff --git a/static/css/artist/artist.css b/static/css/artist/artist.css index 97887e7..3c829d9 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,52 @@ a:focus { color: #1db954; text-decoration: underline; } + +/* Toggle Known Status Button for Tracks/Albums */ +.toggle-known-status-btn { + width: 32px; + height: 32px; + padding: 0; + border-radius: 50%; + border: none; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background-color 0.2s ease, transform 0.2s ease; + margin-left: 0.5rem; /* Spacing from other buttons if any */ +} + +.toggle-known-status-btn img { + width: 18px; /* Adjust icon size as needed */ + height: 18px; + filter: brightness(0) invert(1); /* White icon */ +} + +.toggle-known-status-btn[data-status="known"] { + background-color: #28a745; /* Green for known/available */ +} + +.toggle-known-status-btn[data-status="known"]:hover { + background-color: #218838; /* Darker green on hover */ +} + +.toggle-known-status-btn[data-status="missing"] { + background-color: #dc3545; /* Red for missing */ +} + +.toggle-known-status-btn[data-status="missing"]:hover { + background-color: #c82333; /* Darker red on hover */ +} + +.toggle-known-status-btn:active { + transform: scale(0.95); +} + +.album-actions-container { + display: flex; + align-items: center; + /* If you want buttons at the bottom of the card or specific positioning, adjust here */ + /* For now, they will flow naturally. Adding padding if needed. */ + padding-top: 0.5rem; +} diff --git a/static/css/config/config.css b/static/css/config/config.css index 81b1599..1f9d01f 100644 --- a/static/css/config/config.css +++ b/static/css/config/config.css @@ -800,7 +800,7 @@ input:checked + .slider:before { width: 28px; height: 28px; } - +} /* Format help styles */ .format-help { @@ -935,4 +935,54 @@ input:checked + .slider:before { 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; } \ No newline at end of file diff --git a/static/css/playlist/playlist.css b/static/css/playlist/playlist.css index c684386..3b04807 100644 --- a/static/css/playlist/playlist.css +++ b/static/css/playlist/playlist.css @@ -185,6 +185,14 @@ body { margin: 0.5rem; } +/* Style for icons within download buttons */ +.download-btn img { + margin-right: 0.5rem; /* Space between icon and text */ + width: 20px; /* Icon width */ + height: 20px; /* Icon height */ + vertical-align: middle; /* Align icon with text */ +} + .download-btn:hover { background-color: #17a44b; } @@ -459,3 +467,95 @@ a:focus { margin-bottom: 1rem; } } + +/* Notification Styling */ +.notification { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background-color: #333; + color: #fff; + padding: 10px 20px; + border-radius: 5px; + box-shadow: 0 2px 10px rgba(0,0,0,0.2); + z-index: 1005; /* Ensure it's above most other elements */ + opacity: 0; + transition: opacity 0.5s ease-in-out; + animation: fadeInOut 3s ease-in-out; +} + +@keyframes fadeInOut { + 0% { opacity: 0; } + 10% { opacity: 1; } + 90% { opacity: 1; } + 100% { opacity: 0; } +} + +/* Watch and Sync Button Specific Styles */ +.watch-btn { + background-color: #535353; /* A neutral dark gray */ +} + +.watch-btn:hover { + background-color: #6f6f6f; +} + +.sync-btn { + background-color: #28a745; /* A distinct green for sync */ +} + +.sync-btn:hover { + background-color: #218838; +} + +.sync-btn.hidden { + display: none; +} + +/* Toggle Known Status Button for Tracks/Albums */ +.toggle-known-status-btn { + width: 32px; + height: 32px; + padding: 0; + border-radius: 50%; + border: none; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background-color 0.2s ease, transform 0.2s ease; + margin-left: 0.5rem; /* Spacing from other buttons if any */ +} + +.toggle-known-status-btn img { + width: 18px; /* Adjust icon size as needed */ + height: 18px; + filter: brightness(0) invert(1); /* White icon */ +} + +.toggle-known-status-btn[data-status="known"] { + background-color: #28a745; /* Green for known/available */ +} + +.toggle-known-status-btn[data-status="known"]:hover { + background-color: #218838; /* Darker green on hover */ +} + +.toggle-known-status-btn[data-status="missing"] { + background-color: #dc3545; /* Red for missing */ +} + +.toggle-known-status-btn[data-status="missing"]:hover { + background-color: #c82333; /* Darker red on hover */ +} + +.toggle-known-status-btn:active { + transform: scale(0.95); +} + +.track-actions-container { + display: flex; + align-items: center; + margin-left: auto; /* Pushes action buttons to the right */ +} diff --git a/static/html/artist.html b/static/html/artist.html index 969edc8..c0d0514 100644 --- a/static/html/artist.html +++ b/static/html/artist.html @@ -28,6 +28,11 @@ Download Download All Discography + +
diff --git a/static/html/config.html b/static/html/config.html index 42d65db..46c9801 100644 --- a/static/html/config.html +++ b/static/html/config.html @@ -226,6 +226,41 @@ +
+

Watch Options

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ Select which album groups to monitor on watched artist pages. +
+
+
+ + +
+ How often to check watched items for updates (e.g., new playlist tracks, new artist albums). +
+
+
+
diff --git a/static/html/playlist.html b/static/html/playlist.html index d705ff7..7499aa8 100644 --- a/static/html/playlist.html +++ b/static/html/playlist.html @@ -33,6 +33,14 @@ Albums Download Playlist's Albums + +
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/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/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/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