This commit is contained in:
cool.gitter.choco
2025-02-09 19:44:45 -06:00
parent ed23ad0f48
commit da5fda432a
17 changed files with 1137 additions and 382 deletions

View File

@@ -13,3 +13,4 @@
/Dockerfile
/docker-compose.yaml
/README.md
/config

1
.gitignore vendored
View File

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

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

View File

@@ -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 = ""
except Exception:
resource_type = ""
resource_name = ""

126
routes/utils/artist.py Normal file
View 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
})

View File

@@ -12,7 +12,17 @@ from queue import Queue, Empty
# ------------------------------------------------------------------------------
# Configuration
# ------------------------------------------------------------------------------
MAX_CONCURRENT_DL = 3 # maximum number of concurrent download processes
# 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
# ------------------------------------------------------------------------------
@@ -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(

View File

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

View File

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

View File

@@ -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 = `
groupSection.innerHTML = `
<div class="album-group-header">
<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}">
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) : '';
}

View File

@@ -24,23 +24,30 @@ const serviceConfig = {
let currentService = 'spotify';
let currentCredential = null;
document.addEventListener('DOMContentLoaded', () => {
initConfig();
// Global variables to hold the active accounts from the config response.
let activeSpotifyAccount = '';
let activeDeezerAccount = '';
document.addEventListener('DOMContentLoaded', async () => {
try {
await initConfig();
setupServiceTabs();
setupEventListeners();
// 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();
});
}
} 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">
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('');
</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">
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('');
</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);
}

View File

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

View File

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

View File

@@ -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.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) => {
const queueSidebar = document.getElementById('downloadQueue');
if (queueSidebar) {
queueSidebar.addEventListener('click', async (e) => {
if (e.target.closest('.close-btn')) {
this.toggleVisibility();
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

View File

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

View File

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