meh
This commit is contained in:
@@ -201,6 +201,14 @@ input:checked + .slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* Setting description */
|
||||
.setting-description {
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: #b3b3b3;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Service Tabs */
|
||||
.service-tabs {
|
||||
display: flex;
|
||||
@@ -387,6 +395,16 @@ input:checked + .slider:before {
|
||||
min-height: 1.2rem;
|
||||
}
|
||||
|
||||
/* Success Messages */
|
||||
#configSuccess {
|
||||
color: #1db954;
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
min-height: 1.2rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* MOBILE RESPONSIVENESS */
|
||||
@media (max-width: 768px) {
|
||||
.config-container {
|
||||
|
||||
BIN
static/images/placeholder.jpg
Normal file
BIN
static/images/placeholder.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
@@ -9,18 +9,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the config to get active Spotify account first
|
||||
fetch('/api/config')
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Failed to fetch config');
|
||||
return response.json();
|
||||
})
|
||||
.then(config => {
|
||||
const mainAccount = config.spotify || '';
|
||||
|
||||
// Then fetch album info with the main parameter
|
||||
return fetch(`/api/album/info?id=${encodeURIComponent(albumId)}&main=${mainAccount}`);
|
||||
})
|
||||
// Fetch album info directly
|
||||
fetch(`/api/album/info?id=${encodeURIComponent(albumId)}`)
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
return response.json();
|
||||
@@ -48,19 +38,21 @@ function renderAlbum(album) {
|
||||
|
||||
// Set album header info.
|
||||
document.getElementById('album-name').innerHTML =
|
||||
`<a href="${baseUrl}/album/${album.id}">${album.name}</a>`;
|
||||
`<a href="${baseUrl}/album/${album.id || ''}">${album.name || 'Unknown Album'}</a>`;
|
||||
|
||||
document.getElementById('album-artist').innerHTML =
|
||||
`By ${album.artists.map(artist => `<a href="${baseUrl}/artist/${artist.id}">${artist.name}</a>`).join(', ')}`;
|
||||
`By ${album.artists?.map(artist =>
|
||||
`<a href="${baseUrl}/artist/${artist?.id || ''}">${artist?.name || 'Unknown Artist'}</a>`
|
||||
).join(', ') || 'Unknown Artist'}`;
|
||||
|
||||
const releaseYear = new Date(album.release_date).getFullYear();
|
||||
const releaseYear = album.release_date ? new Date(album.release_date).getFullYear() : 'N/A';
|
||||
document.getElementById('album-stats').textContent =
|
||||
`${releaseYear} • ${album.total_tracks} songs • ${album.label}`;
|
||||
`${releaseYear} • ${album.total_tracks || '0'} songs • ${album.label || 'Unknown Label'}`;
|
||||
|
||||
document.getElementById('album-copyright').textContent =
|
||||
album.copyrights.map(c => c.text).join(' • ');
|
||||
album.copyrights?.map(c => c?.text || '').filter(text => text).join(' • ') || '';
|
||||
|
||||
const image = album.images[0]?.url || 'placeholder.jpg';
|
||||
const image = album.images?.[0]?.url || '/static/images/placeholder.jpg';
|
||||
document.getElementById('album-image').src = image;
|
||||
|
||||
// Create (if needed) the Home Button.
|
||||
@@ -107,7 +99,7 @@ function renderAlbum(album) {
|
||||
downloadAlbumBtn.textContent = 'Queued!';
|
||||
})
|
||||
.catch(err => {
|
||||
showError('Failed to queue album download: ' + err.message);
|
||||
showError('Failed to queue album download: ' + (err?.message || 'Unknown error'));
|
||||
downloadAlbumBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
@@ -116,30 +108,36 @@ function renderAlbum(album) {
|
||||
const tracksList = document.getElementById('tracks-list');
|
||||
tracksList.innerHTML = '';
|
||||
|
||||
album.tracks.items.forEach((track, index) => {
|
||||
const trackElement = document.createElement('div');
|
||||
trackElement.className = 'track';
|
||||
trackElement.innerHTML = `
|
||||
<div class="track-number">${index + 1}</div>
|
||||
<div class="track-info">
|
||||
<div class="track-name">
|
||||
<a href="${baseUrl}/track/${track.id}">${track.name}</a>
|
||||
if (album.tracks?.items) {
|
||||
album.tracks.items.forEach((track, index) => {
|
||||
if (!track) return; // Skip null or undefined tracks
|
||||
|
||||
const trackElement = document.createElement('div');
|
||||
trackElement.className = 'track';
|
||||
trackElement.innerHTML = `
|
||||
<div class="track-number">${index + 1}</div>
|
||||
<div class="track-info">
|
||||
<div class="track-name">
|
||||
<a href="${baseUrl}/track/${track.id || ''}">${track.name || 'Unknown Track'}</a>
|
||||
</div>
|
||||
<div class="track-artist">
|
||||
${track.artists?.map(a =>
|
||||
`<a href="${baseUrl}/artist/${a?.id || ''}">${a?.name || 'Unknown Artist'}</a>`
|
||||
).join(', ') || 'Unknown Artist'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="track-artist">
|
||||
${track.artists.map(a => `<a href="${baseUrl}/artist/${a.id}">${a.name}</a>`).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="track-duration">${msToTime(track.duration_ms)}</div>
|
||||
<button class="download-btn download-btn--circle"
|
||||
data-url="${track.external_urls.spotify}"
|
||||
data-type="track"
|
||||
data-name="${track.name}"
|
||||
title="Download">
|
||||
<img src="/static/images/download.svg" alt="Download">
|
||||
</button>
|
||||
`;
|
||||
tracksList.appendChild(trackElement);
|
||||
});
|
||||
<div class="track-duration">${msToTime(track.duration_ms || 0)}</div>
|
||||
<button class="download-btn download-btn--circle"
|
||||
data-url="${track.external_urls?.spotify || ''}"
|
||||
data-type="track"
|
||||
data-name="${track.name || 'Unknown Track'}"
|
||||
title="Download">
|
||||
<img src="/static/images/download.svg" alt="Download">
|
||||
</button>
|
||||
`;
|
||||
tracksList.appendChild(trackElement);
|
||||
});
|
||||
}
|
||||
|
||||
// Reveal header and track list.
|
||||
document.getElementById('album-header').classList.remove('hidden');
|
||||
@@ -166,11 +164,15 @@ function renderAlbum(album) {
|
||||
}
|
||||
|
||||
async function downloadWholeAlbum(album) {
|
||||
const url = album.external_urls.spotify;
|
||||
const url = album.external_urls?.spotify || '';
|
||||
if (!url) {
|
||||
throw new Error('Missing album URL');
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadQueue.startAlbumDownload(url, { name: album.name });
|
||||
await downloadQueue.startAlbumDownload(url, { name: album.name || 'Unknown Album' });
|
||||
} catch (error) {
|
||||
showError('Album download failed: ' + error.message);
|
||||
showError('Album download failed: ' + (error?.message || 'Unknown error'));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -183,7 +185,7 @@ function msToTime(duration) {
|
||||
|
||||
function showError(message) {
|
||||
const errorEl = document.getElementById('error');
|
||||
errorEl.textContent = message;
|
||||
errorEl.textContent = message || 'An error occurred';
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
@@ -192,9 +194,9 @@ function attachDownloadListeners() {
|
||||
if (btn.id === 'downloadAlbumBtn') return;
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const url = e.currentTarget.dataset.url;
|
||||
const type = e.currentTarget.dataset.type;
|
||||
const name = e.currentTarget.dataset.name || extractName(url);
|
||||
const url = e.currentTarget.dataset.url || '';
|
||||
const type = e.currentTarget.dataset.type || '';
|
||||
const name = e.currentTarget.dataset.name || extractName(url) || 'Unknown';
|
||||
// Remove the button immediately after click.
|
||||
e.currentTarget.remove();
|
||||
startDownload(url, type, { name });
|
||||
@@ -203,47 +205,25 @@ function attachDownloadListeners() {
|
||||
}
|
||||
|
||||
async function startDownload(url, type, item, albumType) {
|
||||
const config = JSON.parse(localStorage.getItem('activeConfig')) || {};
|
||||
const {
|
||||
fallback = false,
|
||||
spotify = '',
|
||||
deezer = '',
|
||||
spotifyQuality = 'NORMAL',
|
||||
deezerQuality = 'MP3_128',
|
||||
realTime = false,
|
||||
customDirFormat = '',
|
||||
customTrackFormat = ''
|
||||
} = config;
|
||||
|
||||
if (!url) {
|
||||
showError('Missing URL for download');
|
||||
return;
|
||||
}
|
||||
|
||||
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
|
||||
let apiUrl = '';
|
||||
let apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`;
|
||||
|
||||
if (type === 'album') {
|
||||
apiUrl = `/api/album/download?service=${service}&url=${encodeURIComponent(url)}`;
|
||||
} else if (type === 'artist') {
|
||||
apiUrl = `/api/artist/download?service=${service}&artist_url=${encodeURIComponent(url)}&album_type=${encodeURIComponent(albumType || 'album,single,compilation')}`;
|
||||
} else {
|
||||
apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`;
|
||||
// Add name and artist if available for better progress display
|
||||
if (item.name) {
|
||||
apiUrl += `&name=${encodeURIComponent(item.name)}`;
|
||||
}
|
||||
|
||||
if (fallback && service === 'spotify') {
|
||||
apiUrl += `&main=${deezer}&fallback=${spotify}`;
|
||||
apiUrl += `&quality=${deezerQuality}&fall_quality=${spotifyQuality}`;
|
||||
} else {
|
||||
const mainAccount = service === 'spotify' ? spotify : deezer;
|
||||
apiUrl += `&main=${mainAccount}&quality=${service === 'spotify' ? spotifyQuality : deezerQuality}`;
|
||||
if (item.artist) {
|
||||
apiUrl += `&artist=${encodeURIComponent(item.artist)}`;
|
||||
}
|
||||
|
||||
if (realTime) {
|
||||
apiUrl += '&real_time=true';
|
||||
}
|
||||
|
||||
// Append custom directory and file format settings if provided.
|
||||
if (customDirFormat) {
|
||||
apiUrl += `&custom_dir_format=${encodeURIComponent(customDirFormat)}`;
|
||||
}
|
||||
if (customTrackFormat) {
|
||||
apiUrl += `&custom_file_format=${encodeURIComponent(customTrackFormat)}`;
|
||||
|
||||
// For artist downloads, include album_type
|
||||
if (type === 'artist' && albumType) {
|
||||
apiUrl += `&album_type=${encodeURIComponent(albumType)}`;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -251,10 +231,10 @@ async function startDownload(url, type, item, albumType) {
|
||||
const data = await response.json();
|
||||
downloadQueue.addDownload(item, type, data.prg_file);
|
||||
} catch (error) {
|
||||
showError('Download failed: ' + error.message);
|
||||
showError('Download failed: ' + (error?.message || 'Unknown error'));
|
||||
}
|
||||
}
|
||||
|
||||
function extractName(url) {
|
||||
return url;
|
||||
return url || 'Unknown';
|
||||
}
|
||||
|
||||
@@ -10,18 +10,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the config to get active Spotify account first
|
||||
fetch('/api/config')
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Failed to fetch config');
|
||||
return response.json();
|
||||
})
|
||||
.then(config => {
|
||||
const mainAccount = config.spotify || '';
|
||||
|
||||
// Then fetch artist info with the main parameter
|
||||
return fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}&main=${mainAccount}`);
|
||||
})
|
||||
// Fetch artist info directly
|
||||
fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}`)
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
return response.json();
|
||||
@@ -42,13 +32,13 @@ function renderArtist(artistData, artistId) {
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
document.getElementById('error').classList.add('hidden');
|
||||
|
||||
const firstAlbum = artistData.items[0];
|
||||
const artistName = firstAlbum?.artists[0]?.name || 'Unknown Artist';
|
||||
const artistImage = firstAlbum?.images[0]?.url || 'placeholder.jpg';
|
||||
const firstAlbum = artistData.items?.[0] || {};
|
||||
const artistName = firstAlbum?.artists?.[0]?.name || 'Unknown Artist';
|
||||
const artistImage = firstAlbum?.images?.[0]?.url || '/static/images/placeholder.jpg';
|
||||
|
||||
document.getElementById('artist-name').innerHTML =
|
||||
`<a href="/artist/${artistId}" class="artist-link">${artistName}</a>`;
|
||||
document.getElementById('artist-stats').textContent = `${artistData.total} albums`;
|
||||
document.getElementById('artist-stats').textContent = `${artistData.total || '0'} albums`;
|
||||
document.getElementById('artist-image').src = artistImage;
|
||||
|
||||
// Define the artist URL (used by both full-discography and group downloads)
|
||||
@@ -93,13 +83,14 @@ function renderArtist(artistData, artistId) {
|
||||
.catch(err => {
|
||||
downloadArtistBtn.textContent = 'Download All Discography';
|
||||
downloadArtistBtn.disabled = false;
|
||||
showError('Failed to queue artist download: ' + err.message);
|
||||
showError('Failed to queue artist download: ' + (err?.message || 'Unknown error'));
|
||||
});
|
||||
});
|
||||
|
||||
// Group albums by type (album, single, compilation, etc.)
|
||||
const albumGroups = artistData.items.reduce((groups, album) => {
|
||||
const type = album.album_type.toLowerCase();
|
||||
const albumGroups = (artistData.items || []).reduce((groups, album) => {
|
||||
if (!album) return groups;
|
||||
const type = (album.album_type || 'unknown').toLowerCase();
|
||||
if (!groups[type]) groups[type] = [];
|
||||
groups[type].push(album);
|
||||
return groups;
|
||||
@@ -126,22 +117,24 @@ function renderArtist(artistData, artistId) {
|
||||
|
||||
const albumsContainer = groupSection.querySelector('.albums-list');
|
||||
albums.forEach(album => {
|
||||
if (!album) return;
|
||||
|
||||
const albumElement = document.createElement('div');
|
||||
albumElement.className = 'album-card';
|
||||
albumElement.innerHTML = `
|
||||
<a href="/album/${album.id}" class="album-link">
|
||||
<img src="${album.images[1]?.url || album.images[0]?.url || 'placeholder.jpg'}"
|
||||
<a href="/album/${album.id || ''}" class="album-link">
|
||||
<img src="${album.images?.[1]?.url || album.images?.[0]?.url || '/static/images/placeholder.jpg'}"
|
||||
alt="Album cover"
|
||||
class="album-cover">
|
||||
</a>
|
||||
<div class="album-info">
|
||||
<div class="album-title">${album.name}</div>
|
||||
<div class="album-artist">${album.artists.map(a => a.name).join(', ')}</div>
|
||||
<div class="album-title">${album.name || 'Unknown Album'}</div>
|
||||
<div class="album-artist">${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}</div>
|
||||
</div>
|
||||
<button class="download-btn download-btn--circle"
|
||||
data-url="${album.external_urls.spotify}"
|
||||
data-type="${album.album_type}"
|
||||
data-name="${album.name}"
|
||||
data-url="${album.external_urls?.spotify || ''}"
|
||||
data-type="${album.album_type || 'album'}"
|
||||
data-name="${album.name || 'Unknown Album'}"
|
||||
title="Download">
|
||||
<img src="/static/images/download.svg" alt="Download">
|
||||
</button>
|
||||
@@ -164,7 +157,7 @@ function renderArtist(artistData, artistId) {
|
||||
function attachGroupDownloadListeners(artistUrl, artistName) {
|
||||
document.querySelectorAll('.group-download-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
const groupType = e.target.dataset.groupType; // e.g. "album", "single", "compilation"
|
||||
const groupType = e.target.dataset.groupType || 'album'; // e.g. "album", "single", "compilation"
|
||||
e.target.disabled = true;
|
||||
e.target.textContent = `Queueing all ${capitalize(groupType)}s...`;
|
||||
|
||||
@@ -172,14 +165,14 @@ function attachGroupDownloadListeners(artistUrl, artistName) {
|
||||
// Use the artist download function with the group type filter.
|
||||
await downloadQueue.startArtistDownload(
|
||||
artistUrl,
|
||||
{ name: artistName, artist: artistName },
|
||||
{ name: artistName || 'Unknown Artist', artist: artistName || 'Unknown Artist' },
|
||||
groupType // Only queue releases of this specific type.
|
||||
);
|
||||
e.target.textContent = `Queued all ${capitalize(groupType)}s`;
|
||||
} catch (error) {
|
||||
e.target.textContent = `Download All ${capitalize(groupType)}s`;
|
||||
e.target.disabled = false;
|
||||
showError(`Failed to queue download for all ${groupType}s: ${error.message}`);
|
||||
showError(`Failed to queue download for all ${groupType}s: ${error?.message || 'Unknown error'}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -190,10 +183,13 @@ function attachDownloadListeners() {
|
||||
document.querySelectorAll('.download-btn:not(.group-download-btn)').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const { url, name, type } = e.currentTarget.dataset;
|
||||
const url = e.currentTarget.dataset.url || '';
|
||||
const name = e.currentTarget.dataset.name || 'Unknown';
|
||||
const type = e.currentTarget.dataset.type || 'album';
|
||||
|
||||
e.currentTarget.remove();
|
||||
downloadQueue.startAlbumDownload(url, { name, type })
|
||||
.catch(err => showError('Download failed: ' + err.message));
|
||||
.catch(err => showError('Download failed: ' + (err?.message || 'Unknown error')));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -201,8 +197,10 @@ function attachDownloadListeners() {
|
||||
// UI Helpers
|
||||
function showError(message) {
|
||||
const errorEl = document.getElementById('error');
|
||||
errorEl.textContent = message;
|
||||
errorEl.classList.remove('hidden');
|
||||
if (errorEl) {
|
||||
errorEl.textContent = message || 'An error occurred';
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function capitalize(str) {
|
||||
|
||||
@@ -83,6 +83,9 @@ function setupEventListeners() {
|
||||
document.getElementById('realTimeToggle').addEventListener('change', saveConfig);
|
||||
document.getElementById('spotifyQualitySelect').addEventListener('change', saveConfig);
|
||||
document.getElementById('deezerQualitySelect').addEventListener('change', saveConfig);
|
||||
document.getElementById('tracknumPaddingToggle').addEventListener('change', saveConfig);
|
||||
document.getElementById('maxRetries').addEventListener('change', saveConfig);
|
||||
document.getElementById('retryDelaySeconds').addEventListener('change', saveConfig);
|
||||
|
||||
// Update active account globals when the account selector is changed.
|
||||
document.getElementById('spotifyAccountSelect').addEventListener('change', (e) => {
|
||||
@@ -350,11 +353,41 @@ function toggleSearchFieldsVisibility(showSearchFields) {
|
||||
const searchFieldsDiv = document.getElementById('searchFields');
|
||||
|
||||
if (showSearchFields) {
|
||||
// Hide regular fields and remove 'required' attribute
|
||||
serviceFieldsDiv.style.display = 'none';
|
||||
// Remove required attribute from service fields
|
||||
serviceConfig[currentService].fields.forEach(field => {
|
||||
const input = document.getElementById(field.id);
|
||||
if (input) input.removeAttribute('required');
|
||||
});
|
||||
|
||||
// Show search fields and add 'required' attribute
|
||||
searchFieldsDiv.style.display = 'block';
|
||||
// Make search fields required
|
||||
if (currentService === 'spotify' && serviceConfig[currentService].searchFields) {
|
||||
serviceConfig[currentService].searchFields.forEach(field => {
|
||||
const input = document.getElementById(field.id);
|
||||
if (input) input.setAttribute('required', '');
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Show regular fields and add 'required' attribute
|
||||
serviceFieldsDiv.style.display = 'block';
|
||||
// Make service fields required
|
||||
serviceConfig[currentService].fields.forEach(field => {
|
||||
const input = document.getElementById(field.id);
|
||||
if (input) input.setAttribute('required', '');
|
||||
});
|
||||
|
||||
// Hide search fields and remove 'required' attribute
|
||||
searchFieldsDiv.style.display = 'none';
|
||||
// Remove required from search fields
|
||||
if (currentService === 'spotify' && serviceConfig[currentService].searchFields) {
|
||||
serviceConfig[currentService].searchFields.forEach(field => {
|
||||
const input = document.getElementById(field.id);
|
||||
if (input) input.removeAttribute('required');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,9 +464,25 @@ async function handleCredentialSubmit(e) {
|
||||
if (isEditingSearch && service === 'spotify') {
|
||||
// Handle search credentials
|
||||
const formData = {};
|
||||
let isValid = true;
|
||||
let firstInvalidField = null;
|
||||
|
||||
// Manually validate search fields
|
||||
serviceConfig[service].searchFields.forEach(field => {
|
||||
formData[field.id] = document.getElementById(field.id).value.trim();
|
||||
const input = document.getElementById(field.id);
|
||||
const value = input ? input.value.trim() : '';
|
||||
formData[field.id] = value;
|
||||
|
||||
if (!value) {
|
||||
isValid = false;
|
||||
if (!firstInvalidField) firstInvalidField = input;
|
||||
}
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
if (firstInvalidField) firstInvalidField.focus();
|
||||
throw new Error('All fields are required');
|
||||
}
|
||||
|
||||
data = serviceConfig[service].searchValidator(formData);
|
||||
endpoint = `/api/credentials/${service}/${endpointName}?type=search`;
|
||||
@@ -444,9 +493,25 @@ async function handleCredentialSubmit(e) {
|
||||
} else {
|
||||
// Handle regular account credentials
|
||||
const formData = {};
|
||||
let isValid = true;
|
||||
let firstInvalidField = null;
|
||||
|
||||
// Manually validate account fields
|
||||
serviceConfig[service].fields.forEach(field => {
|
||||
formData[field.id] = document.getElementById(field.id).value.trim();
|
||||
const input = document.getElementById(field.id);
|
||||
const value = input ? input.value.trim() : '';
|
||||
formData[field.id] = value;
|
||||
|
||||
if (!value) {
|
||||
isValid = false;
|
||||
if (!firstInvalidField) firstInvalidField = input;
|
||||
}
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
if (firstInvalidField) firstInvalidField.focus();
|
||||
throw new Error('All fields are required');
|
||||
}
|
||||
|
||||
data = serviceConfig[service].validator(formData);
|
||||
endpoint = `/api/credentials/${service}/${endpointName}`;
|
||||
@@ -468,6 +533,9 @@ async function handleCredentialSubmit(e) {
|
||||
await saveConfig();
|
||||
loadCredentials(service);
|
||||
resetForm();
|
||||
|
||||
// Show success message
|
||||
showConfigSuccess(isEditingSearch ? 'API credentials saved successfully' : 'Account saved successfully');
|
||||
} catch (error) {
|
||||
showConfigError(error.message);
|
||||
}
|
||||
@@ -501,7 +569,11 @@ async function saveConfig() {
|
||||
realTime: document.getElementById('realTimeToggle').checked,
|
||||
customDirFormat: document.getElementById('customDirFormat').value,
|
||||
customTrackFormat: document.getElementById('customTrackFormat').value,
|
||||
maxConcurrentDownloads: parseInt(document.getElementById('maxConcurrentDownloads').value, 10) || 3
|
||||
maxConcurrentDownloads: parseInt(document.getElementById('maxConcurrentDownloads').value, 10) || 3,
|
||||
maxRetries: parseInt(document.getElementById('maxRetries').value, 10) || 3,
|
||||
retryDelaySeconds: parseInt(document.getElementById('retryDelaySeconds').value, 10) || 5,
|
||||
retry_delay_increase: parseInt(document.getElementById('retryDelayIncrease').value, 10) || 5,
|
||||
tracknum_padding: document.getElementById('tracknumPaddingToggle').checked
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -546,6 +618,10 @@ async function loadConfig() {
|
||||
document.getElementById('customDirFormat').value = savedConfig.customDirFormat || '%ar_album%/%album%';
|
||||
document.getElementById('customTrackFormat').value = savedConfig.customTrackFormat || '%tracknum%. %music%';
|
||||
document.getElementById('maxConcurrentDownloads').value = savedConfig.maxConcurrentDownloads || '3';
|
||||
document.getElementById('maxRetries').value = savedConfig.maxRetries || '3';
|
||||
document.getElementById('retryDelaySeconds').value = savedConfig.retryDelaySeconds || '5';
|
||||
document.getElementById('retryDelayIncrease').value = savedConfig.retry_delay_increase || '5';
|
||||
document.getElementById('tracknumPaddingToggle').checked = savedConfig.tracknum_padding === undefined ? true : !!savedConfig.tracknum_padding;
|
||||
} catch (error) {
|
||||
showConfigError('Error loading config: ' + error.message);
|
||||
}
|
||||
@@ -556,3 +632,9 @@ function showConfigError(message) {
|
||||
errorDiv.textContent = message;
|
||||
setTimeout(() => (errorDiv.textContent = ''), 5000);
|
||||
}
|
||||
|
||||
function showConfigSuccess(message) {
|
||||
const successDiv = document.getElementById('configSuccess');
|
||||
successDiv.textContent = message;
|
||||
setTimeout(() => (successDiv.textContent = ''), 5000);
|
||||
}
|
||||
|
||||
@@ -14,25 +14,42 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
// Save the search type to local storage whenever it changes
|
||||
searchType.addEventListener('change', () => {
|
||||
localStorage.setItem('searchType', searchType.value);
|
||||
});
|
||||
if (searchType) {
|
||||
searchType.addEventListener('change', () => {
|
||||
localStorage.setItem('searchType', searchType.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize queue icon
|
||||
queueIcon.addEventListener('click', () => downloadQueue.toggleVisibility());
|
||||
if (queueIcon) {
|
||||
queueIcon.addEventListener('click', () => downloadQueue.toggleVisibility());
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
searchButton.addEventListener('click', performSearch);
|
||||
searchInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') performSearch();
|
||||
});
|
||||
if (searchButton) {
|
||||
searchButton.addEventListener('click', performSearch);
|
||||
}
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') performSearch();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function performSearch() {
|
||||
const query = document.getElementById('searchInput').value.trim();
|
||||
const searchType = document.getElementById('searchType').value;
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const searchType = document.getElementById('searchType');
|
||||
const resultsContainer = document.getElementById('resultsContainer');
|
||||
|
||||
if (!searchInput || !searchType || !resultsContainer) {
|
||||
console.error('Required DOM elements not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const query = searchInput.value.trim();
|
||||
const typeValue = searchType.value;
|
||||
|
||||
if (!query) {
|
||||
showError('Please enter a search term');
|
||||
return;
|
||||
@@ -50,7 +67,7 @@ async function performSearch() {
|
||||
window.location.href = `${window.location.origin}/${type}/${id}`;
|
||||
return;
|
||||
} catch (error) {
|
||||
showError(`Invalid Spotify URL: ${error.message}`);
|
||||
showError(`Invalid Spotify URL: ${error?.message || 'Unknown error'}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -61,26 +78,27 @@ async function performSearch() {
|
||||
// Fetch config to get active Spotify account
|
||||
const configResponse = await fetch('/api/config');
|
||||
const config = await configResponse.json();
|
||||
const mainAccount = config.spotify || '';
|
||||
const mainAccount = config?.spotify || '';
|
||||
|
||||
// Add the main parameter to the search API call
|
||||
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&search_type=${searchType}&limit=50&main=${mainAccount}`);
|
||||
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&search_type=${typeValue}&limit=50&main=${mainAccount}`);
|
||||
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;
|
||||
const items = data.data?.[`${typeValue}s`]?.items;
|
||||
if (!items?.length) {
|
||||
resultsContainer.innerHTML = '<div class="error">No results found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
resultsContainer.innerHTML = items
|
||||
.map((item, index) => createResultCard(item, searchType, index))
|
||||
.map((item, index) => item ? createResultCard(item, typeValue, index) : '')
|
||||
.filter(card => card) // Filter out empty strings
|
||||
.join('');
|
||||
attachDownloadListeners(items);
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
showError(error?.message || 'Search failed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,21 +111,24 @@ function attachDownloadListeners(items) {
|
||||
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;
|
||||
const url = e.currentTarget.dataset.url || '';
|
||||
const type = e.currentTarget.dataset.type || '';
|
||||
const albumType = e.currentTarget.dataset.albumType || '';
|
||||
// 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;
|
||||
const item = (idx !== null && items[idx]) ? items[idx] : null;
|
||||
|
||||
// Remove the button or card from the UI as appropriate.
|
||||
if (e.currentTarget.classList.contains('main-download')) {
|
||||
card.remove();
|
||||
if (card) card.remove();
|
||||
} else {
|
||||
e.currentTarget.remove();
|
||||
}
|
||||
startDownload(url, type, item, albumType);
|
||||
|
||||
if (url && type) {
|
||||
startDownload(url, type, item, albumType);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -118,13 +139,22 @@ function attachDownloadListeners(items) {
|
||||
* so that the backend endpoint (at /artist/download) receives the required query parameters.
|
||||
*/
|
||||
async function startDownload(url, type, item, albumType) {
|
||||
if (!url || !type) {
|
||||
showError('Missing URL or type for download');
|
||||
return;
|
||||
}
|
||||
|
||||
// Enrich the item object with the artist property.
|
||||
if (type === 'track' || type === 'album') {
|
||||
item.artist = item.artists.map(a => a.name).join(', ');
|
||||
} else if (type === 'playlist') {
|
||||
item.artist = item.owner.display_name;
|
||||
} else if (type === 'artist') {
|
||||
item.artist = item.name;
|
||||
if (item) {
|
||||
if (type === 'track' || type === 'album') {
|
||||
item.artist = item.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist';
|
||||
} else if (type === 'playlist') {
|
||||
item.artist = item.owner?.display_name || 'Unknown Owner';
|
||||
} else if (type === 'artist') {
|
||||
item.artist = item.name || 'Unknown Artist';
|
||||
}
|
||||
} else {
|
||||
item = { name: 'Unknown', artist: 'Unknown Artist' };
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -142,17 +172,20 @@ async function startDownload(url, type, item, albumType) {
|
||||
throw new Error(`Unsupported type: ${type}`);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Download failed: ' + error.message);
|
||||
showError('Download failed: ' + (error?.message || 'Unknown error'));
|
||||
}
|
||||
}
|
||||
|
||||
// UI Helper Functions
|
||||
function showError(message) {
|
||||
document.getElementById('resultsContainer').innerHTML = `<div class="error">${message}</div>`;
|
||||
const resultsContainer = document.getElementById('resultsContainer');
|
||||
if (resultsContainer) {
|
||||
resultsContainer.innerHTML = `<div class="error">${message || 'An error occurred'}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function isSpotifyUrl(url) {
|
||||
return url.startsWith('https://open.spotify.com/');
|
||||
return url && url.startsWith('https://open.spotify.com/');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,6 +193,8 @@ function isSpotifyUrl(url) {
|
||||
* Expected URL format: https://open.spotify.com/{type}/{id}
|
||||
*/
|
||||
function getSpotifyResourceDetails(url) {
|
||||
if (!url) throw new Error('Empty URL provided');
|
||||
|
||||
const urlObj = new URL(url);
|
||||
const pathParts = urlObj.pathname.split('/');
|
||||
if (pathParts.length < 3 || !pathParts[1] || !pathParts[2]) {
|
||||
@@ -172,6 +207,8 @@ function getSpotifyResourceDetails(url) {
|
||||
}
|
||||
|
||||
function msToMinutesSeconds(ms) {
|
||||
if (!ms || isNaN(ms)) return '0:00';
|
||||
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = ((ms % 60000) / 1000).toFixed(0);
|
||||
return `${minutes}:${seconds.padStart(2, '0')}`;
|
||||
@@ -182,11 +219,15 @@ function msToMinutesSeconds(ms) {
|
||||
* The additional parameter "index" is used to set a data-index attribute on the card.
|
||||
*/
|
||||
function createResultCard(item, type, index) {
|
||||
if (!item) return '';
|
||||
|
||||
let newUrl = '#';
|
||||
try {
|
||||
const spotifyUrl = item.external_urls.spotify;
|
||||
const parsedUrl = new URL(spotifyUrl);
|
||||
newUrl = window.location.origin + parsedUrl.pathname;
|
||||
const spotifyUrl = item.external_urls?.spotify;
|
||||
if (spotifyUrl) {
|
||||
const parsedUrl = new URL(spotifyUrl);
|
||||
newUrl = window.location.origin + parsedUrl.pathname;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing URL:', e);
|
||||
}
|
||||
@@ -195,15 +236,15 @@ function createResultCard(item, type, index) {
|
||||
|
||||
switch (type) {
|
||||
case 'track':
|
||||
imageUrl = item.album.images[0]?.url || '';
|
||||
title = item.name;
|
||||
subtitle = item.artists.map(a => a.name).join(', ');
|
||||
imageUrl = item.album?.images?.[0]?.url || '/static/images/placeholder.jpg';
|
||||
title = item.name || 'Unknown Track';
|
||||
subtitle = item.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist';
|
||||
details = `
|
||||
<span>${item.album.name}</span>
|
||||
<span>${item.album?.name || 'Unknown Album'}</span>
|
||||
<span class="duration">${msToMinutesSeconds(item.duration_ms)}</span>
|
||||
`;
|
||||
return `
|
||||
<div class="result-card" data-id="${item.id}" data-index="${index}">
|
||||
<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>
|
||||
@@ -211,7 +252,7 @@ function createResultCard(item, type, index) {
|
||||
<div class="track-title">${title}</div>
|
||||
<div class="title-buttons">
|
||||
<button class="download-btn-small"
|
||||
data-url="${item.external_urls.spotify}"
|
||||
data-url="${item.external_urls?.spotify || ''}"
|
||||
data-type="${type}"
|
||||
title="Download">
|
||||
<img src="/static/images/download.svg" alt="Download">
|
||||
@@ -226,15 +267,15 @@ function createResultCard(item, type, index) {
|
||||
</div>
|
||||
`;
|
||||
case 'playlist':
|
||||
imageUrl = item.images[0]?.url || '';
|
||||
title = item.name;
|
||||
subtitle = item.owner.display_name;
|
||||
imageUrl = item.images?.[0]?.url || '/static/images/placeholder.jpg';
|
||||
title = item.name || 'Unknown Playlist';
|
||||
subtitle = item.owner?.display_name || 'Unknown Owner';
|
||||
details = `
|
||||
<span>${item.tracks.total} tracks</span>
|
||||
<span>${item.tracks?.total || '0'} tracks</span>
|
||||
<span class="duration">${item.description || 'No description'}</span>
|
||||
`;
|
||||
return `
|
||||
<div class="result-card" data-id="${item.id}" data-index="${index}">
|
||||
<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>
|
||||
@@ -242,7 +283,7 @@ function createResultCard(item, type, index) {
|
||||
<div class="track-title">${title}</div>
|
||||
<div class="title-buttons">
|
||||
<button class="download-btn-small"
|
||||
data-url="${item.external_urls.spotify}"
|
||||
data-url="${item.external_urls?.spotify || ''}"
|
||||
data-type="${type}"
|
||||
title="Download">
|
||||
<img src="/static/images/download.svg" alt="Download">
|
||||
@@ -257,15 +298,15 @@ function createResultCard(item, type, index) {
|
||||
</div>
|
||||
`;
|
||||
case 'album':
|
||||
imageUrl = item.images[0]?.url || '';
|
||||
title = item.name;
|
||||
subtitle = item.artists.map(a => a.name).join(', ');
|
||||
imageUrl = item.images?.[0]?.url || '/static/images/placeholder.jpg';
|
||||
title = item.name || 'Unknown Album';
|
||||
subtitle = item.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist';
|
||||
details = `
|
||||
<span>${item.release_date}</span>
|
||||
<span class="duration">${item.total_tracks} tracks</span>
|
||||
<span>${item.release_date || 'Unknown release date'}</span>
|
||||
<span class="duration">${item.total_tracks || '0'} tracks</span>
|
||||
`;
|
||||
return `
|
||||
<div class="result-card" data-id="${item.id}" data-index="${index}">
|
||||
<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>
|
||||
@@ -273,7 +314,7 @@ function createResultCard(item, type, index) {
|
||||
<div class="track-title">${title}</div>
|
||||
<div class="title-buttons">
|
||||
<button class="download-btn-small"
|
||||
data-url="${item.external_urls.spotify}"
|
||||
data-url="${item.external_urls?.spotify || ''}"
|
||||
data-type="${type}"
|
||||
title="Download">
|
||||
<img src="/static/images/download.svg" alt="Download">
|
||||
@@ -288,12 +329,12 @@ function createResultCard(item, type, index) {
|
||||
</div>
|
||||
`;
|
||||
case 'artist':
|
||||
imageUrl = (item.images && item.images.length) ? item.images[0].url : '';
|
||||
title = item.name;
|
||||
imageUrl = (item.images && item.images.length) ? item.images[0].url : '/static/images/placeholder.jpg';
|
||||
title = item.name || 'Unknown Artist';
|
||||
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}" data-index="${index}">
|
||||
<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>
|
||||
@@ -302,7 +343,7 @@ function createResultCard(item, type, index) {
|
||||
<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-url="${item.external_urls?.spotify || ''}"
|
||||
data-type="${type}"
|
||||
title="Download">
|
||||
<img src="/static/images/download.svg" alt="Download">
|
||||
@@ -325,7 +366,7 @@ function createResultCard(item, type, index) {
|
||||
</button>
|
||||
<div class="secondary-options">
|
||||
<button class="download-btn option-btn"
|
||||
data-url="${item.external_urls.spotify}"
|
||||
data-url="${item.external_urls?.spotify || ''}"
|
||||
data-type="${type}"
|
||||
data-album-type="album">
|
||||
<img src="https://www.svgrepo.com/show/40029/vinyl-record.svg"
|
||||
@@ -334,7 +375,7 @@ function createResultCard(item, type, index) {
|
||||
Albums
|
||||
</button>
|
||||
<button class="download-btn option-btn"
|
||||
data-url="${item.external_urls.spotify}"
|
||||
data-url="${item.external_urls?.spotify || ''}"
|
||||
data-type="${type}"
|
||||
data-album-type="single">
|
||||
<img src="https://www.svgrepo.com/show/147837/cassette.svg"
|
||||
@@ -343,7 +384,7 @@ function createResultCard(item, type, index) {
|
||||
Singles
|
||||
</button>
|
||||
<button class="download-btn option-btn"
|
||||
data-url="${item.external_urls.spotify}"
|
||||
data-url="${item.external_urls?.spotify || ''}"
|
||||
data-type="${type}"
|
||||
data-album-type="compilation">
|
||||
<img src="https://brandeps.com/icon-download/C/Collection-icon-vector-01.svg"
|
||||
@@ -361,15 +402,15 @@ function createResultCard(item, type, index) {
|
||||
subtitle = '';
|
||||
details = '';
|
||||
return `
|
||||
<div class="result-card" data-id="${item.id}" data-index="${index}">
|
||||
<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">
|
||||
<img src="${imageUrl || '/static/images/placeholder.jpg'}" class="album-art" alt="${type} cover">
|
||||
</div>
|
||||
<div class="title-and-view">
|
||||
<div class="track-title">${title}</div>
|
||||
<div class="title-buttons">
|
||||
<button class="download-btn-small"
|
||||
data-url="${item.external_urls.spotify}"
|
||||
data-url="${item.external_urls?.spotify || ''}"
|
||||
data-type="${type}"
|
||||
title="Download">
|
||||
<img src="/static/images/download.svg" alt="Download">
|
||||
|
||||
@@ -11,18 +11,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the config to get active Spotify account first
|
||||
fetch('/api/config')
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Failed to fetch config');
|
||||
return response.json();
|
||||
})
|
||||
.then(config => {
|
||||
const mainAccount = config.spotify || '';
|
||||
|
||||
// Then fetch playlist info with the main parameter
|
||||
return fetch(`/api/playlist/info?id=${encodeURIComponent(playlistId)}&main=${mainAccount}`);
|
||||
})
|
||||
// Fetch playlist info directly
|
||||
fetch(`/api/playlist/info?id=${encodeURIComponent(playlistId)}`)
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
return response.json();
|
||||
@@ -50,12 +40,12 @@ function renderPlaylist(playlist) {
|
||||
document.getElementById('error').classList.add('hidden');
|
||||
|
||||
// Update header info
|
||||
document.getElementById('playlist-name').textContent = playlist.name;
|
||||
document.getElementById('playlist-owner').textContent = `By ${playlist.owner.display_name}`;
|
||||
document.getElementById('playlist-name').textContent = playlist.name || 'Unknown Playlist';
|
||||
document.getElementById('playlist-owner').textContent = `By ${playlist.owner?.display_name || 'Unknown User'}`;
|
||||
document.getElementById('playlist-stats').textContent =
|
||||
`${playlist.followers.total} followers • ${playlist.tracks.total} songs`;
|
||||
document.getElementById('playlist-description').textContent = playlist.description;
|
||||
const image = playlist.images[0]?.url || 'placeholder.jpg';
|
||||
`${playlist.followers?.total || '0'} followers • ${playlist.tracks?.total || '0'} songs`;
|
||||
document.getElementById('playlist-description').textContent = playlist.description || '';
|
||||
const image = playlist.images?.[0]?.url || '/static/images/placeholder.jpg';
|
||||
document.getElementById('playlist-image').src = image;
|
||||
|
||||
// --- Add Home Button ---
|
||||
@@ -68,7 +58,9 @@ function renderPlaylist(playlist) {
|
||||
homeButton.innerHTML = `<img src="/static/images/home.svg" alt="Home">`;
|
||||
// Insert the home button at the beginning of the header container.
|
||||
const headerContainer = document.getElementById('playlist-header');
|
||||
headerContainer.insertBefore(homeButton, headerContainer.firstChild);
|
||||
if (headerContainer) {
|
||||
headerContainer.insertBefore(homeButton, headerContainer.firstChild);
|
||||
}
|
||||
}
|
||||
homeButton.addEventListener('click', () => {
|
||||
// Navigate to the site's base URL.
|
||||
@@ -84,7 +76,9 @@ function renderPlaylist(playlist) {
|
||||
downloadPlaylistBtn.className = 'download-btn download-btn--main';
|
||||
// Insert the button into the header container.
|
||||
const headerContainer = document.getElementById('playlist-header');
|
||||
headerContainer.appendChild(downloadPlaylistBtn);
|
||||
if (headerContainer) {
|
||||
headerContainer.appendChild(downloadPlaylistBtn);
|
||||
}
|
||||
}
|
||||
downloadPlaylistBtn.addEventListener('click', () => {
|
||||
// Remove individual track download buttons (but leave the whole playlist button).
|
||||
@@ -102,7 +96,7 @@ function renderPlaylist(playlist) {
|
||||
downloadWholePlaylist(playlist).then(() => {
|
||||
downloadPlaylistBtn.textContent = 'Queued!';
|
||||
}).catch(err => {
|
||||
showError('Failed to queue playlist download: ' + err.message);
|
||||
showError('Failed to queue playlist download: ' + (err?.message || 'Unknown error'));
|
||||
downloadPlaylistBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
@@ -116,7 +110,9 @@ function renderPlaylist(playlist) {
|
||||
downloadAlbumsBtn.className = 'download-btn download-btn--main';
|
||||
// Insert the new button into the header container.
|
||||
const headerContainer = document.getElementById('playlist-header');
|
||||
headerContainer.appendChild(downloadAlbumsBtn);
|
||||
if (headerContainer) {
|
||||
headerContainer.appendChild(downloadAlbumsBtn);
|
||||
}
|
||||
}
|
||||
downloadAlbumsBtn.addEventListener('click', () => {
|
||||
// Remove individual track download buttons (but leave this album button).
|
||||
@@ -132,48 +128,54 @@ function renderPlaylist(playlist) {
|
||||
downloadAlbumsBtn.textContent = 'Queued!';
|
||||
})
|
||||
.catch(err => {
|
||||
showError('Failed to queue album downloads: ' + err.message);
|
||||
showError('Failed to queue album downloads: ' + (err?.message || 'Unknown error'));
|
||||
downloadAlbumsBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
// Render tracks list
|
||||
const tracksList = document.getElementById('tracks-list');
|
||||
if (!tracksList) return;
|
||||
|
||||
tracksList.innerHTML = ''; // Clear any existing content
|
||||
|
||||
playlist.tracks.items.forEach((item, index) => {
|
||||
const track = item.track;
|
||||
// Create links for track, artist, and album using their IDs.
|
||||
const trackLink = `/track/${track.id}`;
|
||||
const artistLink = `/artist/${track.artists[0].id}`;
|
||||
const albumLink = `/album/${track.album.id}`;
|
||||
if (playlist.tracks?.items) {
|
||||
playlist.tracks.items.forEach((item, index) => {
|
||||
if (!item || !item.track) return; // Skip null/undefined tracks
|
||||
|
||||
const track = item.track;
|
||||
// Create links for track, artist, and album using their IDs.
|
||||
const trackLink = `/track/${track.id || ''}`;
|
||||
const artistLink = `/artist/${track.artists?.[0]?.id || ''}`;
|
||||
const albumLink = `/album/${track.album?.id || ''}`;
|
||||
|
||||
const trackElement = document.createElement('div');
|
||||
trackElement.className = 'track';
|
||||
trackElement.innerHTML = `
|
||||
<div class="track-number">${index + 1}</div>
|
||||
<div class="track-info">
|
||||
<div class="track-name">
|
||||
<a href="${trackLink}" title="View track details">${track.name}</a>
|
||||
const trackElement = document.createElement('div');
|
||||
trackElement.className = 'track';
|
||||
trackElement.innerHTML = `
|
||||
<div class="track-number">${index + 1}</div>
|
||||
<div class="track-info">
|
||||
<div class="track-name">
|
||||
<a href="${trackLink}" title="View track details">${track.name || 'Unknown Track'}</a>
|
||||
</div>
|
||||
<div class="track-artist">
|
||||
<a href="${artistLink}" title="View artist details">${track.artists?.[0]?.name || 'Unknown Artist'}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="track-artist">
|
||||
<a href="${artistLink}" title="View artist details">${track.artists[0].name}</a>
|
||||
<div class="track-album">
|
||||
<a href="${albumLink}" title="View album details">${track.album?.name || 'Unknown Album'}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="track-album">
|
||||
<a href="${albumLink}" title="View album details">${track.album.name}</a>
|
||||
</div>
|
||||
<div class="track-duration">${msToTime(track.duration_ms)}</div>
|
||||
<button class="download-btn download-btn--circle"
|
||||
data-url="${track.external_urls.spotify}"
|
||||
data-type="track"
|
||||
data-name="${track.name}"
|
||||
title="Download">
|
||||
<img src="/static/images/download.svg" alt="Download">
|
||||
</button>
|
||||
`;
|
||||
tracksList.appendChild(trackElement);
|
||||
});
|
||||
<div class="track-duration">${msToTime(track.duration_ms || 0)}</div>
|
||||
<button class="download-btn download-btn--circle"
|
||||
data-url="${track.external_urls?.spotify || ''}"
|
||||
data-type="track"
|
||||
data-name="${track.name || 'Unknown Track'}"
|
||||
title="Download">
|
||||
<img src="/static/images/download.svg" alt="Download">
|
||||
</button>
|
||||
`;
|
||||
tracksList.appendChild(trackElement);
|
||||
});
|
||||
}
|
||||
|
||||
// Reveal header and tracks container
|
||||
document.getElementById('playlist-header').classList.remove('hidden');
|
||||
@@ -187,6 +189,8 @@ function renderPlaylist(playlist) {
|
||||
* Converts milliseconds to minutes:seconds.
|
||||
*/
|
||||
function msToTime(duration) {
|
||||
if (!duration || isNaN(duration)) return '0:00';
|
||||
|
||||
const minutes = Math.floor(duration / 60000);
|
||||
const seconds = ((duration % 60000) / 1000).toFixed(0);
|
||||
return `${minutes}:${seconds.padStart(2, '0')}`;
|
||||
@@ -197,8 +201,10 @@ function msToTime(duration) {
|
||||
*/
|
||||
function showError(message) {
|
||||
const errorEl = document.getElementById('error');
|
||||
errorEl.textContent = message;
|
||||
errorEl.classList.remove('hidden');
|
||||
if (errorEl) {
|
||||
errorEl.textContent = message || 'An error occurred';
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,9 +216,9 @@ function attachDownloadListeners() {
|
||||
if (btn.id === 'downloadPlaylistBtn' || btn.id === 'downloadAlbumsBtn') return;
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const url = e.currentTarget.dataset.url;
|
||||
const type = e.currentTarget.dataset.type;
|
||||
const name = e.currentTarget.dataset.name || extractName(url);
|
||||
const url = e.currentTarget.dataset.url || '';
|
||||
const type = e.currentTarget.dataset.type || '';
|
||||
const name = e.currentTarget.dataset.name || extractName(url) || 'Unknown';
|
||||
// Remove the button immediately after click.
|
||||
e.currentTarget.remove();
|
||||
startDownload(url, type, { name });
|
||||
@@ -224,11 +230,19 @@ function attachDownloadListeners() {
|
||||
* Initiates the whole playlist download by calling the playlist endpoint.
|
||||
*/
|
||||
async function downloadWholePlaylist(playlist) {
|
||||
const url = playlist.external_urls.spotify;
|
||||
if (!playlist) {
|
||||
throw new Error('Invalid playlist data');
|
||||
}
|
||||
|
||||
const url = playlist.external_urls?.spotify || '';
|
||||
if (!url) {
|
||||
throw new Error('Missing playlist URL');
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadQueue.startPlaylistDownload(url, { name: playlist.name });
|
||||
await downloadQueue.startPlaylistDownload(url, { name: playlist.name || 'Unknown Playlist' });
|
||||
} catch (error) {
|
||||
showError('Playlist download failed: ' + error.message);
|
||||
showError('Playlist download failed: ' + (error?.message || 'Unknown error'));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -239,9 +253,16 @@ async function downloadWholePlaylist(playlist) {
|
||||
* with the progress (queued_albums/total_albums).
|
||||
*/
|
||||
async function downloadPlaylistAlbums(playlist) {
|
||||
if (!playlist?.tracks?.items) {
|
||||
showError('No tracks found in this playlist.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a map of unique albums (using album ID as the key).
|
||||
const albumMap = new Map();
|
||||
playlist.tracks.items.forEach(item => {
|
||||
if (!item?.track?.album) return;
|
||||
|
||||
const album = item.track.album;
|
||||
if (album && album.id) {
|
||||
albumMap.set(album.id, album);
|
||||
@@ -266,9 +287,14 @@ async function downloadPlaylistAlbums(playlist) {
|
||||
// Process each album sequentially.
|
||||
for (let i = 0; i < totalAlbums; i++) {
|
||||
const album = uniqueAlbums[i];
|
||||
if (!album) continue;
|
||||
|
||||
const albumUrl = album.external_urls?.spotify || '';
|
||||
if (!albumUrl) continue;
|
||||
|
||||
await downloadQueue.startAlbumDownload(
|
||||
album.external_urls.spotify,
|
||||
{ name: album.name }
|
||||
albumUrl,
|
||||
{ name: album.name || 'Unknown Album' }
|
||||
);
|
||||
|
||||
// Update button text with current progress.
|
||||
@@ -291,56 +317,29 @@ async function downloadPlaylistAlbums(playlist) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the download process by building the API URL,
|
||||
* fetching download details, and then adding the download to the queue.
|
||||
* Starts the download process by building a minimal API URL with only the necessary parameters,
|
||||
* since the server will use config defaults for others.
|
||||
*/
|
||||
async function startDownload(url, type, item, albumType) {
|
||||
// Retrieve configuration (if any) from localStorage.
|
||||
const config = JSON.parse(localStorage.getItem('activeConfig')) || {};
|
||||
const {
|
||||
fallback = false,
|
||||
spotify = '',
|
||||
deezer = '',
|
||||
spotifyQuality = 'NORMAL',
|
||||
deezerQuality = 'MP3_128',
|
||||
realTime = false,
|
||||
customTrackFormat = '',
|
||||
customDirFormat = ''
|
||||
} = config;
|
||||
|
||||
if (!url || !type) {
|
||||
showError('Missing URL or type for download');
|
||||
return;
|
||||
}
|
||||
|
||||
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
|
||||
let apiUrl = '';
|
||||
let apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`;
|
||||
|
||||
// Build API URL based on the download type.
|
||||
if (type === 'playlist') {
|
||||
// Use the dedicated playlist download endpoint.
|
||||
apiUrl = `/api/playlist/download?service=${service}&url=${encodeURIComponent(url)}`;
|
||||
} else if (type === 'artist') {
|
||||
apiUrl = `/api/artist/download?service=${service}&artist_url=${encodeURIComponent(url)}&album_type=${encodeURIComponent(albumType || 'album,single,compilation')}`;
|
||||
} else {
|
||||
// Default is track download.
|
||||
apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`;
|
||||
// Add name and artist if available for better progress display
|
||||
if (item.name) {
|
||||
apiUrl += `&name=${encodeURIComponent(item.name)}`;
|
||||
}
|
||||
|
||||
// Append account and quality details.
|
||||
if (fallback && service === 'spotify') {
|
||||
apiUrl += `&main=${deezer}&fallback=${spotify}`;
|
||||
apiUrl += `&quality=${deezerQuality}&fall_quality=${spotifyQuality}`;
|
||||
} else {
|
||||
const mainAccount = service === 'spotify' ? spotify : deezer;
|
||||
apiUrl += `&main=${mainAccount}&quality=${service === 'spotify' ? spotifyQuality : deezerQuality}`;
|
||||
if (item.artist) {
|
||||
apiUrl += `&artist=${encodeURIComponent(item.artist)}`;
|
||||
}
|
||||
|
||||
if (realTime) {
|
||||
apiUrl += '&real_time=true';
|
||||
}
|
||||
|
||||
// Append custom formatting parameters.
|
||||
if (customTrackFormat) {
|
||||
apiUrl += `&custom_track_format=${encodeURIComponent(customTrackFormat)}`;
|
||||
}
|
||||
if (customDirFormat) {
|
||||
apiUrl += `&custom_dir_format=${encodeURIComponent(customDirFormat)}`;
|
||||
|
||||
// For artist downloads, include album_type
|
||||
if (type === 'artist' && albumType) {
|
||||
apiUrl += `&album_type=${encodeURIComponent(albumType)}`;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -349,7 +348,7 @@ async function startDownload(url, type, item, albumType) {
|
||||
// Add the download to the queue using the working queue implementation.
|
||||
downloadQueue.addDownload(item, type, data.prg_file);
|
||||
} catch (error) {
|
||||
showError('Download failed: ' + error.message);
|
||||
showError('Download failed: ' + (error?.message || 'Unknown error'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,5 +356,5 @@ async function startDownload(url, type, item, albumType) {
|
||||
* A helper function to extract a display name from the URL.
|
||||
*/
|
||||
function extractName(url) {
|
||||
return url;
|
||||
return url || 'Unknown';
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@ class CustomURLSearchParams {
|
||||
|
||||
class DownloadQueue {
|
||||
constructor() {
|
||||
// Constants read from the server config
|
||||
this.MAX_RETRIES = 3; // Default max retries
|
||||
this.RETRY_DELAY = 5; // Default retry delay in seconds
|
||||
this.RETRY_DELAY_INCREASE = 5; // Default retry delay increase in seconds
|
||||
|
||||
this.downloadQueue = {}; // keyed by unique queueId
|
||||
this.currentConfig = {}; // Cache for current config
|
||||
|
||||
@@ -277,13 +282,18 @@ class DownloadQueue {
|
||||
*/
|
||||
createQueueItem(item, type, prgFile, queueId) {
|
||||
const defaultMessage = (type === 'playlist') ? 'Reading track list' : 'Initializing download...';
|
||||
|
||||
// Use display values if available, or fall back to standard fields
|
||||
const displayTitle = item.name || 'Unknown';
|
||||
const displayType = type.charAt(0).toUpperCase() + type.slice(1);
|
||||
|
||||
const div = document.createElement('article');
|
||||
div.className = 'queue-item';
|
||||
div.setAttribute('aria-live', 'polite');
|
||||
div.setAttribute('aria-atomic', 'true');
|
||||
div.innerHTML = `
|
||||
<div class="title">${item.name}</div>
|
||||
<div class="type">${type.charAt(0).toUpperCase() + type.slice(1)}</div>
|
||||
<div class="title">${displayTitle}</div>
|
||||
<div class="type">${displayType}</div>
|
||||
<div class="log" id="log-${queueId}-${prgFile}">${defaultMessage}</div>
|
||||
<button class="cancel-btn" data-prg="${prgFile}" data-type="${type}" data-queueid="${queueId}" title="Cancel Download">
|
||||
<img src="https://www.svgrepo.com/show/488384/skull-head.svg" alt="Cancel Download">
|
||||
@@ -453,13 +463,25 @@ class DownloadQueue {
|
||||
return `Queued track "${data.name}"${data.artist ? ` by ${data.artist}` : ''}`;
|
||||
}
|
||||
return `Queued ${data.type} "${data.name}"`;
|
||||
|
||||
case 'started':
|
||||
return `Download started`;
|
||||
|
||||
case 'processing':
|
||||
return `Processing download...`;
|
||||
|
||||
case 'cancel':
|
||||
return 'Download cancelled';
|
||||
|
||||
case 'interrupted':
|
||||
return 'Download was interrupted';
|
||||
|
||||
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...`;
|
||||
@@ -482,6 +504,7 @@ 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('/');
|
||||
@@ -498,6 +521,7 @@ class DownloadQueue {
|
||||
}
|
||||
}
|
||||
return `Progress: ${data.status}...`;
|
||||
|
||||
case 'done':
|
||||
if (data.type === 'track') {
|
||||
return `Finished track "${data.song}" by ${data.artist}`;
|
||||
@@ -509,14 +533,30 @@ 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`;
|
||||
if (data.retry_count !== undefined) {
|
||||
return `Retrying download (attempt ${data.retry_count}/${this.MAX_RETRIES})`;
|
||||
}
|
||||
return `Retrying download...`;
|
||||
|
||||
case 'error':
|
||||
return `Error: ${data.message || 'Unknown error'}`;
|
||||
let errorMsg = `Error: ${data.message || 'Unknown error'}`;
|
||||
if (data.can_retry !== undefined) {
|
||||
if (data.can_retry) {
|
||||
errorMsg += ` (Can be retried)`;
|
||||
} else {
|
||||
errorMsg += ` (Max retries reached)`;
|
||||
}
|
||||
}
|
||||
return errorMsg;
|
||||
|
||||
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);
|
||||
@@ -524,6 +564,7 @@ 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;
|
||||
}
|
||||
@@ -540,47 +581,83 @@ class DownloadQueue {
|
||||
if (cancelBtn) {
|
||||
cancelBtn.style.display = 'none';
|
||||
}
|
||||
logElement.innerHTML = `
|
||||
<div class="error-message">${this.getStatusMessage(progress)}</div>
|
||||
<div class="error-buttons">
|
||||
<button class="close-error-btn" title="Close">×</button>
|
||||
<button class="retry-btn" title="Retry">Retry</button>
|
||||
</div>
|
||||
`;
|
||||
logElement.querySelector('.close-error-btn').addEventListener('click', () => {
|
||||
if (entry.autoRetryInterval) {
|
||||
clearInterval(entry.autoRetryInterval);
|
||||
entry.autoRetryInterval = null;
|
||||
}
|
||||
this.cleanupEntry(queueId);
|
||||
});
|
||||
logElement.querySelector('.retry-btn').addEventListener('click', async () => {
|
||||
if (entry.autoRetryInterval) {
|
||||
clearInterval(entry.autoRetryInterval);
|
||||
entry.autoRetryInterval = null;
|
||||
}
|
||||
this.retryDownload(queueId, logElement);
|
||||
});
|
||||
if (entry.requestUrl) {
|
||||
const maxRetries = 10;
|
||||
if (entry.retryCount < maxRetries) {
|
||||
const autoRetryDelay = 300; // seconds
|
||||
let secondsLeft = autoRetryDelay;
|
||||
entry.autoRetryInterval = setInterval(() => {
|
||||
secondsLeft--;
|
||||
const errorMsgEl = logElement.querySelector('.error-message');
|
||||
if (errorMsgEl) {
|
||||
errorMsgEl.textContent = `Error: ${progress.message || 'Unknown error'}. Retrying in ${secondsLeft} seconds... (attempt ${entry.retryCount + 1}/${maxRetries})`;
|
||||
}
|
||||
if (secondsLeft <= 0) {
|
||||
clearInterval(entry.autoRetryInterval);
|
||||
entry.autoRetryInterval = null;
|
||||
this.retryDownload(queueId, logElement);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Check if we're under the max retries threshold for auto-retry
|
||||
const canRetry = entry.retryCount < this.MAX_RETRIES;
|
||||
|
||||
if (canRetry) {
|
||||
logElement.innerHTML = `
|
||||
<div class="error-message">${this.getStatusMessage(progress)}</div>
|
||||
<div class="error-buttons">
|
||||
<button class="close-error-btn" title="Close">×</button>
|
||||
<button class="retry-btn" title="Retry">Retry</button>
|
||||
</div>
|
||||
`;
|
||||
logElement.querySelector('.close-error-btn').addEventListener('click', () => {
|
||||
if (entry.autoRetryInterval) {
|
||||
clearInterval(entry.autoRetryInterval);
|
||||
entry.autoRetryInterval = null;
|
||||
}
|
||||
this.cleanupEntry(queueId);
|
||||
});
|
||||
logElement.querySelector('.retry-btn').addEventListener('click', async () => {
|
||||
if (entry.autoRetryInterval) {
|
||||
clearInterval(entry.autoRetryInterval);
|
||||
entry.autoRetryInterval = null;
|
||||
}
|
||||
this.retryDownload(queueId, logElement);
|
||||
});
|
||||
|
||||
// Implement auto-retry if we have the original request URL
|
||||
if (entry.requestUrl) {
|
||||
const maxRetries = this.MAX_RETRIES;
|
||||
if (entry.retryCount < maxRetries) {
|
||||
// Calculate the delay based on retry count (exponential backoff)
|
||||
const baseDelay = this.RETRY_DELAY || 5; // seconds, use server's retry delay or default to 5
|
||||
const increase = this.RETRY_DELAY_INCREASE || 5;
|
||||
const retryDelay = baseDelay + (entry.retryCount * increase);
|
||||
|
||||
let secondsLeft = retryDelay;
|
||||
entry.autoRetryInterval = setInterval(() => {
|
||||
secondsLeft--;
|
||||
const errorMsgEl = logElement.querySelector('.error-message');
|
||||
if (errorMsgEl) {
|
||||
errorMsgEl.textContent = `Error: ${progress.message || 'Unknown error'}. Retrying in ${secondsLeft} seconds... (attempt ${entry.retryCount + 1}/${maxRetries})`;
|
||||
}
|
||||
if (secondsLeft <= 0) {
|
||||
clearInterval(entry.autoRetryInterval);
|
||||
entry.autoRetryInterval = null;
|
||||
this.retryDownload(queueId, logElement);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Cannot be retried - just show the error
|
||||
logElement.innerHTML = `
|
||||
<div class="error-message">${this.getStatusMessage(progress)}</div>
|
||||
<div class="error-buttons">
|
||||
<button class="close-error-btn" title="Close">×</button>
|
||||
</div>
|
||||
`;
|
||||
logElement.querySelector('.close-error-btn').addEventListener('click', () => {
|
||||
this.cleanupEntry(queueId);
|
||||
});
|
||||
}
|
||||
return;
|
||||
} else if (progress.status === 'interrupted') {
|
||||
logElement.textContent = 'Download was interrupted';
|
||||
setTimeout(() => this.cleanupEntry(queueId), 5000);
|
||||
} else if (progress.status === 'complete') {
|
||||
logElement.textContent = 'Download completed successfully';
|
||||
// Hide the cancel button
|
||||
const cancelBtn = entry.element.querySelector('.cancel-btn');
|
||||
if (cancelBtn) {
|
||||
cancelBtn.style.display = 'none';
|
||||
}
|
||||
// Add success styling
|
||||
entry.element.classList.add('download-success');
|
||||
setTimeout(() => this.cleanupEntry(queueId), 5000);
|
||||
} else {
|
||||
logElement.textContent = this.getStatusMessage(progress);
|
||||
setTimeout(() => this.cleanupEntry(queueId), 5000);
|
||||
@@ -608,17 +685,36 @@ class DownloadQueue {
|
||||
async retryDownload(queueId, logElement) {
|
||||
const entry = this.downloadQueue[queueId];
|
||||
if (!entry) return;
|
||||
|
||||
logElement.textContent = 'Retrying download...';
|
||||
|
||||
// If we don't have the request URL, we can't retry
|
||||
if (!entry.requestUrl) {
|
||||
logElement.textContent = 'Retry not available: missing original request information.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the stored original request URL to create a new download
|
||||
const retryResponse = await fetch(entry.requestUrl);
|
||||
if (!retryResponse.ok) {
|
||||
throw new Error(`Server returned ${retryResponse.status}`);
|
||||
}
|
||||
|
||||
const retryData = await retryResponse.json();
|
||||
|
||||
if (retryData.prg_file) {
|
||||
// If the old PRG file exists, we should delete it
|
||||
const oldPrgFile = entry.prgFile;
|
||||
await fetch(`/api/prgs/delete/${oldPrgFile}`, { method: 'DELETE' });
|
||||
if (oldPrgFile) {
|
||||
try {
|
||||
await fetch(`/api/prgs/delete/${oldPrgFile}`, { method: 'DELETE' });
|
||||
} catch (deleteError) {
|
||||
console.error('Error deleting old PRG file:', deleteError);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the entry with the new PRG file
|
||||
const logEl = entry.element.querySelector('.log');
|
||||
logEl.id = `log-${entry.uniqueId}-${retryData.prg_file}`;
|
||||
entry.prgFile = retryData.prg_file;
|
||||
@@ -627,60 +723,27 @@ class DownloadQueue {
|
||||
entry.lastUpdated = Date.now();
|
||||
entry.retryCount = (entry.retryCount || 0) + 1;
|
||||
logEl.textContent = 'Retry initiated...';
|
||||
|
||||
// Start monitoring the new PRG file
|
||||
this.startEntryMonitoring(queueId);
|
||||
} else {
|
||||
logElement.textContent = 'Retry failed: invalid response from server';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Retry error:', error);
|
||||
logElement.textContent = 'Retry failed: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds common URL parameters for download API requests.
|
||||
*/
|
||||
_buildCommonParams(url, service, config) {
|
||||
const params = new CustomURLSearchParams();
|
||||
params.append('service', service);
|
||||
params.append('url', url);
|
||||
|
||||
if (service === 'spotify') {
|
||||
if (config.fallback) {
|
||||
params.append('main', config.deezer);
|
||||
params.append('fallback', config.spotify);
|
||||
params.append('quality', config.deezerQuality);
|
||||
params.append('fall_quality', config.spotifyQuality);
|
||||
} else {
|
||||
params.append('main', config.spotify);
|
||||
params.append('quality', config.spotifyQuality);
|
||||
}
|
||||
} else {
|
||||
params.append('main', config.deezer);
|
||||
params.append('quality', config.deezerQuality);
|
||||
}
|
||||
|
||||
if (config.realTime) {
|
||||
params.append('real_time', 'true');
|
||||
}
|
||||
|
||||
if (config.customTrackFormat) {
|
||||
params.append('custom_track_format', config.customTrackFormat);
|
||||
}
|
||||
|
||||
if (config.customDirFormat) {
|
||||
params.append('custom_dir_format', config.customDirFormat);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
async startTrackDownload(url, item) {
|
||||
await this.loadConfig();
|
||||
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
|
||||
const params = this._buildCommonParams(url, service, this.currentConfig);
|
||||
params.append('name', item.name || '');
|
||||
params.append('artist', item.artist || '');
|
||||
const apiUrl = `/api/track/download?${params.toString()}`;
|
||||
|
||||
// Use minimal parameters in the URL, letting server use config for defaults
|
||||
const apiUrl = `/api/track/download?service=${service}&url=${encodeURIComponent(url)}` +
|
||||
(item.name ? `&name=${encodeURIComponent(item.name)}` : '') +
|
||||
(item.artist ? `&artist=${encodeURIComponent(item.artist)}` : '');
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl);
|
||||
if (!response.ok) throw new Error('Network error');
|
||||
@@ -695,12 +758,15 @@ class DownloadQueue {
|
||||
async startPlaylistDownload(url, item) {
|
||||
await this.loadConfig();
|
||||
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
|
||||
const params = this._buildCommonParams(url, service, this.currentConfig);
|
||||
params.append('name', item.name || '');
|
||||
params.append('artist', item.artist || '');
|
||||
const apiUrl = `/api/playlist/download?${params.toString()}`;
|
||||
|
||||
// Use minimal parameters in the URL, letting server use config for defaults
|
||||
const apiUrl = `/api/playlist/download?service=${service}&url=${encodeURIComponent(url)}` +
|
||||
(item.name ? `&name=${encodeURIComponent(item.name)}` : '') +
|
||||
(item.artist ? `&artist=${encodeURIComponent(item.artist)}` : '');
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl);
|
||||
if (!response.ok) throw new Error('Network error');
|
||||
const data = await response.json();
|
||||
this.addDownload(item, 'playlist', data.prg_file, apiUrl);
|
||||
} catch (error) {
|
||||
@@ -709,14 +775,16 @@ class DownloadQueue {
|
||||
}
|
||||
}
|
||||
|
||||
async startArtistDownload(url, item, albumType = 'album,single,compilation,appears_on') {
|
||||
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);
|
||||
params.append('name', item.name || '');
|
||||
params.append('artist', item.artist || '');
|
||||
const apiUrl = `/api/artist/download?${params.toString()}`;
|
||||
|
||||
// Use minimal parameters in the URL, letting server use config for defaults
|
||||
const apiUrl = `/api/artist/download?service=${service}&url=${encodeURIComponent(url)}` +
|
||||
`&album_type=${albumType}` +
|
||||
(item.name ? `&name=${encodeURIComponent(item.name)}` : '') +
|
||||
(item.artist ? `&artist=${encodeURIComponent(item.artist)}` : '');
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl);
|
||||
if (!response.ok) throw new Error('Network error');
|
||||
@@ -737,12 +805,15 @@ class DownloadQueue {
|
||||
async startAlbumDownload(url, item) {
|
||||
await this.loadConfig();
|
||||
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
|
||||
const params = this._buildCommonParams(url, service, this.currentConfig);
|
||||
params.append('name', item.name || '');
|
||||
params.append('artist', item.artist || '');
|
||||
const apiUrl = `/api/album/download?${params.toString()}`;
|
||||
|
||||
// Use minimal parameters in the URL, letting server use config for defaults
|
||||
const apiUrl = `/api/album/download?service=${service}&url=${encodeURIComponent(url)}` +
|
||||
(item.name ? `&name=${encodeURIComponent(item.name)}` : '') +
|
||||
(item.artist ? `&artist=${encodeURIComponent(item.artist)}` : '');
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl);
|
||||
if (!response.ok) throw new Error('Network error');
|
||||
const data = await response.json();
|
||||
this.addDownload(item, 'album', data.prg_file, apiUrl);
|
||||
} catch (error) {
|
||||
@@ -772,16 +843,81 @@ class DownloadQueue {
|
||||
const prgResponse = await fetch(`/api/prgs/${prgFile}`);
|
||||
if (!prgResponse.ok) continue;
|
||||
const prgData = await prgResponse.json();
|
||||
|
||||
// Skip prg files that are marked as cancelled or completed
|
||||
if (prgData.last_line &&
|
||||
(prgData.last_line.status === 'cancel' ||
|
||||
prgData.last_line.status === 'complete')) {
|
||||
// Delete old completed or cancelled PRG files
|
||||
try {
|
||||
await fetch(`/api/prgs/delete/${prgFile}`, { method: 'DELETE' });
|
||||
console.log(`Cleaned up old PRG file: ${prgFile}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete completed/cancelled PRG file ${prgFile}:`, error);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use the enhanced original request info from the first line
|
||||
const originalRequest = prgData.original_request || {};
|
||||
|
||||
// Use the explicit display fields if available, or fall back to other fields
|
||||
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'
|
||||
name: prgData.display_title || originalRequest.display_title || originalRequest.name || prgFile,
|
||||
artist: prgData.display_artist || originalRequest.display_artist || originalRequest.artist || '',
|
||||
type: prgData.display_type || originalRequest.display_type || originalRequest.type || 'unknown',
|
||||
service: originalRequest.service || '',
|
||||
url: originalRequest.url || '',
|
||||
endpoint: originalRequest.endpoint || '',
|
||||
download_type: originalRequest.download_type || ''
|
||||
};
|
||||
this.addDownload(dummyItem, dummyItem.type, prgFile);
|
||||
|
||||
// Check if this is a retry file and get the retry count
|
||||
let retryCount = 0;
|
||||
if (prgFile.includes('_retry')) {
|
||||
const retryMatch = prgFile.match(/_retry(\d+)/);
|
||||
if (retryMatch && retryMatch[1]) {
|
||||
retryCount = parseInt(retryMatch[1], 10);
|
||||
} else if (prgData.last_line && prgData.last_line.retry_count) {
|
||||
retryCount = prgData.last_line.retry_count;
|
||||
}
|
||||
} else if (prgData.last_line && prgData.last_line.retry_count) {
|
||||
retryCount = prgData.last_line.retry_count;
|
||||
}
|
||||
|
||||
// Build a potential requestUrl from the original information
|
||||
let requestUrl = null;
|
||||
if (dummyItem.endpoint && dummyItem.url) {
|
||||
const params = new CustomURLSearchParams();
|
||||
params.append('service', dummyItem.service);
|
||||
params.append('url', dummyItem.url);
|
||||
|
||||
if (dummyItem.name) params.append('name', dummyItem.name);
|
||||
if (dummyItem.artist) params.append('artist', dummyItem.artist);
|
||||
|
||||
// Add any other parameters from the original request
|
||||
for (const [key, value] of Object.entries(originalRequest)) {
|
||||
if (!['service', 'url', 'name', 'artist', 'type', 'endpoint', 'download_type',
|
||||
'display_title', 'display_type', 'display_artist'].includes(key)) {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
requestUrl = `${dummyItem.endpoint}?${params.toString()}`;
|
||||
}
|
||||
|
||||
// Add to download queue
|
||||
const queueId = this.generateQueueId();
|
||||
const entry = this.createQueueEntry(dummyItem, dummyItem.type, prgFile, queueId, requestUrl);
|
||||
entry.retryCount = retryCount;
|
||||
this.downloadQueue[queueId] = entry;
|
||||
} catch (error) {
|
||||
console.error("Error fetching details for", prgFile, error);
|
||||
}
|
||||
}
|
||||
|
||||
// After adding all entries, update the queue
|
||||
this.updateQueueOrder();
|
||||
} catch (error) {
|
||||
console.error("Error loading existing PRG files:", error);
|
||||
}
|
||||
@@ -792,6 +928,19 @@ class DownloadQueue {
|
||||
const response = await fetch('/api/config');
|
||||
if (!response.ok) throw new Error('Failed to fetch config');
|
||||
this.currentConfig = await response.json();
|
||||
|
||||
// Update our retry constants from the server config
|
||||
if (this.currentConfig.maxRetries !== undefined) {
|
||||
this.MAX_RETRIES = this.currentConfig.maxRetries;
|
||||
}
|
||||
if (this.currentConfig.retryDelaySeconds !== undefined) {
|
||||
this.RETRY_DELAY = this.currentConfig.retryDelaySeconds;
|
||||
}
|
||||
if (this.currentConfig.retry_delay_increase !== undefined) {
|
||||
this.RETRY_DELAY_INCREASE = this.currentConfig.retry_delay_increase;
|
||||
}
|
||||
|
||||
console.log(`Loaded retry settings from config: max=${this.MAX_RETRIES}, delay=${this.RETRY_DELAY}, increase=${this.RETRY_DELAY_INCREASE}`);
|
||||
} catch (error) {
|
||||
console.error('Error loading config:', error);
|
||||
this.currentConfig = {};
|
||||
|
||||
@@ -11,18 +11,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the config to get active Spotify account first
|
||||
fetch('/api/config')
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Failed to fetch config');
|
||||
return response.json();
|
||||
})
|
||||
.then(config => {
|
||||
const mainAccount = config.spotify || '';
|
||||
|
||||
// Then fetch track info with the main parameter
|
||||
return fetch(`/api/track/info?id=${encodeURIComponent(trackId)}&main=${mainAccount}`);
|
||||
})
|
||||
// Fetch track info directly
|
||||
fetch(`/api/track/info?id=${encodeURIComponent(trackId)}`)
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
return response.json();
|
||||
@@ -52,25 +42,25 @@ function renderTrack(track) {
|
||||
|
||||
// Update track information fields.
|
||||
document.getElementById('track-name').innerHTML =
|
||||
`<a href="/track/${track.id}" title="View track details">${track.name}</a>`;
|
||||
`<a href="/track/${track.id || ''}" title="View track details">${track.name || 'Unknown Track'}</a>`;
|
||||
|
||||
document.getElementById('track-artist').innerHTML =
|
||||
`By ${track.artists.map(a =>
|
||||
`<a href="/artist/${a.id}" title="View artist details">${a.name}</a>`
|
||||
).join(', ')}`;
|
||||
`By ${track.artists?.map(a =>
|
||||
`<a href="/artist/${a?.id || ''}" title="View artist details">${a?.name || 'Unknown Artist'}</a>`
|
||||
).join(', ') || 'Unknown Artist'}`;
|
||||
|
||||
document.getElementById('track-album').innerHTML =
|
||||
`Album: <a href="/album/${track.album.id}" title="View album details">${track.album.name}</a> (${track.album.album_type})`;
|
||||
`Album: <a href="/album/${track.album?.id || ''}" title="View album details">${track.album?.name || 'Unknown Album'}</a> (${track.album?.album_type || 'album'})`;
|
||||
|
||||
document.getElementById('track-duration').textContent =
|
||||
`Duration: ${msToTime(track.duration_ms)}`;
|
||||
`Duration: ${msToTime(track.duration_ms || 0)}`;
|
||||
|
||||
document.getElementById('track-explicit').textContent =
|
||||
track.explicit ? 'Explicit' : 'Clean';
|
||||
|
||||
const imageUrl = (track.album.images && track.album.images[0])
|
||||
const imageUrl = (track.album?.images && track.album.images[0])
|
||||
? track.album.images[0].url
|
||||
: 'placeholder.jpg';
|
||||
: '/static/images/placeholder.jpg';
|
||||
document.getElementById('track-album-image').src = imageUrl;
|
||||
|
||||
// --- Insert Home Button (if not already present) ---
|
||||
@@ -81,7 +71,10 @@ function renderTrack(track) {
|
||||
homeButton.className = 'home-btn';
|
||||
homeButton.innerHTML = `<img src="/static/images/home.svg" alt="Home" />`;
|
||||
// Prepend the home button into the header.
|
||||
document.getElementById('track-header').insertBefore(homeButton, document.getElementById('track-header').firstChild);
|
||||
const trackHeader = document.getElementById('track-header');
|
||||
if (trackHeader) {
|
||||
trackHeader.insertBefore(homeButton, trackHeader.firstChild);
|
||||
}
|
||||
}
|
||||
homeButton.addEventListener('click', () => {
|
||||
window.location.href = window.location.origin;
|
||||
@@ -93,28 +86,41 @@ function renderTrack(track) {
|
||||
// Remove the parent container (#actions) if needed.
|
||||
const actionsContainer = document.getElementById('actions');
|
||||
if (actionsContainer) {
|
||||
actionsContainer.parentNode.removeChild(actionsContainer);
|
||||
actionsContainer.parentNode?.removeChild(actionsContainer);
|
||||
}
|
||||
// Set the inner HTML to use the download.svg icon.
|
||||
downloadBtn.innerHTML = `<img src="/static/images/download.svg" alt="Download">`;
|
||||
// Append the download button to the track header so it appears at the right.
|
||||
document.getElementById('track-header').appendChild(downloadBtn);
|
||||
const trackHeader = document.getElementById('track-header');
|
||||
if (trackHeader) {
|
||||
trackHeader.appendChild(downloadBtn);
|
||||
}
|
||||
}
|
||||
|
||||
downloadBtn.addEventListener('click', () => {
|
||||
downloadBtn.disabled = true;
|
||||
downloadBtn.innerHTML = `<span>Queueing...</span>`;
|
||||
|
||||
downloadQueue.startTrackDownload(track.external_urls.spotify, { name: track.name })
|
||||
.then(() => {
|
||||
downloadBtn.innerHTML = `<span>Queued!</span>`;
|
||||
})
|
||||
.catch(err => {
|
||||
showError('Failed to queue track download: ' + err.message);
|
||||
if (downloadBtn) {
|
||||
downloadBtn.addEventListener('click', () => {
|
||||
downloadBtn.disabled = true;
|
||||
downloadBtn.innerHTML = `<span>Queueing...</span>`;
|
||||
|
||||
const trackUrl = track.external_urls?.spotify || '';
|
||||
if (!trackUrl) {
|
||||
showError('Missing track URL');
|
||||
downloadBtn.disabled = false;
|
||||
downloadBtn.innerHTML = `<img src="/static/images/download.svg" alt="Download">`;
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
downloadQueue.startTrackDownload(trackUrl, { name: track.name || 'Unknown Track' })
|
||||
.then(() => {
|
||||
downloadBtn.innerHTML = `<span>Queued!</span>`;
|
||||
})
|
||||
.catch(err => {
|
||||
showError('Failed to queue track download: ' + (err?.message || 'Unknown error'));
|
||||
downloadBtn.disabled = false;
|
||||
downloadBtn.innerHTML = `<img src="/static/images/download.svg" alt="Download">`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Reveal the header now that track info is loaded.
|
||||
document.getElementById('track-header').classList.remove('hidden');
|
||||
@@ -124,6 +130,8 @@ function renderTrack(track) {
|
||||
* Converts milliseconds to minutes:seconds.
|
||||
*/
|
||||
function msToTime(duration) {
|
||||
if (!duration || isNaN(duration)) return '0:00';
|
||||
|
||||
const minutes = Math.floor(duration / 60000);
|
||||
const seconds = Math.floor((duration % 60000) / 1000);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
@@ -135,49 +143,30 @@ function msToTime(duration) {
|
||||
function showError(message) {
|
||||
const errorEl = document.getElementById('error');
|
||||
if (errorEl) {
|
||||
errorEl.textContent = message;
|
||||
errorEl.textContent = message || 'An error occurred';
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the download process by building the API URL,
|
||||
* fetching download details, and then adding the download to the queue.
|
||||
* Starts the download process by building a minimal API URL with only the necessary parameters,
|
||||
* since the server will use config defaults for others.
|
||||
*/
|
||||
async function startDownload(url, type, item) {
|
||||
const config = JSON.parse(localStorage.getItem('activeConfig')) || {};
|
||||
const {
|
||||
fallback = false,
|
||||
spotify = '',
|
||||
deezer = '',
|
||||
spotifyQuality = 'NORMAL',
|
||||
deezerQuality = 'MP3_128',
|
||||
realTime = false,
|
||||
customTrackFormat = '',
|
||||
customDirFormat = ''
|
||||
} = config;
|
||||
|
||||
if (!url || !type) {
|
||||
showError('Missing URL or type for download');
|
||||
return;
|
||||
}
|
||||
|
||||
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
|
||||
let apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`;
|
||||
|
||||
if (fallback && service === 'spotify') {
|
||||
apiUrl += `&main=${deezer}&fallback=${spotify}`;
|
||||
apiUrl += `&quality=${deezerQuality}&fall_quality=${spotifyQuality}`;
|
||||
} else {
|
||||
const mainAccount = service === 'spotify' ? spotify : deezer;
|
||||
apiUrl += `&main=${mainAccount}&quality=${service === 'spotify' ? spotifyQuality : deezerQuality}`;
|
||||
// Add name and artist if available for better progress display
|
||||
if (item.name) {
|
||||
apiUrl += `&name=${encodeURIComponent(item.name)}`;
|
||||
}
|
||||
|
||||
if (realTime) {
|
||||
apiUrl += '&real_time=true';
|
||||
}
|
||||
|
||||
// Append custom formatting parameters if they are set.
|
||||
if (customTrackFormat) {
|
||||
apiUrl += `&custom_track_format=${encodeURIComponent(customTrackFormat)}`;
|
||||
}
|
||||
if (customDirFormat) {
|
||||
apiUrl += `&custom_dir_format=${encodeURIComponent(customDirFormat)}`;
|
||||
if (item.artist) {
|
||||
apiUrl += `&artist=${encodeURIComponent(item.artist)}`;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -185,7 +174,7 @@ async function startDownload(url, type, item) {
|
||||
const data = await response.json();
|
||||
downloadQueue.addDownload(item, type, data.prg_file);
|
||||
} catch (error) {
|
||||
showError('Download failed: ' + error.message);
|
||||
showError('Download failed: ' + (error?.message || 'Unknown error'));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user