improved queue handling and artist downloading
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) : '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 card’s 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>
|
||||
|
||||
@@ -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">×</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">×</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
|
||||
|
||||
Reference in New Issue
Block a user