2.0 is coming
This commit is contained in:
2
.env
2
.env
@@ -1,7 +1,7 @@
|
||||
# Docker Compose environment variables
|
||||
|
||||
# Redis connection (external or internal)
|
||||
REDIS_HOST=redis
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_DB=0
|
||||
REDIS_PASSWORD=CHANGE_ME
|
||||
|
||||
8
app.py
8
app.py
@@ -38,6 +38,10 @@ def setup_logging():
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(logging.INFO)
|
||||
|
||||
# Clear any existing handlers from the root logger
|
||||
if root_logger.hasHandlers():
|
||||
root_logger.handlers.clear()
|
||||
|
||||
# Log formatting
|
||||
log_format = logging.Formatter(
|
||||
'%(asctime)s [%(processName)s:%(threadName)s] [%(name)s] [%(levelname)s] - %(message)s',
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
227
routes/artist.py
227
routes/artist.py
@@ -3,32 +3,53 @@
|
||||
Artist endpoint blueprint.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, Response, request
|
||||
from flask import Blueprint, Response, request, jsonify
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
from routes.utils.celery_queue_manager import download_queue_manager
|
||||
from routes.utils.artist import download_artist_albums
|
||||
|
||||
artist_bp = Blueprint('artist', __name__)
|
||||
# Imports for merged watch functionality
|
||||
import logging
|
||||
import threading
|
||||
from routes.utils.watch.db import (
|
||||
add_artist_to_watch as add_artist_db,
|
||||
remove_artist_from_watch as remove_artist_db,
|
||||
get_watched_artist,
|
||||
get_watched_artists,
|
||||
add_specific_albums_to_artist_table,
|
||||
remove_specific_albums_from_artist_table,
|
||||
is_album_in_artist_db
|
||||
)
|
||||
from routes.utils.watch.manager import check_watched_artists
|
||||
from routes.utils.get_info import get_spotify_info
|
||||
|
||||
artist_bp = Blueprint('artist', __name__, url_prefix='/api/artist')
|
||||
|
||||
# Existing log_json can be used, or a logger instance.
|
||||
# Let's initialize a logger for consistency with merged code.
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def log_json(message_dict):
|
||||
print(json.dumps(message_dict))
|
||||
|
||||
|
||||
@artist_bp.route('/download', methods=['GET'])
|
||||
def handle_artist_download():
|
||||
@artist_bp.route('/download/<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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,97 +149,65 @@ class CeleryDownloadQueueManager:
|
||||
message = f"Duplicate download: URL '{incoming_url}' (type: {incoming_type}) is already being processed by task {existing_task_id} (status: {existing_status})."
|
||||
logger.warning(message)
|
||||
|
||||
# Create a new task_id for this duplicate request and mark it as an error
|
||||
error_task_id = str(uuid.uuid4())
|
||||
if from_watch_job:
|
||||
logger.info(f"Task from watch job for {incoming_url} not queued due to active duplicate {existing_task_id}.")
|
||||
return None # Skip execution for watch jobs
|
||||
else:
|
||||
# Create a new task_id for this duplicate request and mark it as an error
|
||||
error_task_id = str(uuid.uuid4())
|
||||
error_task_info_payload = {
|
||||
"download_type": incoming_type,
|
||||
"type": task.get("type", incoming_type),
|
||||
"name": task.get("name", "Duplicate Task"),
|
||||
"artist": task.get("artist", ""),
|
||||
"url": incoming_url,
|
||||
"original_request": task.get("orig_request", task.get("original_request", {})),
|
||||
"created_at": time.time(),
|
||||
"is_duplicate_error_task": True
|
||||
}
|
||||
store_task_info(error_task_id, error_task_info_payload)
|
||||
error_status_payload = {
|
||||
"status": ProgressState.ERROR,
|
||||
"error": message,
|
||||
"existing_task_id": existing_task_id,
|
||||
"timestamp": time.time(),
|
||||
"type": error_task_info_payload["type"],
|
||||
"name": error_task_info_payload["name"],
|
||||
"artist": error_task_info_payload["artist"]
|
||||
}
|
||||
store_task_status(error_task_id, error_status_payload)
|
||||
return error_task_id # Return the ID of this new error-state task
|
||||
|
||||
# Store minimal info for this error task
|
||||
error_task_info_payload = {
|
||||
"download_type": incoming_type,
|
||||
"type": task.get("type", incoming_type),
|
||||
"name": task.get("name", "Duplicate Task"),
|
||||
"artist": task.get("artist", ""),
|
||||
"url": incoming_url,
|
||||
"original_request": task.get("orig_request", task.get("original_request", {})),
|
||||
"created_at": time.time(),
|
||||
"is_duplicate_error_task": True
|
||||
}
|
||||
store_task_info(error_task_id, error_task_info_payload)
|
||||
|
||||
# Store error status for this new task_id
|
||||
error_status_payload = {
|
||||
"status": ProgressState.ERROR,
|
||||
"error": message,
|
||||
"existing_task_id": existing_task_id, # So client knows which task it duplicates
|
||||
"timestamp": time.time(),
|
||||
"type": error_task_info_payload["type"],
|
||||
"name": error_task_info_payload["name"],
|
||||
"artist": error_task_info_payload["artist"]
|
||||
}
|
||||
store_task_status(error_task_id, error_status_payload)
|
||||
|
||||
return error_task_id # Return the ID of this new error-state task
|
||||
# --- End Duplicate Check ---
|
||||
|
||||
# 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")
|
||||
return task_id
|
||||
logger.error(f"Unsupported download type: {incoming_type}")
|
||||
return task_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding task to Celery queue: {e}", exc_info=True)
|
||||
# Generate a task ID even for failed tasks so we can track the error
|
||||
error_task_id = str(uuid.uuid4())
|
||||
store_task_status(error_task_id, {
|
||||
"status": ProgressState.ERROR,
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -7,13 +7,15 @@ from routes.utils.celery_queue_manager import get_config_params
|
||||
|
||||
# We'll rely on get_config_params() instead of directly loading the config file
|
||||
|
||||
def get_spotify_info(spotify_id, spotify_type):
|
||||
def get_spotify_info(spotify_id, spotify_type, limit=None, offset=None):
|
||||
"""
|
||||
Get info from Spotify API using the default Spotify account configured in main.json
|
||||
|
||||
Args:
|
||||
spotify_id: The Spotify ID of the entity
|
||||
spotify_type: The type of entity (track, album, playlist, artist)
|
||||
limit (int, optional): The maximum number of items to return. Only used if spotify_type is "artist".
|
||||
offset (int, optional): The index of the first item to return. Only used if spotify_type is "artist".
|
||||
|
||||
Returns:
|
||||
Dictionary with the entity information
|
||||
@@ -51,7 +53,14 @@ def get_spotify_info(spotify_id, spotify_type):
|
||||
elif spotify_type == "playlist":
|
||||
return Spo.get_playlist(spotify_id)
|
||||
elif spotify_type == "artist":
|
||||
return Spo.get_artist(spotify_id)
|
||||
if limit is not None and offset is not None:
|
||||
return Spo.get_artist(spotify_id, limit=limit, offset=offset)
|
||||
elif limit is not None:
|
||||
return Spo.get_artist(spotify_id, limit=limit)
|
||||
elif offset is not None:
|
||||
return Spo.get_artist(spotify_id, offset=offset)
|
||||
else:
|
||||
return Spo.get_artist(spotify_id)
|
||||
elif spotify_type == "episode":
|
||||
return Spo.get_episode(spotify_id)
|
||||
else:
|
||||
|
||||
703
routes/utils/watch/db.py
Normal file
703
routes/utils/watch/db.py
Normal 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
|
||||
415
routes/utils/watch/manager.py
Normal file
415
routes/utils/watch/manager.py
Normal 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.
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
563
src/js/artist.ts
563
src/js/artist.ts
@@ -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,65 +222,75 @@ 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;
|
||||
albums.forEach(album => {
|
||||
if (!album) return;
|
||||
const albumElement = document.createElement('div');
|
||||
albumElement.className = 'album-card';
|
||||
|
||||
const albumElement = document.createElement('div');
|
||||
albumElement.className = 'album-card';
|
||||
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"
|
||||
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>
|
||||
`;
|
||||
|
||||
// Create album card with or without download button based on explicit filter setting
|
||||
if (isExplicitFilterEnabled) {
|
||||
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>
|
||||
`;
|
||||
} 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 || ''}"
|
||||
data-type="${album.album_type || 'album'}"
|
||||
data-name="${album.name || 'Unknown Album'}"
|
||||
title="Download">
|
||||
<img src="/static/images/download.svg" alt="Download">
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
const actionsContainer = document.createElement('div');
|
||||
actionsContainer.className = 'album-actions-container';
|
||||
|
||||
albumsContainer.appendChild(albumElement);
|
||||
});
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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,109 +303,104 @@ 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>
|
||||
`;
|
||||
appearingAlbums.forEach(album => {
|
||||
if (!album) return;
|
||||
const albumElement = document.createElement('div');
|
||||
albumElement.className = 'album-card';
|
||||
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"
|
||||
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>
|
||||
`;
|
||||
|
||||
const albumsContainer = featuringSection.querySelector('.albums-list');
|
||||
if (albumsContainer) {
|
||||
appearingAlbums.forEach(album => {
|
||||
if (!album) return;
|
||||
const actionsContainer = document.createElement('div');
|
||||
actionsContainer.className = 'album-actions-container';
|
||||
|
||||
const albumElement = document.createElement('div');
|
||||
albumElement.className = 'album-card';
|
||||
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;
|
||||
}
|
||||
|
||||
// Create album card with or without download button based on explicit filter setting
|
||||
if (isExplicitFilterEnabled) {
|
||||
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>
|
||||
`;
|
||||
} 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 || ''}"
|
||||
data-type="${album.album_type || 'album'}"
|
||||
data-name="${album.name || 'Unknown Album'}"
|
||||
title="Download">
|
||||
<img src="/static/images/download.svg" alt="Download">
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
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";
|
||||
|
||||
albumsContainer.appendChild(albumElement);
|
||||
});
|
||||
}
|
||||
|
||||
// Add to the end so it appears at the bottom
|
||||
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);
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 || ''}"
|
||||
data-type="track"
|
||||
data-name="${track.name || 'Unknown Track'}"
|
||||
title="Download">
|
||||
<img src="/static/images/download.svg" alt="Download">
|
||||
</button>
|
||||
`;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
4
static/images/check.svg
Normal 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 |
5
static/images/eye-crossed.svg
Normal file
5
static/images/eye-crossed.svg
Normal 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
4
static/images/eye.svg
Normal 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
12
static/images/missing.svg
Normal 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 |
4
static/images/refresh.svg
Normal file
4
static/images/refresh.svg
Normal 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 |
Reference in New Issue
Block a user