diff --git a/.dockerignore b/.dockerignore index 3d50c99..0da008a 100755 --- a/.dockerignore +++ b/.dockerignore @@ -13,3 +13,4 @@ /Dockerfile /docker-compose.yaml /README.md +/config \ No newline at end of file diff --git a/.gitignore b/.gitignore index b39477a..33ebb82 100755 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ routes/utils/__pycache__/__init__.cpython-312.pyc routes/utils/__pycache__/credentials.cpython-312.pyc routes/utils/__pycache__/search.cpython-312.pyc search_test.py +config/main.json diff --git a/app.py b/app.py index 1d9da05..31eccc0 100755 --- a/app.py +++ b/app.py @@ -6,6 +6,8 @@ from routes.album import album_bp from routes.track import track_bp from routes.playlist import playlist_bp from routes.prgs import prgs_bp +from routes.config import config_bp +from routes.artist import artist_bp import logging import time from pathlib import Path @@ -34,12 +36,15 @@ def create_app(): CORS(app) # Register blueprints + app.register_blueprint(config_bp, url_prefix='/api') app.register_blueprint(search_bp, url_prefix='/api') app.register_blueprint(credentials_bp, url_prefix='/api/credentials') app.register_blueprint(album_bp, url_prefix='/api/album') app.register_blueprint(track_bp, url_prefix='/api/track') app.register_blueprint(playlist_bp, url_prefix='/api/playlist') + app.register_blueprint(artist_bp, url_prefix='/api/artist') app.register_blueprint(prgs_bp, url_prefix='/api/prgs') + # Serve frontend @app.route('/') diff --git a/routes/artist.py b/routes/artist.py new file mode 100644 index 0000000..0376ea5 --- /dev/null +++ b/routes/artist.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +""" +Artist endpoint blueprint. +""" + +from flask import Blueprint, Response, request +import json +import os +import random +import string +import sys +import traceback +from multiprocessing import Process + +artist_bp = Blueprint('artist', __name__) + +# Global dictionary to keep track of running download processes. +download_processes = {} + +def generate_random_filename(length=6): + chars = string.ascii_lowercase + string.digits + return ''.join(random.choice(chars) for _ in range(length)) + '.artist.prg' + +class FlushingFileWrapper: + def __init__(self, file): + self.file = file + + def write(self, text): + # Only write lines that start with '{' + for line in text.split('\n'): + line = line.strip() + if line and line.startswith('{'): + try: + obj = json.loads(line) + # Skip writing if the JSON object has type "track" + if obj.get("type") == "track": + continue + except ValueError: + # If not valid JSON, write the line as is. + pass + self.file.write(line + '\n') + self.file.flush() + + def flush(self): + self.file.flush() + +def download_artist_task(service, artist_url, main, fallback, quality, fall_quality, real_time, + album_type, prg_path, orig_request, custom_dir_format, custom_track_format): + """ + This function wraps the call to download_artist_albums, writes the original + request data to the progress file, and then writes JSON status updates. + """ + try: + from routes.utils.artist import download_artist_albums + with open(prg_path, 'w') as f: + flushing_file = FlushingFileWrapper(f) + original_stdout = sys.stdout + sys.stdout = flushing_file # Redirect stdout to our flushing file wrapper + + # Write the original request data to the progress file. + try: + flushing_file.write(json.dumps({"original_request": orig_request}) + "\n") + except Exception as e: + flushing_file.write(json.dumps({ + "status": "error", + "message": f"Failed to write original request data: {str(e)}" + }) + "\n") + + try: + download_artist_albums( + service=service, + artist_url=artist_url, + main=main, + fallback=fallback, + quality=quality, + fall_quality=fall_quality, + real_time=real_time, + album_type=album_type, + custom_dir_format=custom_dir_format, + custom_track_format=custom_track_format + ) + flushing_file.write(json.dumps({"status": "complete"}) + "\n") + except Exception as e: + error_data = json.dumps({ + "status": "error", + "message": str(e), + "traceback": traceback.format_exc() + }) + flushing_file.write(error_data + "\n") + finally: + sys.stdout = original_stdout # Restore stdout + except Exception as e: + with open(prg_path, 'w') as f: + error_data = json.dumps({ + "status": "error", + "message": str(e), + "traceback": traceback.format_exc() + }) + f.write(error_data + "\n") + +@artist_bp.route('/download', methods=['GET']) +def handle_artist_download(): + """ + Starts the artist album download process. + Expected query parameters: + - artist_url: string (e.g., a Spotify artist URL) + - service: string (e.g., "deezer" or "spotify") + - main: string (e.g., "MX") + - fallback: string (optional, e.g., "JP") + - quality: string (e.g., "MP3_128") + - fall_quality: string (optional, e.g., "HIGH") + - real_time: bool (e.g., "true" or "false") + - album_type: string(s); one or more of "album", "single", "appears_on", "compilation" (if multiple, comma-separated) + - custom_dir_format: string (optional, default: "%ar_album%/%album%/%copyright%") + - custom_track_format: string (optional, default: "%tracknum%. %music% - %artist%") + """ + service = request.args.get('service') + artist_url = request.args.get('artist_url') + main = request.args.get('main') + fallback = request.args.get('fallback') + quality = request.args.get('quality') + fall_quality = request.args.get('fall_quality') + album_type = request.args.get('album_type') + real_time_arg = request.args.get('real_time', 'false') + real_time = real_time_arg.lower() in ['true', '1', 'yes'] + + # New query parameters for custom formatting. + custom_dir_format = request.args.get('custom_dir_format', "%ar_album%/%album%") + custom_track_format = request.args.get('custom_track_format', "%tracknum%. %music% - %artist%") + + # Sanitize main and fallback to prevent directory traversal + if main: + main = os.path.basename(main) + if fallback: + fallback = os.path.basename(fallback) + + # Check for required parameters. + if not all([service, artist_url, main, quality, album_type]): + return Response( + json.dumps({"error": "Missing parameters"}), + status=400, + mimetype='application/json' + ) + + # Validate credentials based on the selected service. + try: + if service == 'spotify': + if fallback: + # When using Spotify as the main service with a fallback, assume main credentials for Deezer and fallback for Spotify. + deezer_creds_path = os.path.abspath(os.path.join('./creds/deezer', main, 'credentials.json')) + if not os.path.isfile(deezer_creds_path): + return Response( + json.dumps({"error": "Invalid Deezer credentials directory"}), + status=400, + mimetype='application/json' + ) + spotify_fallback_path = os.path.abspath(os.path.join('./creds/spotify', fallback, 'credentials.json')) + if not os.path.isfile(spotify_fallback_path): + return Response( + json.dumps({"error": "Invalid Spotify fallback credentials directory"}), + status=400, + mimetype='application/json' + ) + else: + # Validate Spotify main credentials. + spotify_creds_path = os.path.abspath(os.path.join('./creds/spotify', main, 'credentials.json')) + if not os.path.isfile(spotify_creds_path): + return Response( + json.dumps({"error": "Invalid Spotify credentials directory"}), + status=400, + mimetype='application/json' + ) + elif service == 'deezer': + # Validate Deezer main credentials. + deezer_creds_path = os.path.abspath(os.path.join('./creds/deezer', main, 'credentials.json')) + if not os.path.isfile(deezer_creds_path): + return Response( + json.dumps({"error": "Invalid Deezer credentials directory"}), + status=400, + mimetype='application/json' + ) + else: + return Response( + json.dumps({"error": "Unsupported service"}), + status=400, + mimetype='application/json' + ) + except Exception as e: + return Response( + json.dumps({"error": f"Credential validation failed: {str(e)}"}), + status=500, + mimetype='application/json' + ) + + # Create a random filename for the progress file. + filename = generate_random_filename() + prg_dir = './prgs' + os.makedirs(prg_dir, exist_ok=True) + prg_path = os.path.join(prg_dir, filename) + + # Capture the original request parameters as a dictionary. + orig_request = request.args.to_dict() + + # Create and start the download process. + process = Process( + target=download_artist_task, + args=( + service, + artist_url, + main, + fallback, + quality, + fall_quality, + real_time, + album_type, + prg_path, + orig_request, + custom_dir_format, + custom_track_format + ) + ) + process.start() + download_processes[filename] = process + + return Response( + json.dumps({"prg_file": filename}), + status=202, + mimetype='application/json' + ) + +@artist_bp.route('/download/cancel', methods=['GET']) +def cancel_artist_download(): + """ + Cancel a running artist download process by its prg file name. + """ + prg_file = request.args.get('prg_file') + if not prg_file: + return Response( + json.dumps({"error": "Missing process id (prg_file) parameter"}), + status=400, + mimetype='application/json' + ) + + process = download_processes.get(prg_file) + prg_dir = './prgs' + prg_path = os.path.join(prg_dir, prg_file) + + if process and process.is_alive(): + process.terminate() + process.join() # Wait for termination + del download_processes[prg_file] + + try: + with open(prg_path, 'a') as f: + f.write(json.dumps({"status": "cancel"}) + "\n") + except Exception as e: + return Response( + json.dumps({"error": f"Failed to write cancel status to file: {str(e)}"}), + status=500, + mimetype='application/json' + ) + + return Response( + json.dumps({"status": "cancel"}), + status=200, + mimetype='application/json' + ) + else: + return Response( + json.dumps({"error": "Process not found or already terminated"}), + status=404, + mimetype='application/json' + ) + +# NEW ENDPOINT: Get Artist Information +@artist_bp.route('/info', methods=['GET']) +def get_artist_info(): + """ + Retrieve Spotify artist metadata given a Spotify ID. + Expects a query parameter 'id' that contains the Spotify artist ID. + """ + spotify_id = request.args.get('id') + if not spotify_id: + return Response( + json.dumps({"error": "Missing parameter: id"}), + status=400, + mimetype='application/json' + ) + + try: + # Import the get_spotify_info function from the utility module. + from routes.utils.get_info import get_spotify_info + # Call the function with the artist type. + artist_info = get_spotify_info(spotify_id, "artist") + return Response( + json.dumps(artist_info), + status=200, + mimetype='application/json' + ) + except Exception as e: + error_data = { + "error": str(e), + "traceback": traceback.format_exc() + } + return Response( + json.dumps(error_data), + status=500, + mimetype='application/json' + ) \ No newline at end of file diff --git a/routes/config.py b/routes/config.py new file mode 100644 index 0000000..cba627b --- /dev/null +++ b/routes/config.py @@ -0,0 +1,45 @@ +from flask import Blueprint, jsonify, request +import json +from pathlib import Path +import logging + +config_bp = Blueprint('config_bp', __name__) +CONFIG_PATH = Path('./config/main.json') + +def get_config(): + try: + if not CONFIG_PATH.exists(): + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + CONFIG_PATH.write_text('{}') + return {} + + with open(CONFIG_PATH, 'r') as f: + return json.load(f) + except Exception as e: + logging.error(f"Error reading config: {str(e)}") + return None + +@config_bp.route('/config', methods=['GET']) +def handle_config(): + config = get_config() + if config is None: + return jsonify({"error": "Could not read config file"}), 500 + return jsonify(config) + +@config_bp.route('/config', methods=['POST', 'PUT']) +def update_config(): + try: + new_config = request.get_json() + if not isinstance(new_config, dict): + return jsonify({"error": "Invalid config format"}), 400 + + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(CONFIG_PATH, 'w') as f: + json.dump(new_config, f, indent=2) + + return jsonify({"message": "Config updated successfully"}) + except json.JSONDecodeError: + return jsonify({"error": "Invalid JSON data"}), 400 + except Exception as e: + logging.error(f"Error updating config: {str(e)}") + return jsonify({"error": "Failed to update config"}), 500 \ No newline at end of file diff --git a/routes/prgs.py b/routes/prgs.py index 86ec1ef..78bd370 100755 --- a/routes/prgs.py +++ b/routes/prgs.py @@ -49,17 +49,9 @@ def get_prg_file(filename): if len(lines) > 1: try: second_line = json.loads(lines[1]) + # Directly extract 'type' and 'name' from the JSON resource_type = second_line.get("type", "") - if resource_type == "track": - resource_name = second_line.get("song", "") - elif resource_type == "album": - resource_name = second_line.get("album", "") - elif resource_type == "playlist": - resource_name = second_line.get("name", "") - elif resource_type == "artist": - resource_name = second_line.get("artist", "") - else: - resource_name = "" + resource_name = second_line.get("name", "") except Exception: resource_type = "" resource_name = "" diff --git a/routes/utils/artist.py b/routes/utils/artist.py new file mode 100644 index 0000000..507bd73 --- /dev/null +++ b/routes/utils/artist.py @@ -0,0 +1,126 @@ +import json +import traceback + +from deezspot.easy_spoty import Spo +from deezspot.libutils.utils import get_ids, link_is_valid +from routes.utils.album import download_album # Assumes album.py is in routes/utils/ + +def log_json(message_dict): + """Helper function to output a JSON-formatted log message.""" + print(json.dumps(message_dict)) + + +def get_artist_discography(url, album_type='album,single,compilation,appears_on'): + if not url: + message = "No artist URL provided." + log_json({"status": "error", "message": message}) + raise ValueError(message) + + try: + # Validate the URL (this function should raise an error if invalid). + link_is_valid(link=url) + except Exception as validation_error: + message = f"Link validation failed: {validation_error}" + log_json({"status": "error", "message": message}) + raise ValueError(message) + + try: + # Extract the artist ID from the URL. + artist_id = get_ids(url) + except Exception as id_error: + message = f"Failed to extract artist ID from URL: {id_error}" + log_json({"status": "error", "message": message}) + raise ValueError(message) + + try: + # Retrieve the discography using the artist ID. + discography = Spo.get_artist(artist_id, album_type=album_type) + return discography + except Exception as fetch_error: + message = f"An error occurred while fetching the discography: {fetch_error}" + log_json({"status": "error", "message": message}) + raise + + +def download_artist_albums(service, artist_url, main, fallback=None, quality=None, + fall_quality=None, real_time=False, album_type='album,single,compilation,appears_on', + custom_dir_format="%ar_album%/%album%/%copyright%", + custom_track_format="%tracknum%. %music% - %artist%"): + try: + discography = get_artist_discography(artist_url, album_type=album_type) + except Exception as e: + log_json({"status": "error", "message": f"Error retrieving artist discography: {e}"}) + raise + albums = discography.get('items', []) + # Extract artist name from the first album's artists as fallback. + artist_name = artist_url + if albums: + first_album = albums[0] + artists = first_album.get('artists', []) + if artists: + artist_name = artists[0].get('name', artist_url) + + if not albums: + log_json({ + "status": "done", + "type": "artist", + "artist": artist_name, + "album_type": album_type, + "message": "No albums found for the artist." + }) + return + + log_json({ + "status": "initializing", + "type": "artist", + "artist": artist_name, + "total_albums": len(albums), + "album_type": album_type + }) + + for album in albums: + try: + album_url = album.get('external_urls', {}).get('spotify') + album_name = album.get('name', 'Unknown Album') + # Extract artist names if available. + artists = [] + if "artists" in album: + artists = [artist.get("name", "Unknown") for artist in album["artists"]] + if not album_url: + log_json({ + "status": "warning", + "type": "album", + "album": album_name, + "artist": artists, + "message": "No Spotify URL found; skipping." + }) + continue + + download_album( + service=service, + url=album_url, + main=main, + fallback=fallback, + quality=quality, + fall_quality=fall_quality, + real_time=real_time, + custom_dir_format=custom_dir_format, + custom_track_format=custom_track_format + ) + + except Exception as album_error: + log_json({ + "status": "error", + "type": "album", + "album": album.get('name', 'Unknown'), + "error": str(album_error) + }) + traceback.print_exc() + + # When everything has been processed, print the final status. + log_json({ + "status": "done", + "type": "artist", + "artist": artist_name, + "album_type": album_type + }) \ No newline at end of file diff --git a/routes/utils/queue.py b/routes/utils/queue.py index 44fcc28..3ea2873 100644 --- a/routes/utils/queue.py +++ b/routes/utils/queue.py @@ -12,8 +12,18 @@ from queue import Queue, Empty # ------------------------------------------------------------------------------ # Configuration # ------------------------------------------------------------------------------ -MAX_CONCURRENT_DL = 3 # maximum number of concurrent download processes -PRG_DIR = './prgs' # directory where .prg files will be stored + +# Load configuration from ./config/main.json and get the max_concurrent_dl value. +CONFIG_PATH = './config/main.json' +try: + with open(CONFIG_PATH, 'r') as f: + config_data = json.load(f) + MAX_CONCURRENT_DL = config_data.get("maxConcurrentDownloads", 3) +except Exception as e: + # Fallback to a default value if there's an error reading the config. + MAX_CONCURRENT_DL = 3 + +PRG_DIR = './prgs' # directory where .prg files will be stored # ------------------------------------------------------------------------------ # Utility Functions and Classes @@ -124,7 +134,8 @@ class DownloadQueueManager: self.pending_tasks = Queue() # holds tasks waiting to run self.running_downloads = {} # maps prg_filename -> Process instance - self.lock = threading.Lock() # protects access to running_downloads + self.cancelled_tasks = set() # holds prg_filenames of tasks that have been cancelled + self.lock = threading.Lock() # protects access to running_downloads and cancelled_tasks self.worker_thread = threading.Thread(target=self.queue_worker, daemon=True) self.running = False @@ -185,19 +196,20 @@ class DownloadQueueManager: def cancel_task(self, prg_filename): """ - Cancel a running download task by terminating its process. - If the task is found and alive, it is terminated and a cancellation - status is appended to its .prg file. + Cancel a download task (either queued or running) by marking it as cancelled or terminating its process. + If the task is running, its process is terminated. + If the task is queued, it is marked as cancelled so that it won't be started. + In either case, a cancellation status is appended to its .prg file. Returns a dictionary indicating the result. """ + prg_path = os.path.join(self.prg_dir, prg_filename) with self.lock: process = self.running_downloads.get(prg_filename) if process and process.is_alive(): process.terminate() process.join() del self.running_downloads[prg_filename] - prg_path = os.path.join(self.prg_dir, prg_filename) try: with open(prg_path, 'a') as f: f.write(json.dumps({"status": "cancel"}) + "\n") @@ -205,7 +217,14 @@ class DownloadQueueManager: return {"error": f"Failed to write cancel status: {str(e)}"} return {"status": "cancelled"} else: - return {"error": "Task not found or already terminated"} + # Task is not running; mark it as cancelled if it's still pending. + self.cancelled_tasks.add(prg_filename) + try: + with open(prg_path, 'a') as f: + f.write(json.dumps({"status": "cancel"}) + "\n") + except Exception as e: + return {"error": f"Failed to write cancel status: {str(e)}"} + return {"status": "cancelled"} def queue_worker(self): """ @@ -217,7 +236,7 @@ class DownloadQueueManager: # First, clean up any finished processes. with self.lock: finished = [] - for prg_filename, process in self.running_downloads.items(): + for prg_filename, process in list(self.running_downloads.items()): if not process.is_alive(): finished.append(prg_filename) for prg_filename in finished: @@ -231,6 +250,13 @@ class DownloadQueueManager: time.sleep(0.5) continue + # Check if the task was cancelled while it was still queued. + with self.lock: + if prg_filename in self.cancelled_tasks: + # Task has been cancelled; remove it from the set and skip processing. + self.cancelled_tasks.remove(prg_filename) + continue + prg_path = task.get('prg_path') # Create and start a new process for the task. p = Process( diff --git a/static/css/queue/queue.css b/static/css/queue/queue.css index 650d8af..2c8d2d9 100644 --- a/static/css/queue/queue.css +++ b/static/css/queue/queue.css @@ -13,8 +13,12 @@ padding: 20px; transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1); z-index: 1001; - overflow-y: auto; + /* Remove overflow-y here to delegate scrolling to the queue items container */ box-shadow: -20px 0 30px rgba(0, 0, 0, 0.4); + + /* Added for flex layout */ + display: flex; + flex-direction: column; } /* When active, the sidebar slides into view */ @@ -65,8 +69,10 @@ /* Container for all queue items */ #queueItems { - max-height: 60vh; + /* Allow the container to fill all available space in the sidebar */ + flex: 1; overflow-y: auto; + /* Removed max-height: 60vh; */ } /* Each download queue item */ diff --git a/static/js/album.js b/static/js/album.js index 8de0208..a1abac1 100644 --- a/static/js/album.js +++ b/static/js/album.js @@ -156,7 +156,12 @@ function renderAlbum(album) { async function downloadWholeAlbum(album) { const url = album.external_urls.spotify; - return startDownload(url, 'album', { name: album.name }); + try { + await downloadQueue.startAlbumDownload(url, { name: album.name }); + } catch (error) { + showError('Album download failed: ' + error.message); + throw error; + } } function msToTime(duration) { diff --git a/static/js/artist.js b/static/js/artist.js index 1f51e66..cecdf47 100644 --- a/static/js/artist.js +++ b/static/js/artist.js @@ -1,8 +1,7 @@ -// Import the downloadQueue singleton from your working queue.js implementation. +// Import the downloadQueue singleton import { downloadQueue } from './queue.js'; document.addEventListener('DOMContentLoaded', () => { - // Parse artist ID from the URL (expected route: /artist/{id}) const pathSegments = window.location.pathname.split('/'); const artistId = pathSegments[pathSegments.indexOf('artist') + 1]; @@ -11,13 +10,12 @@ document.addEventListener('DOMContentLoaded', () => { return; } - // Fetch the artist info (which includes a list of albums) fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}`) .then(response => { if (!response.ok) throw new Error('Network response was not ok'); return response.json(); }) - .then(data => renderArtist(data, artistId)) // Pass artistId along + .then(data => renderArtist(data, artistId)) .catch(error => { console.error('Error:', error); showError('Failed to load artist info.'); @@ -25,114 +23,82 @@ document.addEventListener('DOMContentLoaded', () => { const queueIcon = document.getElementById('queueIcon'); if (queueIcon) { - queueIcon.addEventListener('click', () => { - downloadQueue.toggleVisibility(); - }); + queueIcon.addEventListener('click', () => downloadQueue.toggleVisibility()); } }); -/** - * Renders the artist header and groups the albums by type. - */ function renderArtist(artistData, artistId) { - // Hide loading and error messages document.getElementById('loading').classList.add('hidden'); document.getElementById('error').classList.add('hidden'); - // Use the first album to extract artist details const firstAlbum = artistData.items[0]; const artistName = firstAlbum?.artists[0]?.name || 'Unknown Artist'; const artistImage = firstAlbum?.images[0]?.url || 'placeholder.jpg'; - // Embed the artist name in a link document.getElementById('artist-name').innerHTML = `${artistName}`; document.getElementById('artist-stats').textContent = `${artistData.total} albums`; document.getElementById('artist-image').src = artistImage; - // --- Add Home Button --- + // Home Button let homeButton = document.getElementById('homeButton'); if (!homeButton) { homeButton = document.createElement('button'); homeButton.id = 'homeButton'; homeButton.className = 'home-btn'; - homeButton.innerHTML = `Home`; - const headerContainer = document.getElementById('artist-header'); - headerContainer.insertBefore(homeButton, headerContainer.firstChild); + homeButton.innerHTML = `Home`; + document.getElementById('artist-header').prepend(homeButton); } - homeButton.addEventListener('click', () => { - window.location.href = window.location.origin; - }); + homeButton.addEventListener('click', () => window.location.href = window.location.origin); - // --- Add "Download Whole Artist" Button --- + // Download Whole Artist Button let downloadArtistBtn = document.getElementById('downloadArtistBtn'); if (!downloadArtistBtn) { downloadArtistBtn = document.createElement('button'); downloadArtistBtn.id = 'downloadArtistBtn'; - downloadArtistBtn.textContent = 'Download Whole Artist'; downloadArtistBtn.className = 'download-btn download-btn--main'; - const headerContainer = document.getElementById('artist-header'); - headerContainer.appendChild(downloadArtistBtn); + downloadArtistBtn.textContent = 'Download All Albums'; + document.getElementById('artist-header').appendChild(downloadArtistBtn); } - downloadArtistBtn.addEventListener('click', () => { - // Remove individual album and group download buttons (but leave the whole artist button). - document.querySelectorAll('.download-btn').forEach(btn => { - if (btn.id !== 'downloadArtistBtn') { - btn.remove(); - } - }); + downloadArtistBtn.addEventListener('click', () => { + document.querySelectorAll('.download-btn:not(#downloadArtistBtn)').forEach(btn => btn.remove()); downloadArtistBtn.disabled = true; downloadArtistBtn.textContent = 'Queueing...'; - downloadWholeArtist(artistData) - .then(() => { - downloadArtistBtn.textContent = 'Queued!'; - }) - .catch(err => { - showError('Failed to queue artist download: ' + err.message); - downloadArtistBtn.disabled = false; - }); + queueAllAlbums(artistData.items, downloadArtistBtn); }); - // Group albums by album type. - const albumGroups = {}; - artistData.items.forEach(album => { + // Group albums by type + const albumGroups = artistData.items.reduce((groups, album) => { const type = album.album_type.toLowerCase(); - if (!albumGroups[type]) { - albumGroups[type] = []; - } - albumGroups[type].push(album); - }); + if (!groups[type]) groups[type] = []; + groups[type].push(album); + return groups; + }, {}); - // Render groups into the #album-groups container. + // Render album groups const groupsContainer = document.getElementById('album-groups'); - groupsContainer.innerHTML = ''; // Clear previous content + groupsContainer.innerHTML = ''; - // For each album type, render a section header, a "Download All" button, and the album list. for (const [groupType, albums] of Object.entries(albumGroups)) { const groupSection = document.createElement('section'); groupSection.className = 'album-group'; - - // Header with a download-all button. - const header = document.createElement('div'); - header.className = 'album-group-header'; - header.innerHTML = ` -

${capitalize(groupType)}s

- + + groupSection.innerHTML = ` +
+

${capitalize(groupType)}s

+ +
+
`; - groupSection.appendChild(header); - // Container for individual albums in this group. - const albumsContainer = document.createElement('div'); - albumsContainer.className = 'albums-list'; + const albumsContainer = groupSection.querySelector('.albums-list'); albums.forEach(album => { const albumElement = document.createElement('div'); - // Build a unified album card markup that works for both desktop and mobile. albumElement.className = 'album-card'; albumElement.innerHTML = ` @@ -147,7 +113,6 @@ function renderArtist(artistData, artistId) { - - - - `).join(''); + list.innerHTML = credentials + .map(name => + `
+ ${name} +
+ + +
+
` + ) + .join(''); list.querySelectorAll('.delete-btn').forEach(btn => { btn.addEventListener('click', handleDeleteCredential); @@ -165,18 +187,24 @@ async function handleDeleteCredential(e) { throw new Error('Missing credential information'); } - const response = await fetch(`/api/credentials/${service}/${name}`, { - method: 'DELETE' + const response = await fetch(`/api/credentials/${service}/${name}`, { + method: 'DELETE' }); if (!response.ok) { throw new Error('Failed to delete credential'); } + // If the deleted credential is the active account, clear the selection. const accountSelect = document.getElementById(`${service}AccountSelect`); if (accountSelect.value === name) { accountSelect.value = ''; - saveConfig(); + if (service === 'spotify') { + activeSpotifyAccount = ''; + } else if (service === 'deezer') { + activeDeezerAccount = ''; + } + await saveConfig(); } loadCredentials(service); @@ -191,7 +219,6 @@ async function handleEditCredential(e) { const name = e.target.dataset.name; try { - // Switch to the appropriate service tab document.querySelector(`[data-service="${service}"]`).click(); await new Promise(resolve => setTimeout(resolve, 50)); @@ -209,16 +236,18 @@ async function handleEditCredential(e) { function updateFormFields() { const serviceFields = document.getElementById('serviceFields'); - serviceFields.innerHTML = serviceConfig[currentService].fields.map(field => ` -
- - -
- `).join(''); + serviceFields.innerHTML = serviceConfig[currentService].fields + .map(field => + `
+ + +
` + ) + .join(''); } function populateFormFields(service, data) { @@ -260,7 +289,7 @@ async function handleCredentialSubmit(e) { } await updateAccountSelectors(); - saveConfig(); + await saveConfig(); loadCredentials(service); resetForm(); } catch (error) { @@ -276,7 +305,8 @@ function resetForm() { document.getElementById('credentialForm').reset(); } -function saveConfig() { +async function saveConfig() { + // Read active account values directly from the DOM (or from the globals which are kept in sync) const config = { spotify: document.getElementById('spotifyAccountSelect').value, deezer: document.getElementById('deezerAccountSelect').value, @@ -284,28 +314,60 @@ function saveConfig() { spotifyQuality: document.getElementById('spotifyQualitySelect').value, deezerQuality: document.getElementById('deezerQualitySelect').value, realTime: document.getElementById('realTimeToggle').checked, - // Save the new formatting settings customDirFormat: document.getElementById('customDirFormat').value, - customTrackFormat: document.getElementById('customTrackFormat').value + customTrackFormat: document.getElementById('customTrackFormat').value, + maxConcurrentDownloads: parseInt(document.getElementById('maxConcurrentDownloads').value, 10) || 3 }; - localStorage.setItem('activeConfig', JSON.stringify(config)); + + try { + const response = await fetch('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to save config'); + } + } catch (error) { + showConfigError(error.message); + } } -function loadConfig() { - const saved = JSON.parse(localStorage.getItem('activeConfig')) || {}; - document.getElementById('spotifyAccountSelect').value = saved.spotify || ''; - document.getElementById('deezerAccountSelect').value = saved.deezer || ''; - document.getElementById('fallbackToggle').checked = !!saved.fallback; - document.getElementById('spotifyQualitySelect').value = saved.spotifyQuality || 'NORMAL'; - document.getElementById('deezerQualitySelect').value = saved.deezerQuality || 'MP3_128'; - document.getElementById('realTimeToggle').checked = !!saved.realTime; - // Load the new formatting settings. If not set, you can choose to default to an empty string or a specific format. - document.getElementById('customDirFormat').value = saved.customDirFormat || '%ar_album%/%album%'; - document.getElementById('customTrackFormat').value = saved.customTrackFormat || '%tracknum%. %music%'; +async function loadConfig() { + try { + const response = await fetch('/api/config'); + if (!response.ok) throw new Error('Failed to load config'); + + const savedConfig = await response.json(); + + // Use the "spotify" and "deezer" properties from the API response to set the active accounts. + activeSpotifyAccount = savedConfig.spotify || ''; + activeDeezerAccount = savedConfig.deezer || ''; + + // (Optionally, if the account selects already exist you can set their values here, + // but updateAccountSelectors() will rebuild the options and set the proper values.) + const spotifySelect = document.getElementById('spotifyAccountSelect'); + const deezerSelect = document.getElementById('deezerAccountSelect'); + if (spotifySelect) spotifySelect.value = activeSpotifyAccount; + if (deezerSelect) deezerSelect.value = activeDeezerAccount; + + // Update other configuration fields. + document.getElementById('fallbackToggle').checked = !!savedConfig.fallback; + document.getElementById('spotifyQualitySelect').value = savedConfig.spotifyQuality || 'NORMAL'; + document.getElementById('deezerQualitySelect').value = savedConfig.deezerQuality || 'MP3_128'; + document.getElementById('realTimeToggle').checked = !!savedConfig.realTime; + document.getElementById('customDirFormat').value = savedConfig.customDirFormat || '%ar_album%/%album%'; + document.getElementById('customTrackFormat').value = savedConfig.customTrackFormat || '%tracknum%. %music%'; + document.getElementById('maxConcurrentDownloads').value = savedConfig.maxConcurrentDownloads || '3'; + } catch (error) { + showConfigError('Error loading config: ' + error.message); + } } function showConfigError(message) { const errorDiv = document.getElementById('configError'); errorDiv.textContent = message; - setTimeout(() => errorDiv.textContent = '', 3000); + setTimeout(() => (errorDiv.textContent = ''), 3000); } diff --git a/static/js/main.js b/static/js/main.js index cabe426..d7e50b7 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -1,4 +1,4 @@ -// Import the downloadQueue singleton from your working queue.js implementation. +// main.js import { downloadQueue } from './queue.js'; document.addEventListener('DOMContentLoaded', () => { @@ -97,49 +97,35 @@ function attachDownloadListeners(items) { }); } +/** + * Calls the appropriate downloadQueue method based on the type. + * Before calling, it also enriches the item object with a proper "artist" value. + */ async function startDownload(url, type, item, albumType) { - // Retrieve configuration (if any) from localStorage - const config = JSON.parse(localStorage.getItem('activeConfig')) || {}; - const { - fallback = false, - spotify = '', - deezer = '', - spotifyQuality = 'NORMAL', - deezerQuality = 'MP3_128', - realTime = false, - customTrackFormat = '', - customDirFormat = '' - } = config; - - let service = url.includes('open.spotify.com') ? 'spotify' : 'deezer'; - let apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`; - - if (type === 'artist') { - apiUrl = `/api/artist/download?service=${service}&artist_url=${encodeURIComponent(url)}&album_type=${encodeURIComponent(albumType || 'album,single,compilation')}`; + // Enrich the item object with the artist property. + // This ensures the new "name" and "artist" parameters are sent with the API call. + if (type === 'track') { + item.artist = item.artists.map(a => a.name).join(', '); + } else if (type === 'album') { + item.artist = item.artists.map(a => a.name).join(', '); + } else if (type === 'playlist') { + item.artist = item.owner.display_name; + } else if (type === 'artist') { + item.artist = item.name; } - - if (fallback && service === 'spotify') { - apiUrl += `&main=${deezer}&fallback=${spotify}`; - apiUrl += `&quality=${deezerQuality}&fall_quality=${spotifyQuality}`; - } else { - const mainAccount = service === 'spotify' ? spotify : deezer; - apiUrl += `&main=${mainAccount}&quality=${service === 'spotify' ? spotifyQuality : deezerQuality}`; - } - - if (realTime) apiUrl += '&real_time=true'; - - // Append custom formatting parameters if present. - if (customTrackFormat) { - apiUrl += `&custom_track_format=${encodeURIComponent(customTrackFormat)}`; - } - if (customDirFormat) { - apiUrl += `&custom_dir_format=${encodeURIComponent(customDirFormat)}`; - } - + try { - const response = await fetch(apiUrl); - const data = await response.json(); - downloadQueue.addDownload(item, type, data.prg_file); + if (type === 'track') { + await downloadQueue.startTrackDownload(url, item); + } else if (type === 'playlist') { + await downloadQueue.startPlaylistDownload(url, item); + } else if (type === 'album') { + await downloadQueue.startAlbumDownload(url, item); + } else if (type === 'artist') { + await downloadQueue.startArtistDownload(url, item, albumType); + } else { + throw new Error(`Unsupported type: ${type}`); + } } catch (error) { showError('Download failed: ' + error.message); } diff --git a/static/js/playlist.js b/static/js/playlist.js index f848919..5643967 100644 --- a/static/js/playlist.js +++ b/static/js/playlist.js @@ -187,11 +187,15 @@ function attachDownloadListeners() { /** * Initiates the whole playlist download by calling the playlist endpoint. */ +// playlist.js async function downloadWholePlaylist(playlist) { - // Use the playlist external URL (assumed available) for the download. const url = playlist.external_urls.spotify; - // Queue the whole playlist download with the descriptive playlist name. - startDownload(url, 'playlist', { name: playlist.name }); + try { + await downloadQueue.startPlaylistDownload(url, { name: playlist.name }); + } catch (error) { + showError('Playlist download failed: ' + error.message); + throw error; + } } /** diff --git a/static/js/queue.js b/static/js/queue.js index 954f413..98ea4a3 100644 --- a/static/js/queue.js +++ b/static/js/queue.js @@ -1,15 +1,45 @@ // queue.js + +// --- NEW: Custom URLSearchParams class that does not encode specified keys --- +class CustomURLSearchParams { + constructor(noEncodeKeys = []) { + this.params = {}; + this.noEncodeKeys = noEncodeKeys; + } + append(key, value) { + this.params[key] = value; + } + toString() { + return Object.entries(this.params) + .map(([key, value]) => { + if (this.noEncodeKeys.includes(key)) { + // Do not encode keys specified in noEncodeKeys. + return `${key}=${value}`; + } else { + return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; + } + }) + .join('&'); + } +} + +// --- END NEW --- + class DownloadQueue { constructor() { this.downloadQueue = {}; this.prgInterval = null; - this.initDOM(); - this.initEventListeners(); - this.loadExistingPrgFiles(); + this.currentConfig = {}; // Cache for current config + + // Wait for initDOM to complete before setting up event listeners and loading existing PRG files. + this.initDOM().then(() => { + this.initEventListeners(); + this.loadExistingPrgFiles(); + }); } /* DOM Management */ - initDOM() { + async initDOM() { const queueHTML = `