improved queue handling and artist downloading

This commit is contained in:
cool.gitter.choco
2025-02-10 10:43:51 -06:00
parent 2adf694458
commit 5b91339b47
7 changed files with 576 additions and 622 deletions

View File

@@ -8,114 +8,36 @@ 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 log_json(message_dict):
print(json.dumps(message_dict))
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.
Enqueues album download tasks for the given artist using the new artist module.
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")
- url: string (a Spotify artist URL)
- service: string ("spotify" or "deezer")
- main: string (e.g., a credentials directory name)
- fallback: string (optional)
- 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)
- album_type: string(s); comma-separated values such as "album,single,appears_on,compilation"
- custom_dir_format: string (optional, default: "%ar_album%/%album%/%copyright%")
- custom_track_format: string (optional, default: "%tracknum%. %music% - %artist%")
Since the new download_artist_albums() function simply enqueues album tasks via
the global queue manager, it returns a list of album PRG filenames. These are sent
back immediately in the JSON response.
"""
service = request.args.get('service')
artist_url = request.args.get('artist_url')
url = request.args.get('url')
main = request.args.get('main')
fallback = request.args.get('fallback')
quality = request.args.get('quality')
@@ -125,17 +47,17 @@ def handle_artist_download():
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_dir_format = request.args.get('custom_dir_format', "%ar_album%/%album%/%copyright%")
custom_track_format = request.args.get('custom_track_format', "%tracknum%. %music% - %artist%")
# Sanitize main and fallback to prevent directory traversal
# 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]):
if not all([service, url, main, quality, album_type]):
return Response(
json.dumps({"error": "Missing parameters"}),
status=400,
@@ -146,7 +68,7 @@ def handle_artist_download():
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.
# When a fallback is provided, validate both Deezer and Spotify fallback 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(
@@ -162,7 +84,6 @@ def handle_artist_download():
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(
@@ -171,7 +92,6 @@ def handle_artist_download():
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(
@@ -192,92 +112,61 @@ def handle_artist_download():
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
try:
# Import and call the updated download_artist_albums() function.
from routes.utils.artist import download_artist_albums
album_prg_files = download_artist_albums(
service=service,
url=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
)
# Return the list of album PRG filenames.
return Response(
json.dumps({
"status": "complete",
"album_prg_files": album_prg_files,
"message": "Artist download completed album tasks have been queued."
}),
status=202,
mimetype='application/json'
)
except Exception as e:
return Response(
json.dumps({
"status": "error",
"message": str(e),
"traceback": traceback.format_exc()
}),
status=500,
mimetype='application/json'
)
)
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.
Cancelling an artist download is not supported since the endpoint only enqueues album tasks.
(Cancellation for individual album tasks can be implemented via the queue manager.)
"""
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'
)
return Response(
json.dumps({"error": "Artist download cancellation is not supported."}),
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.
Retrieves Spotify artist metadata given a Spotify artist ID.
Expects a query parameter 'id' with the Spotify artist ID.
"""
spotify_id = request.args.get('id')
if not spotify_id:
@@ -288,9 +177,7 @@ def get_artist_info():
)
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),
@@ -298,12 +185,11 @@ def get_artist_info():
mimetype='application/json'
)
except Exception as e:
error_data = {
"error": str(e),
"traceback": traceback.format_exc()
}
return Response(
json.dumps(error_data),
json.dumps({
"error": str(e),
"traceback": traceback.format_exc()
}),
status=500,
mimetype='application/json'
)
)

View File

@@ -3,7 +3,8 @@ 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/
from routes.utils.queue import download_queue_manager # Global download queue manager
def log_json(message_dict):
"""Helper function to output a JSON-formatted log message."""
@@ -11,116 +12,112 @@ def log_json(message_dict):
def get_artist_discography(url, album_type='album,single,compilation,appears_on'):
"""
Validate the URL, extract the artist ID, and retrieve the discography.
"""
if not url:
message = "No artist URL provided."
log_json({"status": "error", "message": message})
raise ValueError(message)
log_json({"status": "error", "message": "No artist URL provided."})
raise ValueError("No artist URL provided.")
# This will raise an exception if the link is invalid.
link_is_valid(link=url)
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)
msg = f"Failed to extract artist ID from URL: {id_error}"
log_json({"status": "error", "message": msg})
raise ValueError(msg)
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})
msg = f"An error occurred while fetching the discography: {fetch_error}"
log_json({"status": "error", "message": msg})
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',
def download_artist_albums(service, 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%"):
"""
Retrieves the artist discography and, for each album with a valid Spotify URL,
creates a download task that is queued via the global download queue. The queue
creates a PRG file for each album download. This function returns a list of those
album PRG filenames.
"""
try:
discography = get_artist_discography(artist_url, album_type=album_type)
discography = get_artist_discography(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": "done", "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
})
prg_files = []
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."
"message": f"No Spotify URL found for album '{album.get('name', 'Unknown Album')}'; 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
)
album_name = album.get('name', 'Unknown Album')
artists = album.get('artists', [])
# Extract artist names or use "Unknown" as a fallback.
artists = [artist.get("name", "Unknown") for artist in artists]
# Prepare the download task dictionary.
task = {
"download_type": "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,
# Extra info for logging in the PRG file.
"name": album_name,
"type": "album",
"artist": artists,
"orig_request": {
"type": "album",
"name": album_name,
"artist": artists
}
}
# Add the task to the global download queue.
# The queue manager creates the album's PRG file and returns its filename.
prg_filename = download_queue_manager.add_task(task)
prg_files.append(prg_filename)
log_json({
"status": "queued",
"album": album_name,
"artist": artists,
"prg_file": prg_filename,
"message": "Album queued for download."
})
except Exception as album_error:
log_json({
"status": "error",
"type": "album",
"album": album.get('name', 'Unknown'),
"error": str(album_error)
"message": f"Error processing album '{album.get('name', 'Unknown')}': {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
})
return prg_files

View File

@@ -161,14 +161,17 @@ class DownloadQueueManager:
Returns the generated prg filename so that the caller can later
check the status or request cancellation.
"""
prg_filename = generate_random_filename()
prg_path = os.path.join(self.prg_dir, prg_filename)
task['prg_path'] = prg_path
# Determine the download type, defaulting to 'unknown' if not provided.
download_type = task.get("download_type", "unknown")
# Compute the overall position in the queue:
# position = (number of running tasks) + (number of pending tasks) + 1.
position = len(self.running_downloads) + self.pending_tasks.qsize() + 1
# Generate the prg filename based on the download type and queue position.
prg_filename = f"{download_type}_{position}.prg"
prg_path = os.path.join(self.prg_dir, prg_filename)
task['prg_path'] = prg_path
# Create and immediately write the initial entries to the .prg file.
try:
with open(prg_path, 'w') as f:

View File

@@ -27,7 +27,7 @@
}
/* Header inside the queue sidebar */
.queue-header {
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
@@ -36,35 +36,53 @@
margin-bottom: 20px;
}
.queue-title {
.sidebar-header h2 {
font-size: 1.25rem;
font-weight: 600;
color: #fff;
margin: 0;
}
.header-actions {
display: flex;
gap: 10px;
align-items: center;
}
/* Cancel all button styling */
#cancelAllBtn {
background: #ff5555;
border: none;
color: #fff;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
transition: background 0.3s ease;
font-size: 14px;
}
#cancelAllBtn:hover {
background: #ff7777;
}
/* Close button for the queue sidebar */
.queue-close {
.close-btn {
background: #2a2a2a;
border: none;
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.3s ease;
cursor: pointer;
}
.queue-close:hover {
background-color: #333;
}
/* X Icon style for the close button */
.queue-close::before {
content: "×";
color: #ffffff;
font-size: 20px;
color: #fff;
line-height: 32px; /* Center the icon vertically within the button */
cursor: pointer;
transition: background-color 0.3s ease;
}
.close-btn:hover {
background-color: #333;
}
/* Container for all queue items */
@@ -72,7 +90,6 @@
/* Allow the container to fill all available space in the sidebar */
flex: 1;
overflow-y: auto;
/* Removed max-height: 60vh; */
}
/* Each download queue item */
@@ -192,6 +209,7 @@
to { transform: rotate(360deg); }
}
/* Cancel button inside each queue item */
.cancel-btn {
background: none;
border: none;
@@ -205,8 +223,8 @@
}
.cancel-btn img {
width: 16px; /* Reduced from 24px */
height: 16px; /* Reduced from 24px */
width: 16px;
height: 16px;
filter: invert(1);
transition: transform 0.3s ease;
}
@@ -219,71 +237,28 @@
transform: scale(0.9);
}
/* Close button for the download queue sidebar */
.close-btn {
background: #2a2a2a;
/* ------------------------------- */
/* FOOTER & "SHOW MORE" BUTTON */
/* ------------------------------- */
#queueFooter {
text-align: center;
padding-top: 10px;
}
#queueFooter button {
background: #1DB954;
border: none;
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 20px;
padding: 8px 16px;
border-radius: 4px;
color: #fff;
cursor: pointer;
transition: background-color 0.3s ease;
transition: background 0.3s ease;
font-size: 14px;
}
.close-btn:hover {
background-color: #333;
}
/* ------------------------------- */
/* MOBILE RESPONSIVE ADJUSTMENTS */
/* ------------------------------- */
@media (max-width: 600px) {
/* Make the sidebar full width on mobile */
#downloadQueue {
width: 100%;
right: -100%; /* Off-screen fully */
padding: 15px;
}
/* When active, the sidebar slides into view from full width */
#downloadQueue.active {
right: 0;
}
/* Adjust header and title for smaller screens */
.queue-header {
flex-direction: column;
align-items: flex-start;
}
.queue-title {
font-size: 1.1rem;
}
/* Reduce the size of the close buttons */
.queue-close,
.close-btn {
width: 28px;
height: 28px;
font-size: 18px;
}
/* Adjust queue items padding */
.queue-item {
padding: 12px;
margin-bottom: 12px;
}
/* Ensure text remains legible on smaller screens */
.queue-item .log,
.queue-item .type {
font-size: 12px;
}
#queueFooter button:hover {
background: #17a448;
}
/* -------------------------- */
@@ -327,7 +302,6 @@
padding: 0;
}
/* Hover state for the Close (X) button */
.close-error-btn:hover {
background: #ff7777;
}
@@ -340,7 +314,52 @@
font-weight: bold;
}
/* Hover state for the Retry button */
.retry-btn:hover {
background: #17a448;
}
/* ------------------------------- */
/* MOBILE RESPONSIVE ADJUSTMENTS */
/* ------------------------------- */
@media (max-width: 600px) {
/* Make the sidebar full width on mobile */
#downloadQueue {
width: 100%;
right: -100%; /* Off-screen fully */
padding: 15px;
}
/* When active, the sidebar slides into view from full width */
#downloadQueue.active {
right: 0;
}
/* Adjust header and title for smaller screens */
.sidebar-header {
flex-direction: column;
align-items: flex-start;
}
.sidebar-header h2 {
font-size: 1.1rem;
}
/* Reduce the size of the close buttons */
.close-btn {
width: 28px;
height: 28px;
font-size: 18px;
}
/* Adjust queue items padding */
.queue-item {
padding: 12px;
margin-bottom: 12px;
}
/* Ensure text remains legible on smaller screens */
.queue-item .log,
.queue-item .type {
font-size: 12px;
}
}

View File

@@ -40,6 +40,9 @@ function renderArtist(artistData, artistId) {
document.getElementById('artist-stats').textContent = `${artistData.total} albums`;
document.getElementById('artist-image').src = artistImage;
// Define the artist URL (used by both full-discography and group downloads)
const artistUrl = `https://open.spotify.com/artist/${artistId}`;
// Home Button
let homeButton = document.getElementById('homeButton');
if (!homeButton) {
@@ -51,7 +54,7 @@ function renderArtist(artistData, artistId) {
}
homeButton.addEventListener('click', () => window.location.href = window.location.origin);
// Download Whole Artist Button
// Download Whole Artist Button using the new artist API endpoint
let downloadArtistBtn = document.getElementById('downloadArtistBtn');
if (!downloadArtistBtn) {
downloadArtistBtn = document.createElement('button');
@@ -62,14 +65,28 @@ function renderArtist(artistData, artistId) {
}
downloadArtistBtn.addEventListener('click', () => {
// Optionally remove other download buttons from individual albums.
document.querySelectorAll('.download-btn:not(#downloadArtistBtn)').forEach(btn => btn.remove());
downloadArtistBtn.disabled = true;
downloadArtistBtn.textContent = 'Queueing...';
queueAllAlbums(artistData.items, downloadArtistBtn);
// Queue the entire discography (albums, singles, compilations, and appears_on)
downloadQueue.startArtistDownload(
artistUrl,
{ name: artistName, artist: artistName },
'album,single,compilation,appears_on'
)
.then(() => {
downloadArtistBtn.textContent = 'Artist queued';
})
.catch(err => {
downloadArtistBtn.textContent = 'Download All Discography';
downloadArtistBtn.disabled = false;
showError('Failed to queue artist download: ' + err.message);
});
});
// Group albums by type
// Group albums by type (album, single, compilation, etc.)
const albumGroups = artistData.items.reduce((groups, album) => {
const type = album.album_type.toLowerCase();
if (!groups[type]) groups[type] = [];
@@ -84,7 +101,7 @@ function renderArtist(artistData, artistId) {
for (const [groupType, albums] of Object.entries(albumGroups)) {
const groupSection = document.createElement('section');
groupSection.className = 'album-group';
groupSection.innerHTML = `
<div class="album-group-header">
<h3>${capitalize(groupType)}s</h3>
@@ -112,7 +129,7 @@ function renderArtist(artistData, artistId) {
</div>
<button class="download-btn download-btn--circle"
data-url="${album.external_urls.spotify}"
data-type="album"
data-type="${album.album_type}"
data-name="${album.name}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
@@ -126,71 +143,45 @@ function renderArtist(artistData, artistId) {
document.getElementById('artist-header').classList.remove('hidden');
document.getElementById('albums-container').classList.remove('hidden');
attachDownloadListeners();
attachGroupDownloadListeners();
// Pass the artist URL and name so the group buttons can use the artist download function
attachGroupDownloadListeners(artistUrl, artistName);
}
// 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() {
// Event listeners for group downloads using the artist download function
function attachGroupDownloadListeners(artistUrl, artistName) {
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
}));
const groupType = e.target.dataset.groupType; // e.g. "album", "single", "compilation"
e.target.disabled = true;
e.target.textContent = `Queueing ${albums.length} albums...`;
e.target.textContent = `Queueing all ${capitalize(groupType)}s...`;
try {
const results = await Promise.allSettled(
albums.map(album =>
downloadQueue.startAlbumDownload(album.url, { name: album.name })
)
// Use the artist download function with the group type filter.
await downloadQueue.startArtistDownload(
artistUrl,
{ name: artistName, artist: artistName },
groupType // Only queue releases of this specific type.
);
const successful = results.filter(r => r.status === 'fulfilled').length;
e.target.textContent = `Queued ${successful}/${albums.length} albums`;
e.target.textContent = `Queued all ${capitalize(groupType)}s`;
} catch (error) {
e.target.textContent = `Download All ${capitalize(e.target.dataset.groupType)}s`;
e.target.textContent = `Download All ${capitalize(groupType)}s`;
e.target.disabled = false;
showError('Failed to queue some albums: ' + error.message);
showError(`Failed to queue download for all ${groupType}s: ${error.message}`);
}
});
});
}
// Individual download handlers
// Individual download handlers remain unchanged.
function attachDownloadListeners() {
document.querySelectorAll('.download-btn:not(.group-download-btn)').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const { url, name } = e.currentTarget.dataset;
const { url, name, type } = e.currentTarget.dataset;
e.currentTarget.remove();
downloadQueue.startAlbumDownload(url, { name })
downloadQueue.startAlbumDownload(url, { name, type })
.catch(err => showError('Download failed: ' + err.message));
});
});
@@ -205,4 +196,4 @@ function showError(message) {
function capitalize(str) {
return str ? str.charAt(0).toUpperCase() + str.slice(1) : '';
}
}

View File

@@ -62,13 +62,16 @@ async function performSearch() {
const data = await response.json();
if (data.error) throw new Error(data.error);
// When mapping the items, include the index so that each card gets a data-index attribute.
const items = data.data[`${searchType}s`]?.items;
if (!items?.length) {
resultsContainer.innerHTML = '<div class="error">No results found</div>';
return;
}
resultsContainer.innerHTML = items.map(item => createResultCard(item, searchType)).join('');
resultsContainer.innerHTML = items
.map((item, index) => createResultCard(item, searchType, index))
.join('');
attachDownloadListeners(items);
} catch (error) {
showError(error.message);
@@ -77,36 +80,40 @@ async function performSearch() {
/**
* Attaches event listeners to all download buttons (both standard and small versions).
* Instead of using the NodeList index (which can be off when multiple buttons are in one card),
* we look up the closest result cards data-index to get the correct item.
*/
function attachDownloadListeners(items) {
// Query for both download-btn and download-btn-small buttons.
document.querySelectorAll('.download-btn, .download-btn-small').forEach((btn, index) => {
document.querySelectorAll('.download-btn, .download-btn-small').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const url = e.currentTarget.dataset.url;
const type = e.currentTarget.dataset.type;
const albumType = e.currentTarget.dataset.albumType;
// If a main-download button is clicked (if present), remove its entire result card; otherwise just remove the button.
// Get the parent result card and its data-index
const card = e.currentTarget.closest('.result-card');
const idx = card ? card.getAttribute('data-index') : null;
const item = (idx !== null) ? items[idx] : null;
// Remove the button or card from the UI as appropriate.
if (e.currentTarget.classList.contains('main-download')) {
e.currentTarget.closest('.result-card').remove();
card.remove();
} else {
e.currentTarget.remove();
}
startDownload(url, type, items[index], albumType);
startDownload(url, type, item, albumType);
});
});
}
/**
* Calls the appropriate downloadQueue method based on the type.
* Before calling, it also enriches the item object with a proper "artist" value.
* For artists, this function will use the default parameters (which you can adjust)
* so that the backend endpoint (at /artist/download) receives the required query parameters.
*/
async function startDownload(url, type, item, albumType) {
// 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') {
if (type === 'track' || type === 'album') {
item.artist = item.artists.map(a => a.name).join(', ');
} else if (type === 'playlist') {
item.artist = item.owner.display_name;
@@ -122,6 +129,8 @@ async function startDownload(url, type, item, albumType) {
} else if (type === 'album') {
await downloadQueue.startAlbumDownload(url, item);
} else if (type === 'artist') {
// The downloadQueue.startArtistDownload should be implemented to call your
// backend artist endpoint (e.g. /artist/download) with proper query parameters.
await downloadQueue.startArtistDownload(url, item, albumType);
} else {
throw new Error(`Unsupported type: ${type}`);
@@ -147,7 +156,6 @@ function isSpotifyUrl(url) {
function getSpotifyResourceDetails(url) {
const urlObj = new URL(url);
const pathParts = urlObj.pathname.split('/');
// Expecting ['', type, id, ...]
if (pathParts.length < 3 || !pathParts[1] || !pathParts[2]) {
throw new Error('Invalid Spotify URL');
}
@@ -163,7 +171,11 @@ function msToMinutesSeconds(ms) {
return `${minutes}:${seconds.padStart(2, '0')}`;
}
function createResultCard(item, type) {
/**
* Create a result card for a search result.
* The additional parameter "index" is used to set a data-index attribute on the card.
*/
function createResultCard(item, type, index) {
let newUrl = '#';
try {
const spotifyUrl = item.external_urls.spotify;
@@ -185,7 +197,7 @@ function createResultCard(item, type) {
<span class="duration">${msToMinutesSeconds(item.duration_ms)}</span>
`;
return `
<div class="result-card" data-id="${item.id}">
<div class="result-card" data-id="${item.id}" data-index="${index}">
<div class="album-art-wrapper">
<img src="${imageUrl}" class="album-art" alt="${type} cover">
</div>
@@ -216,7 +228,7 @@ function createResultCard(item, type) {
<span class="duration">${item.description || 'No description'}</span>
`;
return `
<div class="result-card" data-id="${item.id}">
<div class="result-card" data-id="${item.id}" data-index="${index}">
<div class="album-art-wrapper">
<img src="${imageUrl}" class="album-art" alt="${type} cover">
</div>
@@ -247,7 +259,7 @@ function createResultCard(item, type) {
<span class="duration">${item.total_tracks} tracks</span>
`;
return `
<div class="result-card" data-id="${item.id}">
<div class="result-card" data-id="${item.id}" data-index="${index}">
<div class="album-art-wrapper">
<img src="${imageUrl}" class="album-art" alt="${type} cover">
</div>
@@ -275,13 +287,14 @@ function createResultCard(item, type) {
subtitle = (item.genres && item.genres.length) ? item.genres.join(', ') : 'Unknown genres';
details = `<span>Followers: ${item.followers?.total || 'N/A'}</span>`;
return `
<div class="result-card" data-id="${item.id}">
<div class="result-card" data-id="${item.id}" data-index="${index}">
<div class="album-art-wrapper">
<img src="${imageUrl}" class="album-art" alt="${type} cover">
</div>
<div class="title-and-view">
<div class="track-title">${title}</div>
<div class="title-buttons">
<!-- A primary download button (if you want one for a “default” download) -->
<button class="download-btn-small"
data-url="${item.external_urls.spotify}"
data-type="${type}"
@@ -295,7 +308,7 @@ function createResultCard(item, type) {
</div>
<div class="track-artist">${subtitle}</div>
<div class="track-details">${details}</div>
<!-- Removed the main "Download All Discography" button -->
<!-- Artist-specific download options -->
<div class="artist-download-buttons">
<div class="download-options-container">
<button class="options-toggle" onclick="this.nextElementSibling.classList.toggle('expanded')">
@@ -342,7 +355,7 @@ function createResultCard(item, type) {
subtitle = '';
details = '';
return `
<div class="result-card" data-id="${item.id}">
<div class="result-card" data-id="${item.id}" data-index="${index}">
<div class="album-art-wrapper">
<img src="${imageUrl}" class="album-art" alt="${type} cover">
</div>

View File

@@ -1,36 +1,31 @@
// queue.js
// --- NEW: Custom URLSearchParams class that does not encode specified keys ---
// --- MODIFIED: Custom URLSearchParams class that does not encode anything ---
class CustomURLSearchParams {
constructor(noEncodeKeys = []) {
constructor() {
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)}`;
}
})
.map(([key, value]) => `${key}=${value}`)
.join('&');
}
}
// --- END NEW ---
// --- END MODIFIED ---
class DownloadQueue {
constructor() {
this.downloadQueue = {};
this.prgInterval = null;
this.downloadQueue = {}; // keyed by unique queueId
this.currentConfig = {}; // Cache for current config
// Load the saved visible count (or default to 10)
const storedVisibleCount = localStorage.getItem("downloadQueueVisibleCount");
this.visibleCount = storedVisibleCount ? parseInt(storedVisibleCount, 10) : 10;
// Load the cached status info (object keyed by prgFile)
this.queueCache = JSON.parse(localStorage.getItem("downloadQueueCache") || "{}");
// Wait for initDOM to complete before setting up event listeners and loading existing PRG files.
this.initDOM().then(() => {
this.initEventListeners();
@@ -40,19 +35,31 @@ class DownloadQueue {
/* DOM Management */
async initDOM() {
// New HTML structure for the download queue.
const queueHTML = `
<div id="downloadQueue" class="sidebar right" hidden>
<div class="sidebar-header">
<h2>Download Queue</h2>
<button class="close-btn" aria-label="Close queue">&times;</button>
<h2>Download Queue (<span id="queueTotalCount">0</span> items)</h2>
<div class="header-actions">
<button id="cancelAllBtn" aria-label="Cancel all downloads">Cancel all</button>
<button class="close-btn" aria-label="Close queue">&times;</button>
</div>
</div>
<div id="queueItems" aria-live="polite"></div>
<div id="queueFooter"></div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', queueHTML);
// Load initial visibility from server config
// Load initial config from the server.
await this.loadConfig();
// Override the server value with locally persisted queue visibility (if present).
const storedVisible = localStorage.getItem("downloadQueueVisible");
if (storedVisible !== null) {
this.currentConfig.downloadQueueVisible = storedVisible === "true";
}
const queueSidebar = document.getElementById('downloadQueue');
queueSidebar.hidden = !this.currentConfig.downloadQueueVisible;
queueSidebar.classList.toggle('active', this.currentConfig.downloadQueueVisible);
@@ -60,6 +67,7 @@ class DownloadQueue {
/* Event Handling */
initEventListeners() {
// Toggle queue visibility via Escape key.
document.addEventListener('keydown', async (e) => {
const queueSidebar = document.getElementById('downloadQueue');
if (e.key === 'Escape' && queueSidebar.classList.contains('active')) {
@@ -67,6 +75,7 @@ class DownloadQueue {
}
});
// Close queue when the close button is clicked.
const queueSidebar = document.getElementById('downloadQueue');
if (queueSidebar) {
queueSidebar.addEventListener('click', async (e) => {
@@ -75,6 +84,32 @@ class DownloadQueue {
}
});
}
// "Cancel all" button.
const cancelAllBtn = document.getElementById('cancelAllBtn');
if (cancelAllBtn) {
cancelAllBtn.addEventListener('click', () => {
for (const queueId in this.downloadQueue) {
const entry = this.downloadQueue[queueId];
if (!entry.hasEnded) {
fetch(`/api/${entry.type}/download/cancel?prg_file=${entry.prgFile}`)
.then(response => response.json())
.then(data => {
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`);
if (logElement) logElement.textContent = "Download cancelled";
entry.hasEnded = true;
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
}
// Cleanup the entry after a short delay.
setTimeout(() => this.cleanupEntry(queueId), 5000);
})
.catch(error => console.error('Cancel error:', error));
}
}
});
}
}
/* Public API */
@@ -85,15 +120,17 @@ class DownloadQueue {
queueSidebar.classList.toggle('active', isVisible);
queueSidebar.hidden = !isVisible;
// Persist the state locally so it survives refreshes.
localStorage.setItem("downloadQueueVisible", isVisible);
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
// Revert UI if save failed.
queueSidebar.classList.toggle('active', !isVisible);
queueSidebar.hidden = isVisible;
this.dispatchEvent('queueVisibilityChanged', { visible: !isVisible });
@@ -110,49 +147,52 @@ class DownloadQueue {
}
/**
* Now accepts an extra argument "requestUrl" which is the same API call used to initiate the download.
* Adds a new download entry.
*/
addDownload(item, type, prgFile, requestUrl = null) {
const queueId = this.generateQueueId();
const entry = this.createQueueEntry(item, type, prgFile, queueId, requestUrl);
this.downloadQueue[queueId] = entry;
document.getElementById('queueItems').appendChild(entry.element);
this.startEntryMonitoring(queueId);
// Re-render and update which entries are processed.
this.updateQueueOrder();
this.dispatchEvent('downloadAdded', { queueId, item, type });
}
/* Start processing the entry only if it is visible. */
async startEntryMonitoring(queueId) {
const entry = this.downloadQueue[queueId];
if (!entry || entry.hasEnded) return;
if (entry.intervalId) return;
entry.intervalId = setInterval(async () => {
// Use the current prgFile value stored in the entry to build the log element id.
if (!this.isEntryVisible(queueId)) {
clearInterval(entry.intervalId);
entry.intervalId = null;
return;
}
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`);
if (entry.hasEnded) {
clearInterval(entry.intervalId);
return;
}
try {
const response = await fetch(`/api/prgs/${entry.prgFile}`);
const data = await response.json();
// Update the entry type from the API response if available.
if (data.type) {
entry.type = data.type;
}
// If the prg file info contains the original_request parameters and we haven't stored a retry URL yet,
// build one using the updated type and original_request parameters.
if (!entry.requestUrl && data.original_request) {
const params = new URLSearchParams(data.original_request).toString();
entry.requestUrl = `/api/${entry.type}/download?${params}`;
const params = new CustomURLSearchParams();
for (const key in data.original_request) {
params.append(key, data.original_request[key]);
}
entry.requestUrl = `/api/${entry.type}/download?${params.toString()}`;
}
const progress = data.last_line;
// NEW: If the progress data exists but has no "status" parameter, ignore it.
if (progress && typeof progress.status === 'undefined') {
if (entry.type === 'playlist') {
logElement.textContent = "Reading tracks list...";
@@ -160,7 +200,6 @@ class DownloadQueue {
this.updateQueueOrder();
return;
}
// If there's no progress at all, treat as inactivity.
if (!progress) {
if (entry.type === 'playlist') {
logElement.textContent = "Reading tracks list...";
@@ -170,19 +209,22 @@ class DownloadQueue {
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;
}
// Update the entry and cache.
entry.lastStatus = progress;
entry.lastUpdated = Date.now();
entry.status = progress.status;
logElement.textContent = this.getStatusMessage(progress);
// Save updated status to cache.
this.queueCache[entry.prgFile] = progress;
localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache));
if (['error', 'complete', 'cancel'].includes(progress.status)) {
this.handleTerminalState(entry, queueId, progress);
}
@@ -193,39 +235,47 @@ 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);
}
/**
* Now accepts a fifth parameter "requestUrl" and stores it in the entry.
* Creates a new queue entry. It checks localStorage for any cached info.
*/
createQueueEntry(item, type, prgFile, queueId, requestUrl) {
return {
// Build the basic entry.
const entry = {
item,
type,
prgFile,
requestUrl, // store the original API request URL so we can retry later
requestUrl, // for potential retry
element: this.createQueueItem(item, type, prgFile, queueId),
lastStatus: null,
lastUpdated: Date.now(),
hasEnded: false,
intervalId: null,
uniqueId: queueId,
retryCount: 0, // Initialize retry counter
autoRetryInterval: null // To store the countdown interval ID for auto retry
retryCount: 0,
autoRetryInterval: null
};
// If cached info exists for this PRG file, use it.
if (this.queueCache[prgFile]) {
entry.lastStatus = this.queueCache[prgFile];
const logEl = entry.element.querySelector('.log');
logEl.textContent = this.getStatusMessage(this.queueCache[prgFile]);
}
return entry;
}
/**
* Returns an HTML element for the queue entry.
*/
createQueueItem(item, type, prgFile, queueId) {
// Use "Reading track list" as the default message for playlists.
const defaultMessage = (type === 'playlist') ? 'Reading track list' : 'Initializing download...';
const div = document.createElement('article');
div.className = 'queue-item';
@@ -239,7 +289,6 @@ class DownloadQueue {
<img src="https://www.svgrepo.com/show/488384/skull-head.svg" alt="Cancel Download">
</button>
`;
div.querySelector('.cancel-btn').addEventListener('click', (e) => this.handleCancelDownload(e));
return div;
}
@@ -247,12 +296,10 @@ class DownloadQueue {
async handleCancelDownload(e) {
const btn = e.target.closest('button');
btn.style.display = 'none';
const { prg, type, queueid } = btn.dataset;
try {
const response = await fetch(`/api/${type}/download/cancel?prg_file=${encodeURIComponent(prg)}`);
const response = await fetch(`/api/${type}/download/cancel?prg_file=${prg}`);
const data = await response.json();
if (data.status === "cancel") {
const logElement = document.getElementById(`log-${queueid}-${prg}`);
logElement.textContent = "Download cancelled";
@@ -260,6 +307,7 @@ class DownloadQueue {
if (entry) {
entry.hasEnded = true;
clearInterval(entry.intervalId);
entry.intervalId = null;
}
setTimeout(() => this.cleanupEntry(queueid), 5000);
}
@@ -268,35 +316,116 @@ class DownloadQueue {
}
}
/* State Management */
async loadExistingPrgFiles() {
try {
const response = await fetch('/api/prgs/list');
const prgFiles = await response.json();
/* Reorders the queue display, updates the total count, and handles "Show more" */
updateQueueOrder() {
const container = document.getElementById('queueItems');
const footer = document.getElementById('queueFooter');
const entries = Object.values(this.downloadQueue);
for (const prgFile of prgFiles) {
const prgResponse = await fetch(`/api/prgs/${prgFile}`);
const prgData = await prgResponse.json();
const dummyItem = { name: prgData.name || prgFile, external_urls: {} };
// In this case, no original request URL is available.
this.addDownload(dummyItem, prgData.type || "unknown", prgFile);
// Sorting: errors/canceled first (group 0), ongoing next (group 1), queued last (group 2, sorted by position).
entries.sort((a, b) => {
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 {
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;
}
return a.lastUpdated - b.lastUpdated;
}
} catch (error) {
console.error('Error loading existing PRG files:', error);
});
document.getElementById('queueTotalCount').textContent = entries.length;
const visibleEntries = entries.slice(0, this.visibleCount);
container.innerHTML = '';
visibleEntries.forEach(entry => {
container.appendChild(entry.element);
if (!entry.intervalId) {
this.startEntryMonitoring(entry.uniqueId);
}
});
entries.slice(this.visibleCount).forEach(entry => {
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
}
});
footer.innerHTML = '';
if (entries.length > this.visibleCount) {
const remaining = entries.length - this.visibleCount;
const showMoreBtn = document.createElement('button');
showMoreBtn.textContent = `Show ${remaining} more`;
showMoreBtn.addEventListener('click', () => {
this.visibleCount += 10;
localStorage.setItem("downloadQueueVisibleCount", this.visibleCount);
this.updateQueueOrder();
});
footer.appendChild(showMoreBtn);
}
}
/* Checks if an entry is visible in the queue display. */
isEntryVisible(queueId) {
const entries = Object.values(this.downloadQueue);
entries.sort((a, b) => {
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 {
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;
}
return a.lastUpdated - b.lastUpdated;
}
});
const index = entries.findIndex(e => e.uniqueId === queueId);
return index >= 0 && index < this.visibleCount;
}
cleanupEntry(queueId) {
const entry = this.downloadQueue[queueId];
if (entry) {
clearInterval(entry.intervalId);
if (entry.intervalId) {
clearInterval(entry.intervalId);
}
if (entry.autoRetryInterval) {
clearInterval(entry.autoRetryInterval);
}
entry.element.remove();
delete this.downloadQueue[queueId];
fetch(`/api/prgs/delete/${encodeURIComponent(entry.prgFile)}`, { method: 'DELETE' })
// Remove the cached info.
if (this.queueCache[entry.prgFile]) {
delete this.queueCache[entry.prgFile];
localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache));
}
fetch(`/api/prgs/delete/${entry.prgFile}`, { method: 'DELETE' })
.catch(console.error);
this.updateQueueOrder();
}
}
@@ -307,39 +436,30 @@ class DownloadQueue {
/* Status Message Handling */
getStatusMessage(data) {
// Helper function to format an array into a human-readable list without a comma before "and".
function formatList(items) {
if (!items || items.length === 0) return '';
if (items.length === 1) return items[0];
if (items.length === 2) return `${items[0]} and ${items[1]}`;
return items.slice(0, -1).join(', ') + ' and ' + items[items.length - 1];
}
// Helper function for a simple pluralization:
function pluralize(word) {
return word.endsWith('s') ? word : word + 's';
}
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}...`;
}
return `Downloading ${data.type}...`;
case 'initializing':
if (data.type === 'playlist') {
return `Initializing playlist download "${data.name}" with ${data.total_tracks} tracks...`;
@@ -362,13 +482,11 @@ class DownloadQueue {
return `Initializing download for ${data.artist} with ${data.total_albums} album(s) [${data.album_type}]...`;
}
return `Initializing ${data.type} download...`;
case 'progress':
if (data.track && data.current_track) {
const parts = data.current_track.split('/');
const current = parts[0];
const total = parts[1] || '?';
if (data.type === 'playlist') {
return `Downloading playlist: Track ${current} of ${total} - ${data.track}`;
} else if (data.type === 'album') {
@@ -380,7 +498,6 @@ class DownloadQueue {
}
}
return `Progress: ${data.status}...`;
case 'done':
if (data.type === 'track') {
return `Finished track "${data.song}" by ${data.artist}`;
@@ -392,19 +509,14 @@ class DownloadQueue {
return `Finished artist "${data.artist}" (${data.album_type})`;
}
return `Finished ${data.type}`;
case 'retrying':
return `Track "${data.song}" by ${data.artist}" failed, retrying (${data.retry_count}/5) in ${data.seconds_left}s`;
case 'error':
return `Error: ${data.message || 'Unknown error'}`;
case 'complete':
return 'Download completed successfully';
case 'skipped':
return `Track "${data.song}" skipped, it already exists!`;
case 'real_time': {
const totalMs = data.time_elapsed;
const minutes = Math.floor(totalMs / 60000);
@@ -412,30 +524,22 @@ class DownloadQueue {
const paddedSeconds = seconds < 10 ? '0' + seconds : seconds;
return `Real-time downloading track "${data.song}" by ${data.artist} (${(data.percentage * 100).toFixed(1)}%). Time elapsed: ${minutes}:${paddedSeconds}`;
}
default:
return data.status;
}
}
/* New Methods to Handle Terminal State, Inactivity and Auto-Retry */
handleTerminalState(entry, queueId, progress) {
// Mark the entry as ended and clear its monitoring interval.
entry.hasEnded = true;
clearInterval(entry.intervalId);
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`);
if (!logElement) return;
if (progress.status === 'error') {
// Hide the cancel button.
const cancelBtn = entry.element.querySelector('.cancel-btn');
if (cancelBtn) {
cancelBtn.style.display = 'none';
}
// Display error message with retry buttons.
logElement.innerHTML = `
<div class="error-message">${this.getStatusMessage(progress)}</div>
<div class="error-buttons">
@@ -443,18 +547,13 @@ class DownloadQueue {
<button class="retry-btn" title="Retry">Retry</button>
</div>
`;
// Close (X) button: immediately remove the queue entry.
logElement.querySelector('.close-error-btn').addEventListener('click', () => {
// If an auto-retry countdown is running, clear it.
if (entry.autoRetryInterval) {
clearInterval(entry.autoRetryInterval);
entry.autoRetryInterval = null;
}
this.cleanupEntry(queueId);
});
// Manual Retry button: cancel the auto-retry timer (if running) and retry immediately.
logElement.querySelector('.retry-btn').addEventListener('click', async () => {
if (entry.autoRetryInterval) {
clearInterval(entry.autoRetryInterval);
@@ -462,16 +561,11 @@ class DownloadQueue {
}
this.retryDownload(queueId, logElement);
});
// --- Auto-Retry Logic ---
// Only auto-retry if we have a requestUrl.
if (entry.requestUrl) {
const maxRetries = 10;
if (entry.retryCount < maxRetries) {
const autoRetryDelay = 300; // seconds (5 minutes)
const autoRetryDelay = 300; // seconds
let secondsLeft = autoRetryDelay;
// Start a countdown that updates the error message every second.
entry.autoRetryInterval = setInterval(() => {
secondsLeft--;
const errorMsgEl = logElement.querySelector('.error-message');
@@ -486,17 +580,20 @@ class DownloadQueue {
}, 1000);
}
}
// Do not automatically clean up if an error occurred.
return;
} else {
// For non-error terminal states, update the message and then clean up after 5 seconds.
logElement.textContent = this.getStatusMessage(progress);
setTimeout(() => this.cleanupEntry(queueId), 5000);
}
}
handleInactivity(entry, queueId, logElement) {
// If no update in 5 minutes (300,000ms), treat as an error.
if (entry.lastStatus && entry.lastStatus.status === 'queued') {
if (logElement) {
logElement.textContent = this.getStatusMessage(entry.lastStatus);
}
return;
}
const now = Date.now();
if (now - entry.lastUpdated > 300000) {
const progress = { status: 'error', message: 'Inactivity timeout' };
@@ -508,13 +605,9 @@ class DownloadQueue {
}
}
/**
* retryDownload() handles both manual and automatic retries.
*/
async retryDownload(queueId, logElement) {
const entry = this.downloadQueue[queueId];
if (!entry) return;
logElement.textContent = 'Retrying download...';
if (!entry.requestUrl) {
logElement.textContent = 'Retry not available: missing original request information.';
@@ -524,23 +617,16 @@ class DownloadQueue {
const retryResponse = await fetch(entry.requestUrl);
const retryData = await retryResponse.json();
if (retryData.prg_file) {
// Delete the failed prg file before updating to the new one.
const oldPrgFile = entry.prgFile;
await fetch(`/api/prgs/delete/${encodeURIComponent(oldPrgFile)}`, { method: 'DELETE' });
// Update the log element's id to reflect the new prg_file.
await fetch(`/api/prgs/delete/${oldPrgFile}`, { method: 'DELETE' });
const logEl = entry.element.querySelector('.log');
logEl.id = `log-${entry.uniqueId}-${retryData.prg_file}`;
// Update the entry with the new prg_file and reset its state.
entry.prgFile = retryData.prg_file;
entry.lastStatus = null;
entry.hasEnded = false;
entry.lastUpdated = Date.now();
entry.retryCount = (entry.retryCount || 0) + 1;
logEl.textContent = 'Retry initiated...';
// Restart monitoring using the new prg_file.
this.startEntryMonitoring(queueId);
} else {
logElement.textContent = 'Retry failed: invalid response from server';
@@ -552,51 +638,23 @@ class DownloadQueue {
/**
* 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 ---
const params = new CustomURLSearchParams();
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);
}
@@ -620,11 +678,9 @@ class DownloadQueue {
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');
@@ -640,11 +696,9 @@ class DownloadQueue {
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();
@@ -655,15 +709,38 @@ class DownloadQueue {
}
}
async startArtistDownload(url, item, albumType = 'album,single,compilation,appears_on') {
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);
params.append('name', item.name || '');
params.append('artist', item.artist || '');
const apiUrl = `/api/artist/download?${params.toString()}`;
try {
const response = await fetch(apiUrl);
if (!response.ok) throw new Error('Network error');
const data = await response.json();
if (data.album_prg_files && Array.isArray(data.album_prg_files)) {
data.album_prg_files.forEach(prgFile => {
this.addDownload(item, 'album', prgFile, apiUrl);
});
} else if (data.prg_file) {
this.addDownload(item, 'album', 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();
@@ -673,24 +750,40 @@ class DownloadQueue {
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()}`;
/**
* Loads existing PRG files from the /api/prgs/list endpoint and adds them as queue entries.
*/
async loadExistingPrgFiles() {
try {
const response = await fetch(apiUrl);
const data = await response.json();
this.addDownload(item, 'artist', data.prg_file, apiUrl);
const response = await fetch('/api/prgs/list');
const prgFiles = await response.json();
// Sort filenames by the numeric portion (assumes format "type_number.prg").
prgFiles.sort((a, b) => {
const numA = parseInt(a.split('_')[1]);
const numB = parseInt(b.split('_')[1]);
return numA - numB;
});
// Iterate through each PRG file and add it as a dummy queue entry.
for (const prgFile of prgFiles) {
try {
const prgResponse = await fetch(`/api/prgs/${prgFile}`);
if (!prgResponse.ok) continue;
const prgData = await prgResponse.json();
const dummyItem = {
name: prgData.original_request && prgData.original_request.name ? prgData.original_request.name : prgFile,
artist: prgData.original_request && prgData.original_request.artist ? prgData.original_request.artist : '',
type: prgData.original_request && prgData.original_request.type ? prgData.original_request.type : 'unknown'
};
this.addDownload(dummyItem, dummyItem.type, prgFile);
} catch (error) {
console.error("Error fetching details for", prgFile, error);
}
}
} catch (error) {
this.dispatchEvent('downloadError', { error, item });
throw error;
console.error("Error loading existing PRG files:", error);
}
}
@@ -705,7 +798,6 @@ class DownloadQueue {
}
}
// Placeholder for saveConfig; implement as needed.
async saveConfig(updatedConfig) {
try {
const response = await fetch('/api/config', {
@@ -720,53 +812,6 @@ class DownloadQueue {
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