2.0 is coming

This commit is contained in:
cool.gitter.not.me.again.duh
2025-05-28 23:32:45 -06:00
parent ee261c28f4
commit 5f3e78e5f4
31 changed files with 2897 additions and 366 deletions

2
.env
View File

@@ -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

8
app.py
View File

@@ -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',
@@ -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()

View File

@@ -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

View File

@@ -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/<album_id>', 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(

View File

@@ -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/<artist_id>', 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/<string:artist_spotify_id>', 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/<string:artist_spotify_id>/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/<string:artist_spotify_id>', 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/<string:artist_spotify_id>', 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/<string:artist_spotify_id>/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/<string:artist_spotify_id>/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

View File

@@ -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()
@@ -149,3 +183,38 @@ def check_config_changes():
"changed": has_changed,
"last_config": last_config
})
@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

View File

@@ -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/<playlist_id>', 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/<string:playlist_spotify_id>', 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/<string:playlist_spotify_id>/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/<string:playlist_spotify_id>', 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/<string:playlist_spotify_id>/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/<string:playlist_spotify_id>/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/<string:playlist_spotify_id>', 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

View File

@@ -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/<track_id>', 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:

View File

@@ -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
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,10 +149,12 @@ 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)
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())
# Store minimal info for this error task
error_task_info_payload = {
"download_type": incoming_type,
"type": task.get("type", incoming_type),
@@ -162,82 +166,48 @@ class CeleryDownloadQueueManager:
"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
"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
# --- End Duplicate Check ---
# 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()
}
# Store the task info in Redis for later retrieval
store_task_info(task_id, complete_task)
# 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 initial queued status
store_task_info(task_id, complete_task)
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")
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,

View File

@@ -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}")

View File

@@ -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,6 +53,13 @@ def get_spotify_info(spotify_id, spotify_type):
elif spotify_type == "playlist":
return Spo.get_playlist(spotify_id)
elif spotify_type == "artist":
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)

703
routes/utils/watch/db.py Normal file
View File

@@ -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

View File

@@ -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.

View File

@@ -254,7 +254,7 @@ function renderAlbum(album: Album) {
</div>
<div class="track-duration">${msToTime(track.duration_ms || 0)}</div>
<button class="download-btn download-btn--circle"
data-url="${track.external_urls?.spotify || ''}"
data-id="${track.id || ''}"
data-type="track"
data-name="${track.name || 'Unknown Track'}"
title="Download">
@@ -300,14 +300,14 @@ function renderAlbum(album: Album) {
}
async function downloadWholeAlbum(album: Album) {
const url = album.external_urls?.spotify || '';
if (!url) {
throw new Error('Missing album URL');
const albumIdToDownload = album.id || '';
if (!albumIdToDownload) {
throw new Error('Missing album ID');
}
try {
// Use the centralized downloadQueue.download method
await downloadQueue.download(url, 'album', { name: album.name || 'Unknown Album' });
await downloadQueue.download(albumIdToDownload, 'album', { name: album.name || 'Unknown Album' });
// Make the queue visible after queueing
downloadQueue.toggleVisibility(true);
} catch (error: any) { // Add type for error
@@ -339,25 +339,30 @@ function attachDownloadListeners() {
const currentTarget = e.currentTarget as HTMLButtonElement | null; // Cast currentTarget
if (!currentTarget) return;
const url = currentTarget.dataset.url || '';
const itemId = currentTarget.dataset.id || '';
const type = currentTarget.dataset.type || '';
const name = currentTarget.dataset.name || extractName(url) || 'Unknown';
const name = currentTarget.dataset.name || 'Unknown';
if (!itemId) {
showError('Missing item ID for download in album page');
return;
}
// Remove the button immediately after click.
currentTarget.remove();
startDownload(url, type, { name }); // albumType will be undefined
startDownload(itemId, type, { name }); // albumType will be undefined
});
});
}
async function startDownload(url: string, type: string, item: { name: string }, albumType?: string) { // Add types and make albumType optional
if (!url) {
showError('Missing URL for download');
return Promise.reject(new Error('Missing URL for download')); // Return a rejected promise
async function startDownload(itemId: string, type: string, item: { name: string }, albumType?: string) { // Add types and make albumType optional
if (!itemId || !type) {
showError('Missing ID or type for download');
return Promise.reject(new Error('Missing ID or type for download')); // Return a rejected promise
}
try {
// Use the centralized downloadQueue.download method
await downloadQueue.download(url, type, item, albumType);
await downloadQueue.download(itemId, type, item, albumType);
// Make the queue visible after queueing
downloadQueue.toggleVisibility(true);
@@ -366,7 +371,3 @@ async function startDownload(url: string, type: string, item: { name: string },
throw error;
}
}
function extractName(url: string | null | undefined): string { // Add type
return url || 'Unknown';
}

View File

@@ -29,12 +29,21 @@ interface Album {
explicit?: boolean; // Added to handle explicit filter
total_tracks?: number;
release_date?: string;
is_locally_known?: boolean; // Added for local DB status
}
interface ArtistData {
items: Album[];
total: number;
// Add other properties if available from the API
// For watch status, the artist object itself might have `is_watched` if we extend API
// For now, we fetch status separately.
}
// Interface for watch status response
interface WatchStatusResponse {
is_watched: boolean;
artist_data?: any; // The artist data from DB if watched
}
document.addEventListener('DOMContentLoaded', () => {
@@ -62,6 +71,9 @@ document.addEventListener('DOMContentLoaded', () => {
if (queueIcon) {
queueIcon.addEventListener('click', () => downloadQueue.toggleVisibility());
}
// Initialize the watch button after main artist rendering
// This is done inside renderArtist after button element is potentially created.
});
function renderArtist(artistData: ArtistData, artistId: string) {
@@ -92,8 +104,16 @@ function renderArtist(artistData: ArtistData, artistId: string) {
artistImageEl.src = artistImageSrc;
}
// Initialize Watch Button after other elements are rendered
const watchArtistBtn = document.getElementById('watchArtistBtn') as HTMLButtonElement | null;
if (watchArtistBtn) {
initializeWatchButton(artistId);
} else {
console.warn("Watch artist button not found in HTML.");
}
// Define the artist URL (used by both full-discography and group downloads)
const artistUrl = `https://open.spotify.com/artist/${artistId}`;
// const artistUrl = `https://open.spotify.com/artist/${artistId}`; // Not directly used here anymore
// Home Button
let homeButton = document.getElementById('homeButton') as HTMLButtonElement | null;
@@ -123,26 +143,20 @@ function renderArtist(artistData: ArtistData, artistId: string) {
// When explicit filter is enabled, disable all download buttons
if (isExplicitFilterEnabled) {
if (downloadArtistBtn) {
// Disable the artist download button and display a message explaining why
downloadArtistBtn.disabled = true;
downloadArtistBtn.classList.add('download-btn--disabled');
downloadArtistBtn.innerHTML = `<span title="Direct artist downloads are restricted when explicit filter is enabled. Please visit individual album pages.">Downloads Restricted</span>`;
}
} else {
// Normal behavior when explicit filter is not enabled
if (downloadArtistBtn) {
downloadArtistBtn.addEventListener('click', () => {
// Optionally remove other download buttons from individual albums.
document.querySelectorAll('.download-btn:not(#downloadArtistBtn)').forEach(btn => btn.remove());
if (downloadArtistBtn) {
downloadArtistBtn.disabled = true;
downloadArtistBtn.textContent = 'Queueing...';
}
// Queue the entire discography (albums, singles, compilations, and appears_on)
// Use our local startDownload function instead of downloadQueue.startArtistDownload
startDownload(
artistUrl,
artistId,
'artist',
{ name: artistName, artist: artistName },
'album,single,compilation,appears_on'
@@ -150,10 +164,7 @@ function renderArtist(artistData: ArtistData, artistId: string) {
.then((taskIds) => {
if (downloadArtistBtn) {
downloadArtistBtn.textContent = 'Artist queued';
// Make the queue visible after queueing
downloadQueue.toggleVisibility(true);
// Optionally show number of albums queued
if (Array.isArray(taskIds)) {
downloadArtistBtn.title = `${taskIds.length} albums queued for download`;
}
@@ -170,40 +181,34 @@ function renderArtist(artistData: ArtistData, artistId: string) {
}
}
// Group albums by type (album, single, compilation, etc.) and separate "appears_on" albums
const albumGroups: Record<string, Album[]> = {};
const appearingAlbums: Album[] = [];
(artistData.items || []).forEach(album => {
if (!album) return;
// Skip explicit albums if filter is enabled
if (isExplicitFilterEnabled && album.explicit) {
return;
}
// Check if this is an "appears_on" album
if (album.album_group === 'appears_on') {
appearingAlbums.push(album);
} else {
// Group by album_type for the artist's own releases
const type = (album.album_type || 'unknown').toLowerCase();
if (!albumGroups[type]) albumGroups[type] = [];
albumGroups[type].push(album);
}
});
// Render album groups
const groupsContainer = document.getElementById('album-groups');
if (groupsContainer) {
groupsContainer.innerHTML = '';
// Render regular album groups first
// Determine if the artist is being watched to show/hide management buttons for albums
const isArtistWatched = watchArtistBtn && watchArtistBtn.dataset.watching === 'true';
for (const [groupType, albums] of Object.entries(albumGroups)) {
const groupSection = document.createElement('section');
groupSection.className = 'album-group';
// If explicit filter is enabled, don't show the group download button
const groupHeaderHTML = isExplicitFilterEnabled ?
`<div class="album-group-header">
<h3>${capitalize(groupType)}s</h3>
@@ -217,22 +222,16 @@ function renderArtist(artistData: ArtistData, artistId: string) {
</button>
</div>`;
groupSection.innerHTML = `
${groupHeaderHTML}
<div class="albums-list"></div>
`;
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;
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 = `
let albumCardHTML = `
<a href="/album/${album.id || ''}" class="album-link">
<img src="${album.images?.[1]?.url || album.images?.[0]?.url || '/static/images/placeholder.jpg'}"
alt="Album cover"
@@ -243,39 +242,55 @@ function renderArtist(artistData: ArtistData, artistId: string) {
<div class="album-artist">${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}</div>
</div>
`;
} else {
albumElement.innerHTML = `
<a href="/album/${album.id || ''}" class="album-link">
<img src="${album.images?.[1]?.url || album.images?.[0]?.url || '/static/images/placeholder.jpg'}"
alt="Album cover"
class="album-cover">
</a>
<div class="album-info">
<div class="album-title">${album.name || 'Unknown Album'}</div>
<div class="album-artist">${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}</div>
</div>
<button class="download-btn download-btn--circle"
data-url="${album.external_urls?.spotify || ''}"
const actionsContainer = document.createElement('div');
actionsContainer.className = 'album-actions-container';
if (!isExplicitFilterEnabled) {
const downloadBtnHTML = `
<button class="download-btn download-btn--circle album-download-btn"
data-id="${album.id || ''}"
data-type="${album.album_type || 'album'}"
data-name="${album.name || 'Unknown Album'}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
</button>
`;
actionsContainer.innerHTML += downloadBtnHTML;
}
albumsContainer.appendChild(albumElement);
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 toggleKnownBtnHTML = `
<button class="action-btn toggle-known-status-btn"
data-id="${album.id || ''}"
data-artist-id="${artistId}"
data-status="${initialStatus}"
title="${initialTitle}">
<img src="${initialIcon}" alt="Mark as Missing/Known">
</button>
`;
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 ?
`<div class="album-group-header">
<h3>Featuring</h3>
@@ -288,23 +303,15 @@ function renderArtist(artistData: ArtistData, artistId: string) {
Download All Featuring Albums
</button>
</div>`;
featuringSection.innerHTML = featuringHeaderHTML;
const appearingAlbumsListContainer = document.createElement('div');
appearingAlbumsListContainer.className = 'albums-list';
featuringSection.innerHTML = `
${featuringHeaderHTML}
<div class="albums-list"></div>
`;
const albumsContainer = featuringSection.querySelector('.albums-list');
if (albumsContainer) {
appearingAlbums.forEach(album => {
if (!album) return;
const albumElement = document.createElement('div');
albumElement.className = 'album-card';
// Create album card with or without download button based on explicit filter setting
if (isExplicitFilterEnabled) {
albumElement.innerHTML = `
let albumCardHTML = `
<a href="/album/${album.id || ''}" class="album-link">
<img src="${album.images?.[1]?.url || album.images?.[0]?.url || '/static/images/placeholder.jpg'}"
alt="Album cover"
@@ -315,82 +322,85 @@ function renderArtist(artistData: ArtistData, artistId: string) {
<div class="album-artist">${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}</div>
</div>
`;
} else {
albumElement.innerHTML = `
<a href="/album/${album.id || ''}" class="album-link">
<img src="${album.images?.[1]?.url || album.images?.[0]?.url || '/static/images/placeholder.jpg'}"
alt="Album cover"
class="album-cover">
</a>
<div class="album-info">
<div class="album-title">${album.name || 'Unknown Album'}</div>
<div class="album-artist">${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}</div>
</div>
<button class="download-btn download-btn--circle"
data-url="${album.external_urls?.spotify || ''}"
const actionsContainer = document.createElement('div');
actionsContainer.className = 'album-actions-container';
if (!isExplicitFilterEnabled) {
const downloadBtnHTML = `
<button class="download-btn download-btn--circle album-download-btn"
data-id="${album.id || ''}"
data-type="${album.album_type || 'album'}"
data-name="${album.name || 'Unknown Album'}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
</button>
`;
actionsContainer.innerHTML += downloadBtnHTML;
}
albumsContainer.appendChild(albumElement);
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 toggleKnownBtnHTML = `
<button class="action-btn toggle-known-status-btn"
data-id="${album.id || ''}"
data-artist-id="${artistId}"
data-status="${initialStatus}"
title="${initialTitle}">
<img src="${initialIcon}" alt="Mark as Missing/Known">
</button>
`;
actionsContainer.innerHTML += toggleKnownBtnHTML;
}
albumElement.innerHTML = albumCardHTML;
if (actionsContainer.hasChildNodes()) {
albumElement.appendChild(actionsContainer);
}
appearingAlbumsListContainer.appendChild(albumElement);
});
}
// Add to the end so it appears at the bottom
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<boolean> {
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<void> {
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<void> {
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 = `<img src="/static/images/eye-crossed.svg" alt="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 = `<img src="/static/images/eye.svg" alt="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 = `<img src="/static/images/refresh.svg" alt="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<void> {
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);
}

View File

@@ -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<HTMLInputElement>;
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<HTMLInputElement>;
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);
}
}

View File

@@ -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);

View File

@@ -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 = `
<div class="track-number">${index + 1}</div>
<div class="track-info">
<div class="track-name">
@@ -284,14 +296,45 @@ function renderPlaylist(playlist: Playlist) {
<a href="${albumLink}" title="View album details">${track.album?.name || 'Unknown Album'}</a>
</div>
<div class="track-duration">${msToTime(track.duration_ms || 0)}</div>
<button class="download-btn download-btn--circle"
data-url="${track.external_urls?.spotify || ''}"
`;
const actionsContainer = document.createElement('div');
actionsContainer.className = 'track-actions-container';
if (!(isExplicitFilterEnabled && hasExplicitTrack)) {
const downloadBtnHTML = `
<button class="download-btn download-btn--circle track-download-btn"
data-id="${track.id || ''}"
data-type="track"
data-name="${track.name || 'Unknown Track'}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
</button>
`;
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 = `
<button class="action-btn toggle-known-status-btn"
data-id="${track.id || ''}"
data-playlist-id="${playlist.id || ''}"
data-status="${initialStatus}"
title="${initialTitle}">
<img src="${initialIcon}" alt="Mark as Missing/Known">
</button>
`;
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 = `<img src="/static/images/eye-crossed.svg" alt="Unwatch"> Unwatch Playlist`;
watchBtn.classList.add('watching');
watchBtn.onclick = () => unwatchPlaylist(playlistId);
syncBtn.classList.remove('hidden');
syncBtn.onclick = () => syncPlaylist(playlistId);
} else {
watchBtn.innerHTML = `<img src="/static/images/eye.svg" alt="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 = `<img src="/static/images/refresh.svg" alt="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);
}

View File

@@ -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<string | string[] | StatusData> { // 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<string | string[] | StatusData> { // Add types and return type
if (!itemId) {
throw new Error('Missing ID for download');
}
await this.loadConfig();
// Build the API URL with only the URL parameter as it's all that's needed
let apiUrl = `/api/${type}/download?url=${encodeURIComponent(url)}`;
// Construct the API URL in the new format /api/{type}/download/{itemId}
let apiUrl = `/api/${type}/download/${itemId}`;
// Prepare query parameters
const queryParams = new URLSearchParams();
// 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

View File

@@ -150,9 +150,16 @@ function renderTrack(track: any) {
downloadBtn.innerHTML = `<img src="/static/images/download.svg" alt="Download">`;
return;
}
const trackIdToDownload = track.id || '';
if (!trackIdToDownload) {
showError('Missing track ID for download');
downloadBtn.disabled = false;
downloadBtn.innerHTML = `<img src="/static/images/download.svg" alt="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 = `<span>Queued!</span>`;
// 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);

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 */
}

View File

@@ -28,6 +28,11 @@
<img src="{{ url_for('static', filename='images/download.svg') }}" alt="Download">
Download All Discography
</button>
<button id="watchArtistBtn" class="watch-btn btn-secondary"> <img src="{{ url_for('static', filename='images/eye.svg') }}" alt="Watch"> Watch Artist </button>
<button id="syncArtistBtn" class="download-btn sync-btn hidden">
<img src="{{ url_for('static', filename='images/refresh.svg') }}" alt="Sync">
Sync Watched Artist
</button>
</div>
</div>
</div>

View File

@@ -226,6 +226,41 @@
</div>
</div>
<div class="watch-options-config card">
<h2 class="section-title">Watch Options</h2>
<div class="config-item">
<label for="watchedArtistAlbumGroup">Artist Page - Album Groups to Watch:</label>
<div id="watchedArtistAlbumGroupChecklist" class="checklist-container">
<div class="checklist-item">
<input type="checkbox" id="albumGroup-album" name="watchedArtistAlbumGroup" value="album">
<label for="albumGroup-album">Album</label>
</div>
<div class="checklist-item">
<input type="checkbox" id="albumGroup-single" name="watchedArtistAlbumGroup" value="single">
<label for="albumGroup-single">Single</label>
</div>
<div class="checklist-item">
<input type="checkbox" id="albumGroup-compilation" name="watchedArtistAlbumGroup" value="compilation">
<label for="albumGroup-compilation">Compilation</label>
</div>
<div class="checklist-item">
<input type="checkbox" id="albumGroup-appears_on" name="watchedArtistAlbumGroup" value="appears_on">
<label for="albumGroup-appears_on">Appears On</label>
</div>
</div>
<div class="setting-description">
Select which album groups to monitor on watched artist pages.
</div>
</div>
<div class="config-item">
<label for="watchPollIntervalSeconds">Watch Poll Interval (seconds):</label>
<input type="number" id="watchPollIntervalSeconds" min="60" value="3600" class="form-input">
<div class="setting-description">
How often to check watched items for updates (e.g., new playlist tracks, new artist albums).
</div>
</div>
</div>
<div class="accounts-section">
<div class="service-tabs">
<button class="tab-button active" data-service="spotify">Spotify</button>

View File

@@ -33,6 +33,14 @@
<img src="{{ url_for('static', filename='images/album.svg') }}" alt="Albums">
Download Playlist's Albums
</button>
<button id="watchPlaylistBtn" class="download-btn watch-btn">
<img src="{{ url_for('static', filename='images/eye.svg') }}" alt="Watch">
Watch Playlist
</button>
<button id="syncPlaylistBtn" class="download-btn sync-btn hidden">
<img src="{{ url_for('static', filename='images/refresh.svg') }}" alt="Sync">
Sync Watched Playlist
</button>
</div>
</div>
</div>

4
static/images/check.svg Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 16C3.58172 16 0 12.4183 0 8C0 3.58172 3.58172 0 8 0C12.4183 0 16 3.58172 16 8C16 12.4183 12.4183 16 8 16ZM11.7069 6.70739C12.0975 6.31703 12.0978 5.68386 11.7074 5.29318C11.3171 4.9025 10.6839 4.90224 10.2932 5.29261L6.99765 8.58551L5.70767 7.29346C5.31746 6.90262 4.6843 6.90212 4.29346 7.29233C3.90262 7.68254 3.90212 8.3157 4.29233 8.70654L6.28912 10.7065C6.47655 10.8943 6.7309 10.9998 6.99619 11C7.26147 11.0002 7.51595 10.8949 7.70361 10.7074L11.7069 6.70739Z" fill="#212121"/>
</svg>

After

Width:  |  Height:  |  Size: 723 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 16H13L10.8368 13.3376C9.96488 13.7682 8.99592 14 8 14C6.09909 14 4.29638 13.1557 3.07945 11.6953L0 8L3.07945 4.30466C3.14989 4.22013 3.22229 4.13767 3.29656 4.05731L0 0H3L16 16ZM5.35254 6.58774C5.12755 7.00862 5 7.48941 5 8C5 9.65685 6.34315 11 8 11C8.29178 11 8.57383 10.9583 8.84053 10.8807L5.35254 6.58774Z" fill="#000000"/>
<path d="M16 8L14.2278 10.1266L7.63351 2.01048C7.75518 2.00351 7.87739 2 8 2C9.90091 2 11.7036 2.84434 12.9206 4.30466L16 8Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 755 B

4
static/images/eye.svg Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 8L3.07945 4.30466C4.29638 2.84434 6.09909 2 8 2C9.90091 2 11.7036 2.84434 12.9206 4.30466L16 8L12.9206 11.6953C11.7036 13.1557 9.90091 14 8 14C6.09909 14 4.29638 13.1557 3.07945 11.6953L0 8ZM8 11C9.65685 11 11 9.65685 11 8C11 6.34315 9.65685 5 8 5C6.34315 5 5 6.34315 5 8C5 9.65685 6.34315 11 8 11Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 599 B

12
static/images/missing.svg Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="800px" height="800px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<title>ic_fluent_missing_metadata_24_filled</title>
<desc>Created with Sketch.</desc>
<g id="🔍-System-Icons" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="ic_fluent_missing_metadata_24_filled" fill="#212121" fill-rule="nonzero">
<path d="M17.5,12 C20.5376,12 23,14.4624 23,17.5 C23,20.5376 20.5376,23 17.5,23 C14.4624,23 12,20.5376 12,17.5 C12,14.4624 14.4624,12 17.5,12 Z M19.7501,2 C20.9927,2 22.0001,3.00736 22.0001,4.25 L22.0001,9.71196 C22.0001,10.50198 21.7124729,11.2623046 21.1951419,11.8530093 L21.0222,12.0361 C20.0073,11.3805 18.7981,11 17.5,11 C13.9101,11 11,13.9101 11,17.5 C11,18.7703 11.3644,19.9554 11.9943,20.9567 C10.7373,21.7569 9.05064,21.6098 7.95104,20.5143 L3.48934,16.0592 C2.21887,14.7913 2.21724,12.7334 3.48556,11.4632 L11.9852,2.95334 C12.5948,2.34297 13.4221,2 14.2847,2 L19.7501,2 Z M17.5,19.88 C17.1551,19.88 16.8755,20.1596 16.8755,20.5045 C16.8755,20.8494 17.1551,21.129 17.5,21.129 C17.8449,21.129 18.1245,20.8494 18.1245,20.5045 C18.1245,20.1596 17.8449,19.88 17.5,19.88 Z M17.5,14.0031 C16.4521,14.0031 15.6357,14.8205 15.6467,15.9574 C15.6493,16.2335 15.8753,16.4552 16.1514,16.4526 C16.4276,16.4499 16.6493,16.2239 16.6465901,15.9478 C16.6411,15.3688 17.0063,15.0031 17.5,15.0031 C17.9724,15.0031 18.3534,15.395 18.3534,15.9526 C18.3534,16.1448571 18.298151,16.2948694 18.1295283,16.5141003 L18.0355,16.63 L17.9365,16.7432 L17.6711,17.0333 C17.1868,17.5749 17,17.9255 17,18.5006 C17,18.7767 17.2239,19.0006 17.5,19.0006 C17.7762,19.0006 18,18.7767 18,18.5006 C18,18.297425 18.0585703,18.1416422 18.2388846,17.9103879 L18.3238,17.8063 L18.4247,17.6908 L18.6905,17.4003 C19.1682,16.866 19.3534,16.5186 19.3534,15.9526 C19.3534,14.8489 18.5311,14.0031 17.5,14.0031 Z M17,5.50218 C16.1716,5.50218 15.5001,6.17374 15.5001,7.00216 C15.5001,7.83057 16.1716,8.50213 17,8.50213 C17.8284,8.50213 18.5,7.83057 18.5,7.00216 C18.5,6.17374 17.8284,5.50218 17,5.50218 Z" id="🎨-Color">
</path>
</g>
</g>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 21C16.9706 21 21 16.9706 21 12C21 9.69494 20.1334 7.59227 18.7083 6L16 3M12 3C7.02944 3 3 7.02944 3 12C3 14.3051 3.86656 16.4077 5.29168 18L8 21M21 3H16M16 3V8M3 21H8M8 21V16" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 502 B