This commit is contained in:
cool.gitter.choco
2025-03-15 14:44:43 -06:00
parent c6204ada00
commit a4932ae36e
31 changed files with 2183 additions and 807 deletions

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">&times;</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">&times;</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">&times;</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 = {};

View File

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