damn
This commit is contained in:
@@ -13,3 +13,4 @@
|
||||
/Dockerfile
|
||||
/docker-compose.yaml
|
||||
/README.md
|
||||
/config
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||
|
||||
5
app.py
5
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,13 +36,16 @@ 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('/')
|
||||
def serve_index():
|
||||
|
||||
309
routes/artist.py
Normal file
309
routes/artist.py
Normal file
@@ -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'
|
||||
)
|
||||
45
routes/config.py
Normal file
45
routes/config.py
Normal file
@@ -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
|
||||
@@ -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 = ""
|
||||
|
||||
126
routes/utils/artist.py
Normal file
126
routes/utils/artist.py
Normal file
@@ -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
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 =
|
||||
`<a href="/artist/${artistId}" class="artist-link">${artistName}</a>`;
|
||||
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 = `<img src="/static/images/home.svg" alt="Home" class="home-icon">`;
|
||||
const headerContainer = document.getElementById('artist-header');
|
||||
headerContainer.insertBefore(homeButton, headerContainer.firstChild);
|
||||
homeButton.innerHTML = `<img src="/static/images/home.svg" alt="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 = `
|
||||
<h3>${capitalize(groupType)}s</h3>
|
||||
<button class="download-btn download-btn--main group-download-btn"
|
||||
data-album-type="${groupType}"
|
||||
data-artist-url="${firstAlbum.artists[0].external_urls.spotify}">
|
||||
Download All ${capitalize(groupType)}s
|
||||
</button>
|
||||
groupSection.innerHTML = `
|
||||
<div class="album-group-header">
|
||||
<h3>${capitalize(groupType)}s</h3>
|
||||
<button class="download-btn download-btn--main group-download-btn"
|
||||
data-group-type="${groupType}">
|
||||
Download All ${capitalize(groupType)}s
|
||||
</button>
|
||||
</div>
|
||||
<div class="albums-list"></div>
|
||||
`;
|
||||
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 = `
|
||||
<a href="/album/${album.id}" class="album-link">
|
||||
@@ -147,7 +113,6 @@ function renderArtist(artistData, artistId) {
|
||||
<button class="download-btn download-btn--circle"
|
||||
data-url="${album.external_urls.spotify}"
|
||||
data-type="album"
|
||||
data-album-type="${album.album_type}"
|
||||
data-name="${album.name}"
|
||||
title="Download">
|
||||
<img src="/static/images/download.svg" alt="Download">
|
||||
@@ -155,148 +120,89 @@ function renderArtist(artistData, artistId) {
|
||||
`;
|
||||
albumsContainer.appendChild(albumElement);
|
||||
});
|
||||
groupSection.appendChild(albumsContainer);
|
||||
|
||||
groupsContainer.appendChild(groupSection);
|
||||
}
|
||||
|
||||
// Reveal header and albums container.
|
||||
document.getElementById('artist-header').classList.remove('hidden');
|
||||
document.getElementById('albums-container').classList.remove('hidden');
|
||||
|
||||
// Attach event listeners for individual album download buttons.
|
||||
attachDownloadListeners();
|
||||
// Attach event listeners for group download buttons.
|
||||
attachGroupDownloadListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays an error message in the UI.
|
||||
*/
|
||||
// Helper to queue multiple albums
|
||||
async function queueAllAlbums(albums, button) {
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
albums.map(album =>
|
||||
downloadQueue.startAlbumDownload(
|
||||
album.external_urls.spotify,
|
||||
{ name: album.name }
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const successful = results.filter(r => r.status === 'fulfilled').length;
|
||||
button.textContent = `Queued ${successful}/${albums.length} albums`;
|
||||
} catch (error) {
|
||||
button.textContent = 'Download All Albums';
|
||||
button.disabled = false;
|
||||
showError('Failed to queue some albums: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners for group downloads
|
||||
function attachGroupDownloadListeners() {
|
||||
document.querySelectorAll('.group-download-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
const groupSection = e.target.closest('.album-group');
|
||||
const albums = Array.from(groupSection.querySelectorAll('.album-card'))
|
||||
.map(card => ({
|
||||
url: card.querySelector('.download-btn').dataset.url,
|
||||
name: card.querySelector('.album-title').textContent
|
||||
}));
|
||||
|
||||
e.target.disabled = true;
|
||||
e.target.textContent = `Queueing ${albums.length} albums...`;
|
||||
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
albums.map(album =>
|
||||
downloadQueue.startAlbumDownload(album.url, { name: album.name })
|
||||
)
|
||||
);
|
||||
|
||||
const successful = results.filter(r => r.status === 'fulfilled').length;
|
||||
e.target.textContent = `Queued ${successful}/${albums.length} albums`;
|
||||
} catch (error) {
|
||||
e.target.textContent = `Download All ${capitalize(e.target.dataset.groupType)}s`;
|
||||
e.target.disabled = false;
|
||||
showError('Failed to queue some albums: ' + error.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Individual download handlers
|
||||
function attachDownloadListeners() {
|
||||
document.querySelectorAll('.download-btn:not(.group-download-btn)').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const { url, name } = e.currentTarget.dataset;
|
||||
e.currentTarget.remove();
|
||||
downloadQueue.startAlbumDownload(url, { name })
|
||||
.catch(err => showError('Download failed: ' + err.message));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// UI Helpers
|
||||
function showError(message) {
|
||||
const errorEl = document.getElementById('error');
|
||||
errorEl.textContent = message;
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches event listeners to all individual download buttons.
|
||||
*/
|
||||
function attachDownloadListeners() {
|
||||
document.querySelectorAll('.download-btn').forEach((btn) => {
|
||||
// Skip the whole artist and group download buttons.
|
||||
if (btn.id === 'downloadArtistBtn' || btn.classList.contains('group-download-btn')) return;
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const url = e.currentTarget.dataset.url;
|
||||
const type = e.currentTarget.dataset.type;
|
||||
const name = e.currentTarget.dataset.name || extractName(url);
|
||||
const albumType = e.currentTarget.dataset.albumType;
|
||||
// Remove button after click.
|
||||
e.currentTarget.remove();
|
||||
startDownload(url, type, { name }, albumType);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches event listeners to all group download buttons.
|
||||
*/
|
||||
function attachGroupDownloadListeners() {
|
||||
document.querySelectorAll('.group-download-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const albumType = e.currentTarget.dataset.albumType;
|
||||
const artistUrl = e.currentTarget.dataset.artistUrl;
|
||||
e.currentTarget.disabled = true;
|
||||
e.currentTarget.textContent = `Queueing ${capitalize(albumType)}s...`;
|
||||
startDownload(artistUrl, 'artist', { name: `All ${capitalize(albumType)}s` }, albumType)
|
||||
.then(() => {
|
||||
e.currentTarget.textContent = `Queued!`;
|
||||
})
|
||||
.catch(err => {
|
||||
showError('Failed to queue group download: ' + err.message);
|
||||
e.currentTarget.disabled = false;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates the whole artist download by calling the artist endpoint.
|
||||
*/
|
||||
async function downloadWholeArtist(artistData) {
|
||||
const artistUrl = artistData.items[0]?.artists[0]?.external_urls.spotify;
|
||||
if (!artistUrl) throw new Error('Artist URL not found.');
|
||||
startDownload(artistUrl, 'artist', { name: artistData.items[0]?.artists[0]?.name || 'Artist' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the download process by building the API URL,
|
||||
* fetching download details, and then adding the download to the queue.
|
||||
*/
|
||||
async function startDownload(url, type, item, albumType) {
|
||||
const config = JSON.parse(localStorage.getItem('activeConfig')) || {};
|
||||
const {
|
||||
fallback = false,
|
||||
spotify = '',
|
||||
deezer = '',
|
||||
spotifyQuality = 'NORMAL',
|
||||
deezerQuality = 'MP3_128',
|
||||
realTime = false,
|
||||
customDirFormat = '%ar_album%/%album%', // Default directory format
|
||||
customTrackFormat = '%tracknum%. %music%' // Default track format
|
||||
} = config;
|
||||
|
||||
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
|
||||
let apiUrl = '';
|
||||
|
||||
if (type === 'artist') {
|
||||
apiUrl = `/api/artist/download?service=${service}&artist_url=${encodeURIComponent(url)}&album_type=${encodeURIComponent(albumType || 'album,single,compilation')}`;
|
||||
} else {
|
||||
apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
// Add custom formatting parameters to the API request
|
||||
apiUrl += `&custom_dir_format=${encodeURIComponent(customDirFormat)}`;
|
||||
apiUrl += `&custom_track_format=${encodeURIComponent(customTrackFormat)}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl);
|
||||
const data = await response.json();
|
||||
const downloadType = apiUrl.includes('/artist/download')
|
||||
? 'artist'
|
||||
: apiUrl.includes('/album/download')
|
||||
? 'album'
|
||||
: type;
|
||||
downloadQueue.addDownload(item, downloadType, data.prg_file);
|
||||
} catch (error) {
|
||||
showError('Download failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper function to extract a display name from the URL.
|
||||
*/
|
||||
function extractName(url) {
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to capitalize the first letter of a string.
|
||||
*/
|
||||
function capitalize(str) {
|
||||
if (!str) return '';
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
return str ? str.charAt(0).toUpperCase() + str.slice(1) : '';
|
||||
}
|
||||
@@ -24,23 +24,30 @@ const serviceConfig = {
|
||||
let currentService = 'spotify';
|
||||
let currentCredential = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initConfig();
|
||||
setupServiceTabs();
|
||||
setupEventListeners();
|
||||
// Global variables to hold the active accounts from the config response.
|
||||
let activeSpotifyAccount = '';
|
||||
let activeDeezerAccount = '';
|
||||
|
||||
// Attach click listener for the queue icon to toggle the download queue sidebar.
|
||||
const queueIcon = document.getElementById('queueIcon');
|
||||
if (queueIcon) {
|
||||
queueIcon.addEventListener('click', () => {
|
||||
downloadQueue.toggleVisibility();
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
await initConfig();
|
||||
setupServiceTabs();
|
||||
setupEventListeners();
|
||||
|
||||
const queueIcon = document.getElementById('queueIcon');
|
||||
if (queueIcon) {
|
||||
queueIcon.addEventListener('click', () => {
|
||||
downloadQueue.toggleVisibility();
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
showConfigError(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
function initConfig() {
|
||||
loadConfig();
|
||||
updateAccountSelectors();
|
||||
async function initConfig() {
|
||||
await loadConfig();
|
||||
await updateAccountSelectors();
|
||||
loadCredentials(currentService);
|
||||
updateFormFields();
|
||||
}
|
||||
@@ -67,19 +74,26 @@ function setupEventListeners() {
|
||||
document.getElementById('spotifyQualitySelect').addEventListener('change', saveConfig);
|
||||
document.getElementById('deezerQualitySelect').addEventListener('change', saveConfig);
|
||||
|
||||
// Account select changes
|
||||
document.getElementById('spotifyAccountSelect').addEventListener('change', saveConfig);
|
||||
document.getElementById('deezerAccountSelect').addEventListener('change', saveConfig);
|
||||
// Update active account globals when the account selector is changed.
|
||||
document.getElementById('spotifyAccountSelect').addEventListener('change', (e) => {
|
||||
activeSpotifyAccount = e.target.value;
|
||||
saveConfig();
|
||||
});
|
||||
document.getElementById('deezerAccountSelect').addEventListener('change', (e) => {
|
||||
activeDeezerAccount = e.target.value;
|
||||
saveConfig();
|
||||
});
|
||||
|
||||
// New formatting settings change listeners
|
||||
// Formatting settings
|
||||
document.getElementById('customDirFormat').addEventListener('change', saveConfig);
|
||||
document.getElementById('customTrackFormat').addEventListener('change', saveConfig);
|
||||
|
||||
// New: Max concurrent downloads change listener
|
||||
document.getElementById('maxConcurrentDownloads').addEventListener('change', saveConfig);
|
||||
}
|
||||
|
||||
async function updateAccountSelectors() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('activeConfig')) || {};
|
||||
|
||||
const [spotifyResponse, deezerResponse] = await Promise.all([
|
||||
fetch('/api/credentials/spotify'),
|
||||
fetch('/api/credentials/deezer')
|
||||
@@ -88,32 +102,38 @@ async function updateAccountSelectors() {
|
||||
const spotifyAccounts = await spotifyResponse.json();
|
||||
const deezerAccounts = await deezerResponse.json();
|
||||
|
||||
// Update Spotify selector
|
||||
// Get the select elements
|
||||
const spotifySelect = document.getElementById('spotifyAccountSelect');
|
||||
const isValidSpotify = spotifyAccounts.includes(saved.spotify);
|
||||
spotifySelect.innerHTML = spotifyAccounts.map(a =>
|
||||
`<option value="${a}" ${a === saved.spotify ? 'selected' : ''}>${a}</option>`
|
||||
).join('');
|
||||
|
||||
if (!isValidSpotify && spotifyAccounts.length > 0) {
|
||||
spotifySelect.value = spotifyAccounts[0];
|
||||
saved.spotify = spotifyAccounts[0];
|
||||
localStorage.setItem('activeConfig', JSON.stringify(saved));
|
||||
}
|
||||
|
||||
// Update Deezer selector
|
||||
const deezerSelect = document.getElementById('deezerAccountSelect');
|
||||
const isValidDeezer = deezerAccounts.includes(saved.deezer);
|
||||
deezerSelect.innerHTML = deezerAccounts.map(a =>
|
||||
`<option value="${a}" ${a === saved.deezer ? 'selected' : ''}>${a}</option>`
|
||||
).join('');
|
||||
|
||||
if (!isValidDeezer && deezerAccounts.length > 0) {
|
||||
deezerSelect.value = deezerAccounts[0];
|
||||
saved.deezer = deezerAccounts[0];
|
||||
localStorage.setItem('activeConfig', JSON.stringify(saved));
|
||||
// Rebuild the Spotify selector options
|
||||
spotifySelect.innerHTML = spotifyAccounts
|
||||
.map(a => `<option value="${a}">${a}</option>`)
|
||||
.join('');
|
||||
|
||||
// Use the active account loaded from the config (activeSpotifyAccount)
|
||||
if (spotifyAccounts.includes(activeSpotifyAccount)) {
|
||||
spotifySelect.value = activeSpotifyAccount;
|
||||
} else if (spotifyAccounts.length > 0) {
|
||||
spotifySelect.value = spotifyAccounts[0];
|
||||
activeSpotifyAccount = spotifyAccounts[0];
|
||||
await saveConfig();
|
||||
}
|
||||
|
||||
// Rebuild the Deezer selector options
|
||||
deezerSelect.innerHTML = deezerAccounts
|
||||
.map(a => `<option value="${a}">${a}</option>`)
|
||||
.join('');
|
||||
|
||||
if (deezerAccounts.includes(activeDeezerAccount)) {
|
||||
deezerSelect.value = activeDeezerAccount;
|
||||
} else if (deezerAccounts.length > 0) {
|
||||
deezerSelect.value = deezerAccounts[0];
|
||||
activeDeezerAccount = deezerAccounts[0];
|
||||
await saveConfig();
|
||||
}
|
||||
|
||||
// Handle empty account lists
|
||||
[spotifySelect, deezerSelect].forEach((select, index) => {
|
||||
const accounts = index === 0 ? spotifyAccounts : deezerAccounts;
|
||||
if (accounts.length === 0) {
|
||||
@@ -137,15 +157,17 @@ async function loadCredentials(service) {
|
||||
|
||||
function renderCredentialsList(service, credentials) {
|
||||
const list = document.querySelector('.credentials-list');
|
||||
list.innerHTML = credentials.map(name => `
|
||||
<div class="credential-item">
|
||||
<span>${name}</span>
|
||||
<div class="credential-actions">
|
||||
<button class="edit-btn" data-name="${name}" data-service="${service}">Edit</button>
|
||||
<button class="delete-btn" data-name="${name}" data-service="${service}">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
list.innerHTML = credentials
|
||||
.map(name =>
|
||||
`<div class="credential-item">
|
||||
<span>${name}</span>
|
||||
<div class="credential-actions">
|
||||
<button class="edit-btn" data-name="${name}" data-service="${service}">Edit</button>
|
||||
<button class="delete-btn" data-name="${name}" data-service="${service}">Delete</button>
|
||||
</div>
|
||||
</div>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
list.querySelectorAll('.delete-btn').forEach(btn => {
|
||||
btn.addEventListener('click', handleDeleteCredential);
|
||||
@@ -173,10 +195,16 @@ async function handleDeleteCredential(e) {
|
||||
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 => `
|
||||
<div class="form-group">
|
||||
<label>${field.label}:</label>
|
||||
<input type="${field.type}"
|
||||
id="${field.id}"
|
||||
name="${field.id}"
|
||||
required
|
||||
${field.type === 'password' ? 'autocomplete="new-password"' : ''}>
|
||||
</div>
|
||||
`).join('');
|
||||
serviceFields.innerHTML = serviceConfig[currentService].fields
|
||||
.map(field =>
|
||||
`<div class="form-group">
|
||||
<label>${field.label}:</label>
|
||||
<input type="${field.type}"
|
||||
id="${field.id}"
|
||||
name="${field.id}"
|
||||
required
|
||||
${field.type === 'password' ? 'autocomplete="new-password"' : ''}>
|
||||
</div>`
|
||||
)
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -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')}`;
|
||||
}
|
||||
|
||||
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)}`;
|
||||
// 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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 = `
|
||||
<div id="downloadQueue" class="sidebar right" hidden>
|
||||
<div class="sidebar-header">
|
||||
@@ -21,46 +51,62 @@ class DownloadQueue {
|
||||
`;
|
||||
document.body.insertAdjacentHTML('beforeend', queueHTML);
|
||||
|
||||
// Restore the sidebar visibility from LocalStorage
|
||||
const savedVisibility = localStorage.getItem('downloadQueueVisible');
|
||||
// Load initial visibility from server config
|
||||
await this.loadConfig();
|
||||
const queueSidebar = document.getElementById('downloadQueue');
|
||||
if (savedVisibility === 'true') {
|
||||
queueSidebar.classList.add('active');
|
||||
queueSidebar.hidden = false;
|
||||
} else {
|
||||
queueSidebar.classList.remove('active');
|
||||
queueSidebar.hidden = true;
|
||||
}
|
||||
queueSidebar.hidden = !this.currentConfig.downloadQueueVisible;
|
||||
queueSidebar.classList.toggle('active', this.currentConfig.downloadQueueVisible);
|
||||
}
|
||||
|
||||
/* Event Handling */
|
||||
initEventListeners() {
|
||||
// Escape key handler
|
||||
document.addEventListener('keydown', (e) => {
|
||||
document.addEventListener('keydown', async (e) => {
|
||||
const queueSidebar = document.getElementById('downloadQueue');
|
||||
if (e.key === 'Escape' && queueSidebar.classList.contains('active')) {
|
||||
this.toggleVisibility();
|
||||
await this.toggleVisibility();
|
||||
}
|
||||
});
|
||||
|
||||
// Close button handler
|
||||
document.getElementById('downloadQueue').addEventListener('click', (e) => {
|
||||
if (e.target.closest('.close-btn')) {
|
||||
this.toggleVisibility();
|
||||
}
|
||||
});
|
||||
const queueSidebar = document.getElementById('downloadQueue');
|
||||
if (queueSidebar) {
|
||||
queueSidebar.addEventListener('click', async (e) => {
|
||||
if (e.target.closest('.close-btn')) {
|
||||
await this.toggleVisibility();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* Public API */
|
||||
toggleVisibility() {
|
||||
async toggleVisibility() {
|
||||
const queueSidebar = document.getElementById('downloadQueue');
|
||||
queueSidebar.classList.toggle('active');
|
||||
queueSidebar.hidden = !queueSidebar.classList.contains('active');
|
||||
const isVisible = !queueSidebar.classList.contains('active');
|
||||
|
||||
// Save the current visibility state to LocalStorage.
|
||||
localStorage.setItem('downloadQueueVisible', queueSidebar.classList.contains('active'));
|
||||
queueSidebar.classList.toggle('active', isVisible);
|
||||
queueSidebar.hidden = !isVisible;
|
||||
|
||||
this.dispatchEvent('queueVisibilityChanged', { visible: queueSidebar.classList.contains('active') });
|
||||
try {
|
||||
// Update config on server
|
||||
await this.loadConfig();
|
||||
const updatedConfig = { ...this.currentConfig, downloadQueueVisible: isVisible };
|
||||
await this.saveConfig(updatedConfig);
|
||||
this.dispatchEvent('queueVisibilityChanged', { visible: isVisible });
|
||||
} catch (error) {
|
||||
console.error('Failed to save queue visibility:', error);
|
||||
// Revert UI if save failed
|
||||
queueSidebar.classList.toggle('active', !isVisible);
|
||||
queueSidebar.hidden = isVisible;
|
||||
this.dispatchEvent('queueVisibilityChanged', { visible: !isVisible });
|
||||
this.showError('Failed to save queue visibility');
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'queue-error';
|
||||
errorDiv.textContent = message;
|
||||
document.getElementById('queueItems').prepend(errorDiv);
|
||||
setTimeout(() => errorDiv.remove(), 3000);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,7 +122,6 @@ class DownloadQueue {
|
||||
this.dispatchEvent('downloadAdded', { queueId, item, type });
|
||||
}
|
||||
|
||||
/* Core Functionality */
|
||||
async startEntryMonitoring(queueId) {
|
||||
const entry = this.downloadQueue[queueId];
|
||||
if (!entry || entry.hasEnded) return;
|
||||
@@ -112,22 +157,24 @@ class DownloadQueue {
|
||||
if (entry.type === 'playlist') {
|
||||
logElement.textContent = "Reading tracks list...";
|
||||
}
|
||||
this.updateQueueOrder();
|
||||
return;
|
||||
}
|
||||
// If there's no progress at all, treat as inactivity.
|
||||
if (!progress) {
|
||||
// For playlists, set the default message.
|
||||
if (entry.type === 'playlist') {
|
||||
logElement.textContent = "Reading tracks list...";
|
||||
} else {
|
||||
this.handleInactivity(entry, queueId, logElement);
|
||||
}
|
||||
this.updateQueueOrder();
|
||||
return;
|
||||
}
|
||||
|
||||
// If the new progress is the same as the last, also treat it as inactivity.
|
||||
if (JSON.stringify(entry.lastStatus) === JSON.stringify(progress)) {
|
||||
this.handleInactivity(entry, queueId, logElement);
|
||||
this.updateQueueOrder();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -146,9 +193,12 @@ class DownloadQueue {
|
||||
message: 'Status check error'
|
||||
});
|
||||
}
|
||||
// Reorder the queue display after updating the entry status.
|
||||
this.updateQueueOrder();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
|
||||
/* Helper Methods */
|
||||
generateQueueId() {
|
||||
return Date.now().toString() + Math.random().toString(36).substr(2, 9);
|
||||
@@ -169,8 +219,8 @@ class DownloadQueue {
|
||||
hasEnded: false,
|
||||
intervalId: null,
|
||||
uniqueId: queueId,
|
||||
retryCount: 0, // <== Initialize retry counter
|
||||
autoRetryInterval: null // <== To store the countdown interval ID for auto retry
|
||||
retryCount: 0, // Initialize retry counter
|
||||
autoRetryInterval: null // To store the countdown interval ID for auto retry
|
||||
};
|
||||
}
|
||||
|
||||
@@ -262,7 +312,6 @@ class DownloadQueue {
|
||||
if (!items || items.length === 0) return '';
|
||||
if (items.length === 1) return items[0];
|
||||
if (items.length === 2) return `${items[0]} and ${items[1]}`;
|
||||
// For three or more items: join all but the last with commas, then " and " the last item.
|
||||
return items.slice(0, -1).join(', ') + ' and ' + items[items.length - 1];
|
||||
}
|
||||
|
||||
@@ -272,6 +321,19 @@ class DownloadQueue {
|
||||
}
|
||||
|
||||
switch (data.status) {
|
||||
case 'queued':
|
||||
// Display a friendly message for queued items.
|
||||
if (data.type === 'album' || data.type === 'playlist') {
|
||||
// Show the name and queue position if provided.
|
||||
return `Queued ${data.type} "${data.name}"${data.position ? ` (position ${data.position})` : ''}`;
|
||||
} else if (data.type === 'track') {
|
||||
return `Queued track "${data.name}"${data.artist ? ` by ${data.artist}` : ''}`;
|
||||
}
|
||||
return `Queued ${data.type} "${data.name}"`;
|
||||
|
||||
case 'cancel':
|
||||
return 'Download cancelled';
|
||||
|
||||
case 'downloading':
|
||||
if (data.type === 'track') {
|
||||
return `Downloading track "${data.song}" by ${data.artist}...`;
|
||||
@@ -356,6 +418,7 @@ class DownloadQueue {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* New Methods to Handle Terminal State, Inactivity and Auto-Retry */
|
||||
|
||||
handleTerminalState(entry, queueId, progress) {
|
||||
@@ -486,6 +549,224 @@ class DownloadQueue {
|
||||
logElement.textContent = 'Retry failed: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds common URL parameters for download API requests.
|
||||
*
|
||||
* Correction: When fallback is enabled for Spotify downloads, the active accounts
|
||||
* are now used correctly as follows:
|
||||
*
|
||||
* - When fallback is true:
|
||||
* • main = config.deezer
|
||||
* • fallback = config.spotify
|
||||
* • quality = config.deezerQuality
|
||||
* • fall_quality = config.spotifyQuality
|
||||
*
|
||||
* - When fallback is false:
|
||||
* • main = config.spotify
|
||||
* • quality = config.spotifyQuality
|
||||
*
|
||||
* For Deezer downloads, always use:
|
||||
* • main = config.deezer
|
||||
* • quality = config.deezerQuality
|
||||
*/
|
||||
_buildCommonParams(url, service, config) {
|
||||
// --- MODIFIED: Use our custom parameter builder for Spotify ---
|
||||
let params;
|
||||
if (service === 'spotify') {
|
||||
params = new CustomURLSearchParams(['url']); // Do not encode the "url" parameter.
|
||||
} else {
|
||||
params = new URLSearchParams();
|
||||
}
|
||||
// --- END MODIFIED ---
|
||||
|
||||
params.append('service', service);
|
||||
params.append('url', url);
|
||||
|
||||
if (service === 'spotify') {
|
||||
if (config.fallback) {
|
||||
// Fallback enabled: use the active Deezer account as main and Spotify as fallback.
|
||||
params.append('main', config.deezer);
|
||||
params.append('fallback', config.spotify);
|
||||
params.append('quality', config.deezerQuality);
|
||||
params.append('fall_quality', config.spotifyQuality);
|
||||
} else {
|
||||
// Fallback disabled: use only the Spotify active account.
|
||||
params.append('main', config.spotify);
|
||||
params.append('quality', config.spotifyQuality);
|
||||
}
|
||||
} else {
|
||||
// For Deezer, always use the active Deezer account.
|
||||
params.append('main', config.deezer);
|
||||
params.append('quality', config.deezerQuality);
|
||||
}
|
||||
|
||||
if (config.realTime) {
|
||||
params.append('real_time', 'true');
|
||||
}
|
||||
|
||||
if (config.customTrackFormat) {
|
||||
params.append('custom_track_format', config.customTrackFormat);
|
||||
}
|
||||
|
||||
if (config.customDirFormat) {
|
||||
params.append('custom_dir_format', config.customDirFormat);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
async startTrackDownload(url, item) {
|
||||
await this.loadConfig();
|
||||
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
|
||||
const params = this._buildCommonParams(url, service, this.currentConfig);
|
||||
// Add the extra parameters "name" and "artist"
|
||||
params.append('name', item.name || '');
|
||||
params.append('artist', item.artist || '');
|
||||
const apiUrl = `/api/track/download?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl);
|
||||
if (!response.ok) throw new Error('Network error');
|
||||
const data = await response.json();
|
||||
this.addDownload(item, 'track', data.prg_file, apiUrl);
|
||||
} catch (error) {
|
||||
this.dispatchEvent('downloadError', { error, item });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async startPlaylistDownload(url, item) {
|
||||
await this.loadConfig();
|
||||
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
|
||||
const params = this._buildCommonParams(url, service, this.currentConfig);
|
||||
// Add the extra parameters "name" and "artist"
|
||||
params.append('name', item.name || '');
|
||||
params.append('artist', item.artist || '');
|
||||
const apiUrl = `/api/playlist/download?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl);
|
||||
const data = await response.json();
|
||||
this.addDownload(item, 'playlist', data.prg_file, apiUrl);
|
||||
} catch (error) {
|
||||
this.dispatchEvent('downloadError', { error, item });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async startAlbumDownload(url, item) {
|
||||
await this.loadConfig();
|
||||
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
|
||||
const params = this._buildCommonParams(url, service, this.currentConfig);
|
||||
// Add the extra parameters "name" and "artist"
|
||||
params.append('name', item.name || '');
|
||||
params.append('artist', item.artist || '');
|
||||
const apiUrl = `/api/album/download?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl);
|
||||
const data = await response.json();
|
||||
this.addDownload(item, 'album', data.prg_file, apiUrl);
|
||||
} catch (error) {
|
||||
this.dispatchEvent('downloadError', { error, item });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async startArtistDownload(url, item, albumType = 'album,single,compilation') {
|
||||
await this.loadConfig();
|
||||
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
|
||||
const params = this._buildCommonParams(url, service, this.currentConfig);
|
||||
params.append('album_type', albumType);
|
||||
// Add the extra parameters "name" and "artist"
|
||||
params.append('name', item.name || '');
|
||||
params.append('artist', item.artist || '');
|
||||
const apiUrl = `/api/artist/download?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl);
|
||||
const data = await response.json();
|
||||
this.addDownload(item, 'artist', data.prg_file, apiUrl);
|
||||
} catch (error) {
|
||||
this.dispatchEvent('downloadError', { error, item });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async loadConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/config');
|
||||
if (!response.ok) throw new Error('Failed to fetch config');
|
||||
this.currentConfig = await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error loading config:', error);
|
||||
this.currentConfig = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Placeholder for saveConfig; implement as needed.
|
||||
async saveConfig(updatedConfig) {
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedConfig)
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to save config');
|
||||
this.currentConfig = await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error saving config:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Reorders the download queue display so that:
|
||||
* - Errored (or canceled) downloads come first (Group 0)
|
||||
* - Ongoing downloads come next (Group 1)
|
||||
* - Queued downloads come last (Group 2), ordered by their position value.
|
||||
*/
|
||||
updateQueueOrder() {
|
||||
const container = document.getElementById('queueItems');
|
||||
const entries = Object.values(this.downloadQueue);
|
||||
|
||||
entries.sort((a, b) => {
|
||||
// Define groups:
|
||||
// Group 0: Errored or canceled downloads
|
||||
// Group 2: Queued downloads
|
||||
// Group 1: All others (ongoing)
|
||||
const getGroup = (entry) => {
|
||||
if (entry.lastStatus && (entry.lastStatus.status === "error" || entry.lastStatus.status === "cancel")) {
|
||||
return 0;
|
||||
} else if (entry.lastStatus && entry.lastStatus.status === "queued") {
|
||||
return 2;
|
||||
} else {
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
const groupA = getGroup(a);
|
||||
const groupB = getGroup(b);
|
||||
if (groupA !== groupB) {
|
||||
return groupA - groupB;
|
||||
} else {
|
||||
// For queued downloads, order by their "position" value (smallest first)
|
||||
if (groupA === 2) {
|
||||
const posA = a.lastStatus && a.lastStatus.position ? a.lastStatus.position : Infinity;
|
||||
const posB = b.lastStatus && b.lastStatus.position ? b.lastStatus.position : Infinity;
|
||||
return posA - posB;
|
||||
}
|
||||
// For errored or ongoing downloads, order by last update time (oldest first)
|
||||
return a.lastUpdated - b.lastUpdated;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear the container and re-append entries in sorted order.
|
||||
container.innerHTML = '';
|
||||
for (const entry of entries) {
|
||||
container.appendChild(entry.element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
||||
@@ -91,22 +91,18 @@ function renderTrack(track) {
|
||||
document.getElementById('track-header').appendChild(downloadBtn);
|
||||
}
|
||||
|
||||
// Attach a click listener to the download button.
|
||||
downloadBtn.addEventListener('click', () => {
|
||||
downloadBtn.disabled = true;
|
||||
// Save the original icon markup in case we need to revert.
|
||||
downloadBtn.dataset.originalHtml = `<img src="/static/images/download.svg" alt="Download">`;
|
||||
downloadBtn.innerHTML = `<span>Queueing...</span>`;
|
||||
|
||||
// Start the download for this track.
|
||||
startDownload(track.external_urls.spotify, 'track', { name: track.name })
|
||||
downloadQueue.startTrackDownload(track.external_urls.spotify, { name: track.name })
|
||||
.then(() => {
|
||||
downloadBtn.innerHTML = `<span>Queued!</span>`;
|
||||
})
|
||||
.catch(err => {
|
||||
showError('Failed to queue track download: ' + err.message);
|
||||
downloadBtn.disabled = false;
|
||||
downloadBtn.innerHTML = downloadBtn.dataset.originalHtml;
|
||||
downloadBtn.innerHTML = `<img src="/static/images/download.svg" alt="Download">`;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="maxConcurrentDownloads">Max Concurrent Downloads:</label>
|
||||
<input type="number" id="maxConcurrentDownloads" min="1" value="3">
|
||||
</div>
|
||||
<!-- New Formatting Options -->
|
||||
<div class="config-item">
|
||||
<label>Custom Directory Format:</label>
|
||||
|
||||
Reference in New Issue
Block a user