diff --git a/static/js/album.js b/static/js/album.js deleted file mode 100644 index 615ddd3..0000000 --- a/static/js/album.js +++ /dev/null @@ -1,282 +0,0 @@ -import { downloadQueue } from './queue.js'; - -document.addEventListener('DOMContentLoaded', () => { - const pathSegments = window.location.pathname.split('/'); - const albumId = pathSegments[pathSegments.indexOf('album') + 1]; - - if (!albumId) { - showError('No album ID provided.'); - return; - } - - // 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(); - }) - .then(data => renderAlbum(data)) - .catch(error => { - console.error('Error:', error); - showError('Failed to load album.'); - }); - - const queueIcon = document.getElementById('queueIcon'); - if (queueIcon) { - queueIcon.addEventListener('click', () => { - downloadQueue.toggleVisibility(); - }); - } -}); - -function renderAlbum(album) { - // Hide loading and error messages. - document.getElementById('loading').classList.add('hidden'); - document.getElementById('error').classList.add('hidden'); - - // Check if album itself is marked explicit and filter is enabled - const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled(); - if (isExplicitFilterEnabled && album.explicit) { - // Show placeholder for explicit album - const placeholderContent = ` -
-

Explicit Content Filtered

-

This album contains explicit content and has been filtered based on your settings.

-

The explicit content filter is controlled by environment variables.

-
- `; - - const contentContainer = document.getElementById('album-header'); - if (contentContainer) { - contentContainer.innerHTML = placeholderContent; - contentContainer.classList.remove('hidden'); - } - - return; // Stop rendering the actual album content - } - - const baseUrl = window.location.origin; - - // Set album header info. - document.getElementById('album-name').innerHTML = - `${album.name || 'Unknown Album'}`; - - document.getElementById('album-artist').innerHTML = - `By ${album.artists?.map(artist => - `${artist?.name || 'Unknown Artist'}` - ).join(', ') || 'Unknown Artist'}`; - - const releaseYear = album.release_date ? new Date(album.release_date).getFullYear() : 'N/A'; - document.getElementById('album-stats').textContent = - `${releaseYear} • ${album.total_tracks || '0'} songs • ${album.label || 'Unknown Label'}`; - - document.getElementById('album-copyright').textContent = - album.copyrights?.map(c => c?.text || '').filter(text => text).join(' • ') || ''; - - const image = album.images?.[0]?.url || '/static/images/placeholder.jpg'; - document.getElementById('album-image').src = image; - - // Create (if needed) the Home Button. - let homeButton = document.getElementById('homeButton'); - if (!homeButton) { - homeButton = document.createElement('button'); - homeButton.id = 'homeButton'; - homeButton.className = 'home-btn'; - - const homeIcon = document.createElement('img'); - homeIcon.src = '/static/images/home.svg'; - homeIcon.alt = 'Home'; - homeButton.appendChild(homeIcon); - - // Insert as first child of album-header. - const headerContainer = document.getElementById('album-header'); - headerContainer.insertBefore(homeButton, headerContainer.firstChild); - } - homeButton.addEventListener('click', () => { - window.location.href = window.location.origin; - }); - - // Check if any track in the album is explicit when filter is enabled - let hasExplicitTrack = false; - if (isExplicitFilterEnabled && album.tracks?.items) { - hasExplicitTrack = album.tracks.items.some(track => track && track.explicit); - } - - // Create (if needed) the Download Album Button. - let downloadAlbumBtn = document.getElementById('downloadAlbumBtn'); - if (!downloadAlbumBtn) { - downloadAlbumBtn = document.createElement('button'); - downloadAlbumBtn.id = 'downloadAlbumBtn'; - downloadAlbumBtn.textContent = 'Download Full Album'; - downloadAlbumBtn.className = 'download-btn download-btn--main'; - document.getElementById('album-header').appendChild(downloadAlbumBtn); - } - - if (isExplicitFilterEnabled && hasExplicitTrack) { - // Disable the album download button and display a message explaining why - downloadAlbumBtn.disabled = true; - downloadAlbumBtn.classList.add('download-btn--disabled'); - downloadAlbumBtn.innerHTML = `Album Contains Explicit Tracks`; - } else { - // Normal behavior when no explicit tracks are present - downloadAlbumBtn.addEventListener('click', () => { - // Remove any other download buttons (keeping the full-album button in place). - document.querySelectorAll('.download-btn').forEach(btn => { - if (btn.id !== 'downloadAlbumBtn') btn.remove(); - }); - - downloadAlbumBtn.disabled = true; - downloadAlbumBtn.textContent = 'Queueing...'; - - downloadWholeAlbum(album) - .then(() => { - downloadAlbumBtn.textContent = 'Queued!'; - }) - .catch(err => { - showError('Failed to queue album download: ' + (err?.message || 'Unknown error')); - downloadAlbumBtn.disabled = false; - }); - }); - } - - // Render each track. - const tracksList = document.getElementById('tracks-list'); - tracksList.innerHTML = ''; - - if (album.tracks?.items) { - album.tracks.items.forEach((track, index) => { - if (!track) return; // Skip null or undefined tracks - - // Skip explicit tracks if filter is enabled - if (isExplicitFilterEnabled && track.explicit) { - // Add a placeholder for filtered explicit tracks - const trackElement = document.createElement('div'); - trackElement.className = 'track track-filtered'; - trackElement.innerHTML = ` -
${index + 1}
-
-
Explicit Content Filtered
-
This track is not shown due to explicit content filter settings
-
-
--:--
- `; - tracksList.appendChild(trackElement); - return; - } - - const trackElement = document.createElement('div'); - trackElement.className = 'track'; - trackElement.innerHTML = ` -
${index + 1}
-
-
- ${track.name || 'Unknown Track'} -
-
- ${track.artists?.map(a => - `${a?.name || 'Unknown Artist'}` - ).join(', ') || 'Unknown Artist'} -
-
-
${msToTime(track.duration_ms || 0)}
- - `; - tracksList.appendChild(trackElement); - }); - } - - // Reveal header and track list. - document.getElementById('album-header').classList.remove('hidden'); - document.getElementById('tracks-container').classList.remove('hidden'); - attachDownloadListeners(); - - // If on a small screen, re-arrange the action buttons. - if (window.innerWidth <= 480) { - let actionsContainer = document.getElementById('album-actions'); - if (!actionsContainer) { - actionsContainer = document.createElement('div'); - actionsContainer.id = 'album-actions'; - document.getElementById('album-header').appendChild(actionsContainer); - } - // Append in the desired order: Home, Download, then Queue Toggle (if exists). - actionsContainer.innerHTML = ''; // Clear any previous content - actionsContainer.appendChild(document.getElementById('homeButton')); - actionsContainer.appendChild(document.getElementById('downloadAlbumBtn')); - const queueToggle = document.querySelector('.queue-toggle'); - if (queueToggle) { - actionsContainer.appendChild(queueToggle); - } - } -} - -async function downloadWholeAlbum(album) { - const url = album.external_urls?.spotify || ''; - if (!url) { - throw new Error('Missing album URL'); - } - - try { - // Use the centralized downloadQueue.download method - await downloadQueue.download(url, 'album', { name: album.name || 'Unknown Album' }); - // Make the queue visible after queueing - downloadQueue.toggleVisibility(true); - } catch (error) { - showError('Album download failed: ' + (error?.message || 'Unknown error')); - throw error; - } -} - -function msToTime(duration) { - const minutes = Math.floor(duration / 60000); - const seconds = ((duration % 60000) / 1000).toFixed(0); - return `${minutes}:${seconds.padStart(2, '0')}`; -} - -function showError(message) { - const errorEl = document.getElementById('error'); - errorEl.textContent = message || 'An error occurred'; - errorEl.classList.remove('hidden'); -} - -function attachDownloadListeners() { - document.querySelectorAll('.download-btn').forEach((btn) => { - 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) || 'Unknown'; - // Remove the button immediately after click. - e.currentTarget.remove(); - startDownload(url, type, { name }); - }); - }); -} - -async function startDownload(url, type, item, albumType) { - if (!url) { - showError('Missing URL for download'); - return; - } - - try { - // Use the centralized downloadQueue.download method - await downloadQueue.download(url, type, item, albumType); - - // Make the queue visible after queueing - downloadQueue.toggleVisibility(true); - } catch (error) { - showError('Download failed: ' + (error?.message || 'Unknown error')); - throw error; - } -} - -function extractName(url) { - return url || 'Unknown'; -} diff --git a/static/js/album.ts b/static/js/album.ts new file mode 100644 index 0000000..07865b4 --- /dev/null +++ b/static/js/album.ts @@ -0,0 +1,372 @@ +import { downloadQueue } from './queue.js'; + +// Define interfaces for API data +interface Image { + url: string; + height?: number; + width?: number; +} + +interface Artist { + id: string; + name: string; + external_urls: { + spotify: string; + }; +} + +interface Track { + id: string; + name: string; + artists: Artist[]; + duration_ms: number; + explicit: boolean; + external_urls: { + spotify: string; + }; +} + +interface Album { + id: string; + name: string; + artists: Artist[]; + images: Image[]; + release_date: string; + total_tracks: number; + label: string; + copyrights: { text: string; type: string }[]; + explicit: boolean; + tracks: { + items: Track[]; + // Add other properties from Spotify API if needed (e.g., total, limit, offset) + }; + external_urls: { + spotify: string; + }; + // Add other album properties if available +} + +document.addEventListener('DOMContentLoaded', () => { + const pathSegments = window.location.pathname.split('/'); + const albumId = pathSegments[pathSegments.indexOf('album') + 1]; + + if (!albumId) { + showError('No album ID provided.'); + return; + } + + // 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() as Promise; // Add Album type + }) + .then(data => renderAlbum(data)) + .catch(error => { + console.error('Error:', error); + showError('Failed to load album.'); + }); + + const queueIcon = document.getElementById('queueIcon'); + if (queueIcon) { + queueIcon.addEventListener('click', () => { + downloadQueue.toggleVisibility(); + }); + } +}); + +function renderAlbum(album: Album) { + // Hide loading and error messages. + const loadingEl = document.getElementById('loading'); + if (loadingEl) loadingEl.classList.add('hidden'); + + const errorSectionEl = document.getElementById('error'); // Renamed to avoid conflict with error var in catch + if (errorSectionEl) errorSectionEl.classList.add('hidden'); + + // Check if album itself is marked explicit and filter is enabled + const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled(); + if (isExplicitFilterEnabled && album.explicit) { + // Show placeholder for explicit album + const placeholderContent = ` +
+

Explicit Content Filtered

+

This album contains explicit content and has been filtered based on your settings.

+

The explicit content filter is controlled by environment variables.

+
+ `; + + const contentContainer = document.getElementById('album-header'); + if (contentContainer) { + contentContainer.innerHTML = placeholderContent; + contentContainer.classList.remove('hidden'); + } + + return; // Stop rendering the actual album content + } + + const baseUrl = window.location.origin; + + // Set album header info. + const albumNameEl = document.getElementById('album-name'); + if (albumNameEl) { + albumNameEl.innerHTML = + `${album.name || 'Unknown Album'}`; + } + + const albumArtistEl = document.getElementById('album-artist'); + if (albumArtistEl) { + albumArtistEl.innerHTML = + `By ${album.artists?.map(artist => + `${artist?.name || 'Unknown Artist'}` + ).join(', ') || 'Unknown Artist'}`; + } + + const releaseYear = album.release_date ? new Date(album.release_date).getFullYear() : 'N/A'; + const albumStatsEl = document.getElementById('album-stats'); + if (albumStatsEl) { + albumStatsEl.textContent = + `${releaseYear} • ${album.total_tracks || '0'} songs • ${album.label || 'Unknown Label'}`; + } + + const albumCopyrightEl = document.getElementById('album-copyright'); + if (albumCopyrightEl) { + albumCopyrightEl.textContent = + album.copyrights?.map(c => c?.text || '').filter(text => text).join(' • ') || ''; + } + + const imageSrc = album.images?.[0]?.url || '/static/images/placeholder.jpg'; + const albumImageEl = document.getElementById('album-image') as HTMLImageElement | null; + if (albumImageEl) { + albumImageEl.src = imageSrc; + } + + // Create (if needed) the Home Button. + let homeButton = document.getElementById('homeButton') as HTMLButtonElement | null; + if (!homeButton) { + homeButton = document.createElement('button'); + homeButton.id = 'homeButton'; + homeButton.className = 'home-btn'; + + const homeIcon = document.createElement('img'); + homeIcon.src = '/static/images/home.svg'; + homeIcon.alt = 'Home'; + homeButton.appendChild(homeIcon); + + // Insert as first child of album-header. + const headerContainer = document.getElementById('album-header'); + if (headerContainer) { // Null check + headerContainer.insertBefore(homeButton, headerContainer.firstChild); + } + } + if (homeButton) { // Null check + homeButton.addEventListener('click', () => { + window.location.href = window.location.origin; + }); + } + + // Check if any track in the album is explicit when filter is enabled + let hasExplicitTrack = false; + if (isExplicitFilterEnabled && album.tracks?.items) { + hasExplicitTrack = album.tracks.items.some(track => track && track.explicit); + } + + // Create (if needed) the Download Album Button. + let downloadAlbumBtn = document.getElementById('downloadAlbumBtn') as HTMLButtonElement | null; + if (!downloadAlbumBtn) { + downloadAlbumBtn = document.createElement('button'); + downloadAlbumBtn.id = 'downloadAlbumBtn'; + downloadAlbumBtn.textContent = 'Download Full Album'; + downloadAlbumBtn.className = 'download-btn download-btn--main'; + const albumHeader = document.getElementById('album-header'); + if (albumHeader) albumHeader.appendChild(downloadAlbumBtn); // Null check + } + + if (downloadAlbumBtn) { // Null check for downloadAlbumBtn + if (isExplicitFilterEnabled && hasExplicitTrack) { + // Disable the album download button and display a message explaining why + downloadAlbumBtn.disabled = true; + downloadAlbumBtn.classList.add('download-btn--disabled'); + downloadAlbumBtn.innerHTML = `Album Contains Explicit Tracks`; + } else { + // Normal behavior when no explicit tracks are present + downloadAlbumBtn.addEventListener('click', () => { + // Remove any other download buttons (keeping the full-album button in place). + document.querySelectorAll('.download-btn').forEach(btn => { + if (btn.id !== 'downloadAlbumBtn') btn.remove(); + }); + + if (downloadAlbumBtn) { // Inner null check + downloadAlbumBtn.disabled = true; + downloadAlbumBtn.textContent = 'Queueing...'; + } + + downloadWholeAlbum(album) + .then(() => { + if (downloadAlbumBtn) downloadAlbumBtn.textContent = 'Queued!'; // Inner null check + }) + .catch(err => { + showError('Failed to queue album download: ' + (err?.message || 'Unknown error')); + if (downloadAlbumBtn) downloadAlbumBtn.disabled = false; // Inner null check + }); + }); + } + } + + // Render each track. + const tracksList = document.getElementById('tracks-list'); + if (tracksList) { // Null check + tracksList.innerHTML = ''; + + if (album.tracks?.items) { + album.tracks.items.forEach((track, index) => { + if (!track) return; // Skip null or undefined tracks + + // Skip explicit tracks if filter is enabled + if (isExplicitFilterEnabled && track.explicit) { + // Add a placeholder for filtered explicit tracks + const trackElement = document.createElement('div'); + trackElement.className = 'track track-filtered'; + trackElement.innerHTML = ` +
${index + 1}
+
+
Explicit Content Filtered
+
This track is not shown due to explicit content filter settings
+
+
--:--
+ `; + tracksList.appendChild(trackElement); + return; + } + + const trackElement = document.createElement('div'); + trackElement.className = 'track'; + trackElement.innerHTML = ` +
${index + 1}
+
+ +
+ ${track.artists?.map(a => + `${a?.name || 'Unknown Artist'}` + ).join(', ') || 'Unknown Artist'} +
+
+
${msToTime(track.duration_ms || 0)}
+ + `; + tracksList.appendChild(trackElement); + }); + } + } + + // Reveal header and track list. + const albumHeaderEl = document.getElementById('album-header'); + if (albumHeaderEl) albumHeaderEl.classList.remove('hidden'); + + const tracksContainerEl = document.getElementById('tracks-container'); + if (tracksContainerEl) tracksContainerEl.classList.remove('hidden'); + attachDownloadListeners(); + + // If on a small screen, re-arrange the action buttons. + if (window.innerWidth <= 480) { + let actionsContainer = document.getElementById('album-actions'); + if (!actionsContainer) { + actionsContainer = document.createElement('div'); + actionsContainer.id = 'album-actions'; + const albumHeader = document.getElementById('album-header'); + if (albumHeader) albumHeader.appendChild(actionsContainer); // Null check + } + if (actionsContainer) { // Null check for actionsContainer + actionsContainer.innerHTML = ''; // Clear any previous content + const homeBtn = document.getElementById('homeButton'); + if (homeBtn) actionsContainer.appendChild(homeBtn); // Null check + + const dlAlbumBtn = document.getElementById('downloadAlbumBtn'); + if (dlAlbumBtn) actionsContainer.appendChild(dlAlbumBtn); // Null check + + const queueToggle = document.querySelector('.queue-toggle'); + if (queueToggle) { + actionsContainer.appendChild(queueToggle); + } + } + } +} + +async function downloadWholeAlbum(album: Album) { + const url = album.external_urls?.spotify || ''; + if (!url) { + throw new Error('Missing album URL'); + } + + try { + // Use the centralized downloadQueue.download method + await downloadQueue.download(url, 'album', { name: album.name || 'Unknown Album' }); + // Make the queue visible after queueing + downloadQueue.toggleVisibility(true); + } catch (error: any) { // Add type for error + showError('Album download failed: ' + (error?.message || 'Unknown error')); + throw error; + } +} + +function msToTime(duration: number): string { + const minutes = Math.floor(duration / 60000); + const seconds = ((duration % 60000) / 1000).toFixed(0); + return `${minutes}:${seconds.padStart(2, '0')}`; +} + +function showError(message: string) { + const errorEl = document.getElementById('error'); + if (errorEl) { // Null check + errorEl.textContent = message || 'An error occurred'; + errorEl.classList.remove('hidden'); + } +} + +function attachDownloadListeners() { + document.querySelectorAll('.download-btn').forEach((btn) => { + const button = btn as HTMLButtonElement; // Cast to HTMLButtonElement + if (button.id === 'downloadAlbumBtn') return; + button.addEventListener('click', (e) => { + e.stopPropagation(); + const currentTarget = e.currentTarget as HTMLButtonElement | null; // Cast currentTarget + if (!currentTarget) return; + + const url = currentTarget.dataset.url || ''; + const type = currentTarget.dataset.type || ''; + const name = currentTarget.dataset.name || extractName(url) || 'Unknown'; + // Remove the button immediately after click. + currentTarget.remove(); + startDownload(url, type, { name }); // albumType will be undefined + }); + }); +} + +async function startDownload(url: string, type: string, item: { name: string }, albumType?: string) { // Add types and make albumType optional + if (!url) { + showError('Missing URL for download'); + return Promise.reject(new Error('Missing URL for download')); // Return a rejected promise + } + + try { + // Use the centralized downloadQueue.download method + await downloadQueue.download(url, type, item, albumType); + + // Make the queue visible after queueing + downloadQueue.toggleVisibility(true); + } catch (error: any) { // Add type for error + showError('Download failed: ' + (error?.message || 'Unknown error')); + throw error; + } +} + +function extractName(url: string | null | undefined): string { // Add type + return url || 'Unknown'; +} diff --git a/static/js/artist.js b/static/js/artist.js deleted file mode 100644 index 696fb84..0000000 --- a/static/js/artist.js +++ /dev/null @@ -1,381 +0,0 @@ -// Import the downloadQueue singleton -import { downloadQueue } from './queue.js'; - -document.addEventListener('DOMContentLoaded', () => { - const pathSegments = window.location.pathname.split('/'); - const artistId = pathSegments[pathSegments.indexOf('artist') + 1]; - - if (!artistId) { - showError('No artist ID provided.'); - return; - } - - // 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(); - }) - .then(data => renderArtist(data, artistId)) - .catch(error => { - console.error('Error:', error); - showError('Failed to load artist info.'); - }); - - const queueIcon = document.getElementById('queueIcon'); - if (queueIcon) { - queueIcon.addEventListener('click', () => downloadQueue.toggleVisibility()); - } -}); - -function renderArtist(artistData, artistId) { - document.getElementById('loading').classList.add('hidden'); - document.getElementById('error').classList.add('hidden'); - - // Check if explicit filter is enabled - const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled(); - - 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 = - `${artistName}`; - 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) - const artistUrl = `https://open.spotify.com/artist/${artistId}`; - - // Home Button - let homeButton = document.getElementById('homeButton'); - if (!homeButton) { - homeButton = document.createElement('button'); - homeButton.id = 'homeButton'; - homeButton.className = 'home-btn'; - homeButton.innerHTML = `Home`; - document.getElementById('artist-header').prepend(homeButton); - } - homeButton.addEventListener('click', () => window.location.href = window.location.origin); - - // Download Whole Artist Button using the new artist API endpoint - let downloadArtistBtn = document.getElementById('downloadArtistBtn'); - if (!downloadArtistBtn) { - downloadArtistBtn = document.createElement('button'); - downloadArtistBtn.id = 'downloadArtistBtn'; - downloadArtistBtn.className = 'download-btn download-btn--main'; - downloadArtistBtn.textContent = 'Download All Discography'; - document.getElementById('artist-header').appendChild(downloadArtistBtn); - } - - // When explicit filter is enabled, disable all download buttons - if (isExplicitFilterEnabled) { - // Disable the artist download button and display a message explaining why - downloadArtistBtn.disabled = true; - downloadArtistBtn.classList.add('download-btn--disabled'); - downloadArtistBtn.innerHTML = `Downloads Restricted`; - } else { - // Normal behavior when explicit filter is not enabled - downloadArtistBtn.addEventListener('click', () => { - // Optionally remove other download buttons from individual albums. - document.querySelectorAll('.download-btn:not(#downloadArtistBtn)').forEach(btn => btn.remove()); - downloadArtistBtn.disabled = true; - downloadArtistBtn.textContent = 'Queueing...'; - - // Queue the entire discography (albums, singles, compilations, and appears_on) - // Use our local startDownload function instead of downloadQueue.startArtistDownload - startDownload( - artistUrl, - 'artist', - { name: artistName, artist: artistName }, - 'album,single,compilation,appears_on' - ) - .then((taskIds) => { - downloadArtistBtn.textContent = 'Artist queued'; - // Make the queue visible after queueing - downloadQueue.toggleVisibility(true); - - // Optionally show number of albums queued - if (Array.isArray(taskIds)) { - downloadArtistBtn.title = `${taskIds.length} albums queued for download`; - } - }) - .catch(err => { - downloadArtistBtn.textContent = 'Download All Discography'; - downloadArtistBtn.disabled = false; - showError('Failed to queue artist download: ' + (err?.message || 'Unknown error')); - }); - }); - } - - // Group albums by type (album, single, compilation, etc.) and separate "appears_on" albums - const albumGroups = {}; - const appearingAlbums = []; - - (artistData.items || []).forEach(album => { - if (!album) return; - - // Skip explicit albums if filter is enabled - if (isExplicitFilterEnabled && album.explicit) { - return; - } - - // Check if this is an "appears_on" album - if (album.album_group === 'appears_on') { - appearingAlbums.push(album); - } else { - // Group by album_type for the artist's own releases - const type = (album.album_type || 'unknown').toLowerCase(); - if (!albumGroups[type]) albumGroups[type] = []; - albumGroups[type].push(album); - } - }); - - // Render album groups - const groupsContainer = document.getElementById('album-groups'); - groupsContainer.innerHTML = ''; - - // Render regular album groups first - for (const [groupType, albums] of Object.entries(albumGroups)) { - const groupSection = document.createElement('section'); - groupSection.className = 'album-group'; - - // If explicit filter is enabled, don't show the group download button - const groupHeaderHTML = isExplicitFilterEnabled ? - `
-

${capitalize(groupType)}s

-
Visit album pages to download content
-
` : - `
-

${capitalize(groupType)}s

- -
`; - - groupSection.innerHTML = ` - ${groupHeaderHTML} -
- `; - - const albumsContainer = groupSection.querySelector('.albums-list'); - albums.forEach(album => { - if (!album) return; - - const albumElement = document.createElement('div'); - albumElement.className = 'album-card'; - - // Create album card with or without download button based on explicit filter setting - if (isExplicitFilterEnabled) { - albumElement.innerHTML = ` - - Album cover - -
-
${album.name || 'Unknown Album'}
-
${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}
-
- `; - } else { - albumElement.innerHTML = ` - - Album cover - -
-
${album.name || 'Unknown Album'}
-
${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}
-
- - `; - } - - albumsContainer.appendChild(albumElement); - }); - - groupsContainer.appendChild(groupSection); - } - - // Render "Featuring" section if there are any appearing albums - if (appearingAlbums.length > 0) { - const featuringSection = document.createElement('section'); - featuringSection.className = 'album-group'; - - const featuringHeaderHTML = isExplicitFilterEnabled ? - `
-

Featuring

-
Visit album pages to download content
-
` : - `
-

Featuring

- -
`; - - featuringSection.innerHTML = ` - ${featuringHeaderHTML} -
- `; - - const albumsContainer = featuringSection.querySelector('.albums-list'); - appearingAlbums.forEach(album => { - if (!album) return; - - const albumElement = document.createElement('div'); - albumElement.className = 'album-card'; - - // Create album card with or without download button based on explicit filter setting - if (isExplicitFilterEnabled) { - albumElement.innerHTML = ` - - Album cover - -
-
${album.name || 'Unknown Album'}
-
${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}
-
- `; - } else { - albumElement.innerHTML = ` - - Album cover - -
-
${album.name || 'Unknown Album'}
-
${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}
-
- - `; - } - - albumsContainer.appendChild(albumElement); - }); - - // Add to the end so it appears at the bottom - groupsContainer.appendChild(featuringSection); - } - - document.getElementById('artist-header').classList.remove('hidden'); - document.getElementById('albums-container').classList.remove('hidden'); - - // Only attach download listeners if explicit filter is not enabled - if (!isExplicitFilterEnabled) { - attachDownloadListeners(); - // Pass the artist URL and name so the group buttons can use the artist download function - attachGroupDownloadListeners(artistUrl, artistName); - } -} - -// Event listeners for group downloads using the artist download function -function attachGroupDownloadListeners(artistUrl, artistName) { - document.querySelectorAll('.group-download-btn').forEach(btn => { - btn.addEventListener('click', async (e) => { - const groupType = e.target.dataset.groupType || 'album'; // e.g. "album", "single", "compilation", "appears_on" - e.target.disabled = true; - - // Custom text for the 'appears_on' group - const displayType = groupType === 'appears_on' ? 'Featuring Albums' : `${capitalize(groupType)}s`; - e.target.textContent = `Queueing all ${displayType}...`; - - try { - // Use our local startDownload function with the group type filter - const taskIds = await startDownload( - artistUrl, - 'artist', - { name: artistName || 'Unknown Artist', artist: artistName || 'Unknown Artist' }, - groupType // Only queue releases of this specific type. - ); - - // Optionally show number of albums queued - const totalQueued = Array.isArray(taskIds) ? taskIds.length : 0; - e.target.textContent = `Queued all ${displayType}`; - e.target.title = `${totalQueued} albums queued for download`; - - // Make the queue visible after queueing - downloadQueue.toggleVisibility(true); - } catch (error) { - e.target.textContent = `Download All ${displayType}`; - e.target.disabled = false; - showError(`Failed to queue download for all ${groupType}: ${error?.message || 'Unknown error'}`); - } - }); - }); -} - -// Individual download handlers remain unchanged. -function attachDownloadListeners() { - document.querySelectorAll('.download-btn:not(.group-download-btn)').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const url = e.currentTarget.dataset.url || ''; - const name = e.currentTarget.dataset.name || 'Unknown'; - // Always use 'album' type for individual album downloads regardless of category - const type = 'album'; - - e.currentTarget.remove(); - // Use the centralized downloadQueue.download method - downloadQueue.download(url, type, { name, type }) - .catch(err => showError('Download failed: ' + (err?.message || 'Unknown error'))); - }); - }); -} - -// Add startDownload function (similar to track.js and main.js) -/** - * Starts the download process via centralized download queue - */ -async function startDownload(url, type, item, albumType) { - if (!url || !type) { - showError('Missing URL or type for download'); - return; - } - - try { - // Use the centralized downloadQueue.download method for all downloads including artist downloads - const result = await downloadQueue.download(url, type, item, albumType); - - // Make the queue visible after queueing - downloadQueue.toggleVisibility(true); - - // Return the result for tracking - return result; - } catch (error) { - showError('Download failed: ' + (error?.message || 'Unknown error')); - throw error; - } -} - -// UI Helpers -function showError(message) { - const errorEl = document.getElementById('error'); - if (errorEl) { - errorEl.textContent = message || 'An error occurred'; - errorEl.classList.remove('hidden'); - } -} - -function capitalize(str) { - return str ? str.charAt(0).toUpperCase() + str.slice(1) : ''; -} diff --git a/static/js/artist.ts b/static/js/artist.ts new file mode 100644 index 0000000..0f39403 --- /dev/null +++ b/static/js/artist.ts @@ -0,0 +1,460 @@ +// Import the downloadQueue singleton +import { downloadQueue } from './queue.js'; + +// Define interfaces for API data +interface Image { + url: string; + height?: number; + width?: number; +} + +interface Artist { + id: string; + name: string; + external_urls: { + spotify: string; + }; +} + +interface Album { + id: string; + name: string; + artists: Artist[]; + images: Image[]; + album_type: string; // "album", "single", "compilation" + album_group?: string; // "album", "single", "compilation", "appears_on" + external_urls: { + spotify: string; + }; + explicit?: boolean; // Added to handle explicit filter + total_tracks?: number; + release_date?: string; +} + +interface ArtistData { + items: Album[]; + total: number; + // Add other properties if available from the API +} + +document.addEventListener('DOMContentLoaded', () => { + const pathSegments = window.location.pathname.split('/'); + const artistId = pathSegments[pathSegments.indexOf('artist') + 1]; + + if (!artistId) { + showError('No artist ID provided.'); + return; + } + + // 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() as Promise; + }) + .then(data => renderArtist(data, artistId)) + .catch(error => { + console.error('Error:', error); + showError('Failed to load artist info.'); + }); + + const queueIcon = document.getElementById('queueIcon'); + if (queueIcon) { + queueIcon.addEventListener('click', () => downloadQueue.toggleVisibility()); + } +}); + +function renderArtist(artistData: ArtistData, artistId: string) { + const loadingEl = document.getElementById('loading'); + if (loadingEl) loadingEl.classList.add('hidden'); + + const errorEl = document.getElementById('error'); + if (errorEl) errorEl.classList.add('hidden'); + + // Check if explicit filter is enabled + const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled(); + + const firstAlbum = artistData.items?.[0]; + const artistName = firstAlbum?.artists?.[0]?.name || 'Unknown Artist'; + const artistImageSrc = firstAlbum?.images?.[0]?.url || '/static/images/placeholder.jpg'; + + const artistNameEl = document.getElementById('artist-name'); + if (artistNameEl) { + artistNameEl.innerHTML = + `${artistName}`; + } + const artistStatsEl = document.getElementById('artist-stats'); + if (artistStatsEl) { + artistStatsEl.textContent = `${artistData.total || '0'} albums`; + } + const artistImageEl = document.getElementById('artist-image') as HTMLImageElement | null; + if (artistImageEl) { + artistImageEl.src = artistImageSrc; + } + + // Define the artist URL (used by both full-discography and group downloads) + const artistUrl = `https://open.spotify.com/artist/${artistId}`; + + // Home Button + let homeButton = document.getElementById('homeButton') as HTMLButtonElement | null; + if (!homeButton) { + homeButton = document.createElement('button'); + homeButton.id = 'homeButton'; + homeButton.className = 'home-btn'; + homeButton.innerHTML = `Home`; + const artistHeader = document.getElementById('artist-header'); + if (artistHeader) artistHeader.prepend(homeButton); + } + if (homeButton) { + homeButton.addEventListener('click', () => window.location.href = window.location.origin); + } + + // Download Whole Artist Button using the new artist API endpoint + let downloadArtistBtn = document.getElementById('downloadArtistBtn') as HTMLButtonElement | null; + if (!downloadArtistBtn) { + downloadArtistBtn = document.createElement('button'); + downloadArtistBtn.id = 'downloadArtistBtn'; + downloadArtistBtn.className = 'download-btn download-btn--main'; + downloadArtistBtn.textContent = 'Download All Discography'; + const artistHeader = document.getElementById('artist-header'); + if (artistHeader) artistHeader.appendChild(downloadArtistBtn); + } + + // When explicit filter is enabled, disable all download buttons + if (isExplicitFilterEnabled) { + if (downloadArtistBtn) { + // Disable the artist download button and display a message explaining why + downloadArtistBtn.disabled = true; + downloadArtistBtn.classList.add('download-btn--disabled'); + downloadArtistBtn.innerHTML = `Downloads Restricted`; + } + } else { + // Normal behavior when explicit filter is not enabled + if (downloadArtistBtn) { + downloadArtistBtn.addEventListener('click', () => { + // Optionally remove other download buttons from individual albums. + document.querySelectorAll('.download-btn:not(#downloadArtistBtn)').forEach(btn => btn.remove()); + if (downloadArtistBtn) { + downloadArtistBtn.disabled = true; + downloadArtistBtn.textContent = 'Queueing...'; + } + + // Queue the entire discography (albums, singles, compilations, and appears_on) + // Use our local startDownload function instead of downloadQueue.startArtistDownload + startDownload( + artistUrl, + 'artist', + { name: artistName, artist: artistName }, + 'album,single,compilation,appears_on' + ) + .then((taskIds) => { + if (downloadArtistBtn) { + downloadArtistBtn.textContent = 'Artist queued'; + // Make the queue visible after queueing + downloadQueue.toggleVisibility(true); + + // Optionally show number of albums queued + if (Array.isArray(taskIds)) { + downloadArtistBtn.title = `${taskIds.length} albums queued for download`; + } + } + }) + .catch(err => { + if (downloadArtistBtn) { + downloadArtistBtn.textContent = 'Download All Discography'; + downloadArtistBtn.disabled = false; + } + showError('Failed to queue artist download: ' + (err?.message || 'Unknown error')); + }); + }); + } + } + + // Group albums by type (album, single, compilation, etc.) and separate "appears_on" albums + const albumGroups: Record = {}; + const appearingAlbums: Album[] = []; + + (artistData.items || []).forEach(album => { + if (!album) return; + + // Skip explicit albums if filter is enabled + if (isExplicitFilterEnabled && album.explicit) { + return; + } + + // Check if this is an "appears_on" album + if (album.album_group === 'appears_on') { + appearingAlbums.push(album); + } else { + // Group by album_type for the artist's own releases + const type = (album.album_type || 'unknown').toLowerCase(); + if (!albumGroups[type]) albumGroups[type] = []; + albumGroups[type].push(album); + } + }); + + // Render album groups + const groupsContainer = document.getElementById('album-groups'); + if (groupsContainer) { + groupsContainer.innerHTML = ''; + + // Render regular album groups first + for (const [groupType, albums] of Object.entries(albumGroups)) { + const groupSection = document.createElement('section'); + groupSection.className = 'album-group'; + + // If explicit filter is enabled, don't show the group download button + const groupHeaderHTML = isExplicitFilterEnabled ? + `
+

${capitalize(groupType)}s

+
Visit album pages to download content
+
` : + `
+

${capitalize(groupType)}s

+ +
`; + + groupSection.innerHTML = ` + ${groupHeaderHTML} +
+ `; + + const albumsContainer = groupSection.querySelector('.albums-list'); + if (albumsContainer) { + albums.forEach(album => { + if (!album) return; + + const albumElement = document.createElement('div'); + albumElement.className = 'album-card'; + + // Create album card with or without download button based on explicit filter setting + if (isExplicitFilterEnabled) { + albumElement.innerHTML = ` + + Album cover + +
+
${album.name || 'Unknown Album'}
+
${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}
+
+ `; + } else { + albumElement.innerHTML = ` + + Album cover + +
+
${album.name || 'Unknown Album'}
+
${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}
+
+ + `; + } + + albumsContainer.appendChild(albumElement); + }); + } + + groupsContainer.appendChild(groupSection); + } + + // Render "Featuring" section if there are any appearing albums + if (appearingAlbums.length > 0) { + const featuringSection = document.createElement('section'); + featuringSection.className = 'album-group'; + + const featuringHeaderHTML = isExplicitFilterEnabled ? + `
+

Featuring

+
Visit album pages to download content
+
` : + `
+

Featuring

+ +
`; + + featuringSection.innerHTML = ` + ${featuringHeaderHTML} +
+ `; + + const albumsContainer = featuringSection.querySelector('.albums-list'); + if (albumsContainer) { + appearingAlbums.forEach(album => { + if (!album) return; + + const albumElement = document.createElement('div'); + albumElement.className = 'album-card'; + + // Create album card with or without download button based on explicit filter setting + if (isExplicitFilterEnabled) { + albumElement.innerHTML = ` + + Album cover + +
+
${album.name || 'Unknown Album'}
+
${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}
+
+ `; + } else { + albumElement.innerHTML = ` + + Album cover + +
+
${album.name || 'Unknown Album'}
+
${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}
+
+ + `; + } + + albumsContainer.appendChild(albumElement); + }); + } + + // Add to the end so it appears at the bottom + groupsContainer.appendChild(featuringSection); + } + } + + const artistHeaderEl = document.getElementById('artist-header'); + if (artistHeaderEl) artistHeaderEl.classList.remove('hidden'); + + const albumsContainerEl = document.getElementById('albums-container'); + if (albumsContainerEl) albumsContainerEl.classList.remove('hidden'); + + // Only attach download listeners if explicit filter is not enabled + if (!isExplicitFilterEnabled) { + attachDownloadListeners(); + // Pass the artist URL and name so the group buttons can use the artist download function + attachGroupDownloadListeners(artistUrl, artistName); + } +} + +// Event listeners for group downloads using the artist download function +function attachGroupDownloadListeners(artistUrl: string, artistName: string) { + document.querySelectorAll('.group-download-btn').forEach(btn => { + const button = btn as HTMLButtonElement; // Cast to HTMLButtonElement + button.addEventListener('click', async (e) => { + const target = e.target as HTMLButtonElement | null; // Cast target + if (!target) return; + + const groupType = target.dataset.groupType || 'album'; // e.g. "album", "single", "compilation", "appears_on" + target.disabled = true; + + // Custom text for the 'appears_on' group + const displayType = groupType === 'appears_on' ? 'Featuring Albums' : `${capitalize(groupType)}s`; + target.textContent = `Queueing all ${displayType}...`; + + try { + // Use our local startDownload function with the group type filter + const taskIds = await startDownload( + artistUrl, + 'artist', + { name: artistName || 'Unknown Artist', artist: artistName || 'Unknown Artist' }, + groupType // Only queue releases of this specific type. + ); + + // Optionally show number of albums queued + const totalQueued = Array.isArray(taskIds) ? taskIds.length : 0; + target.textContent = `Queued all ${displayType}`; + target.title = `${totalQueued} albums queued for download`; + + // Make the queue visible after queueing + downloadQueue.toggleVisibility(true); + } catch (error: any) { // Add type for error + target.textContent = `Download All ${displayType}`; + target.disabled = false; + showError(`Failed to queue download for all ${groupType}: ${error?.message || 'Unknown error'}`); + } + }); + }); +} + +// Individual download handlers remain unchanged. +function attachDownloadListeners() { + document.querySelectorAll('.download-btn:not(.group-download-btn)').forEach(btn => { + const button = btn as HTMLButtonElement; // Cast to HTMLButtonElement + button.addEventListener('click', (e) => { + e.stopPropagation(); + const currentTarget = e.currentTarget as HTMLButtonElement | null; // Cast currentTarget + if (!currentTarget) return; + + const url = currentTarget.dataset.url || ''; + const name = currentTarget.dataset.name || 'Unknown'; + // Always use 'album' type for individual album downloads regardless of category + const type = 'album'; + + currentTarget.remove(); + // Use the centralized downloadQueue.download method + downloadQueue.download(url, type, { name, type }) + .catch((err: any) => showError('Download failed: ' + (err?.message || 'Unknown error'))); // Add type for err + }); + }); +} + +// Add startDownload function (similar to track.js and main.js) +/** + * Starts the download process via centralized download queue + */ +async function startDownload(url: string, type: string, item: { name: string, artist?: string, type?: string }, albumType?: string) { + if (!url || !type) { + showError('Missing URL or type for download'); + return Promise.reject(new Error('Missing URL or type for download')); // Return a rejected promise + } + + try { + // Use the centralized downloadQueue.download method for all downloads including artist downloads + const result = await downloadQueue.download(url, type, item, albumType); + + // Make the queue visible after queueing + downloadQueue.toggleVisibility(true); + + // Return the result for tracking + return result; + } catch (error: any) { // Add type for error + showError('Download failed: ' + (error?.message || 'Unknown error')); + throw error; + } +} + +// UI Helpers +function showError(message: string) { + const errorEl = document.getElementById('error'); + if (errorEl) { + errorEl.textContent = message || 'An error occurred'; + errorEl.classList.remove('hidden'); + } +} + +function capitalize(str: string) { + return str ? str.charAt(0).toUpperCase() + str.slice(1) : ''; +} diff --git a/static/js/config.js b/static/js/config.js deleted file mode 100644 index 87ca997..0000000 --- a/static/js/config.js +++ /dev/null @@ -1,763 +0,0 @@ -import { downloadQueue } from './queue.js'; - -const serviceConfig = { - spotify: { - fields: [ - { id: 'username', label: 'Username', type: 'text' }, - { id: 'credentials', label: 'Credentials', type: 'text' } - ], - validator: (data) => ({ - username: data.username, - credentials: data.credentials - }), - // Adding search credentials fields - searchFields: [ - { id: 'client_id', label: 'Client ID', type: 'text' }, - { id: 'client_secret', label: 'Client Secret', type: 'password' } - ], - searchValidator: (data) => ({ - client_id: data.client_id, - client_secret: data.client_secret - }) - }, - deezer: { - fields: [ - { id: 'arl', label: 'ARL', type: 'text' } - ], - validator: (data) => ({ - arl: data.arl - }) - } -}; - -let currentService = 'spotify'; -let currentCredential = null; -let isEditingSearch = false; - -// Global variables to hold the active accounts from the config response. -let activeSpotifyAccount = ''; -let activeDeezerAccount = ''; - -document.addEventListener('DOMContentLoaded', async () => { - try { - await initConfig(); - setupServiceTabs(); - setupEventListeners(); - - const queueIcon = document.getElementById('queueIcon'); - if (queueIcon) { - queueIcon.addEventListener('click', () => { - downloadQueue.toggleVisibility(); - }); - } - } catch (error) { - showConfigError(error.message); - } -}); - -async function initConfig() { - await loadConfig(); - await updateAccountSelectors(); - loadCredentials(currentService); - updateFormFields(); -} - -function setupServiceTabs() { - const serviceTabs = document.querySelectorAll('.tab-button'); - serviceTabs.forEach(tab => { - tab.addEventListener('click', () => { - serviceTabs.forEach(t => t.classList.remove('active')); - tab.classList.add('active'); - currentService = tab.dataset.service; - loadCredentials(currentService); - updateFormFields(); - }); - }); -} - -function setupEventListeners() { - document.getElementById('credentialForm').addEventListener('submit', handleCredentialSubmit); - - // Config change listeners - document.getElementById('defaultServiceSelect').addEventListener('change', function() { - updateServiceSpecificOptions(); - saveConfig(); - }); - document.getElementById('fallbackToggle').addEventListener('change', saveConfig); - 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) => { - activeSpotifyAccount = e.target.value; - saveConfig(); - }); - document.getElementById('deezerAccountSelect').addEventListener('change', (e) => { - activeDeezerAccount = e.target.value; - saveConfig(); - }); - - // Formatting settings - document.getElementById('customDirFormat').addEventListener('change', saveConfig); - document.getElementById('customTrackFormat').addEventListener('change', saveConfig); - - // Copy to clipboard when selecting placeholders - document.getElementById('dirFormatHelp').addEventListener('change', function() { - copyPlaceholderToClipboard(this); - }); - document.getElementById('trackFormatHelp').addEventListener('change', function() { - copyPlaceholderToClipboard(this); - }); - - // Max concurrent downloads change listener - document.getElementById('maxConcurrentDownloads').addEventListener('change', saveConfig); -} - -function updateServiceSpecificOptions() { - // Get the selected service - const selectedService = document.getElementById('defaultServiceSelect').value; - - // Get all service-specific sections - const spotifyOptions = document.querySelectorAll('.config-item.spotify-specific'); - const deezerOptions = document.querySelectorAll('.config-item.deezer-specific'); - - // Handle Spotify specific options - if (selectedService === 'spotify') { - // Highlight Spotify section - document.getElementById('spotifyQualitySelect').closest('.config-item').classList.add('highlighted-option'); - document.getElementById('spotifyAccountSelect').closest('.config-item').classList.add('highlighted-option'); - - // Remove highlight from Deezer - document.getElementById('deezerQualitySelect').closest('.config-item').classList.remove('highlighted-option'); - document.getElementById('deezerAccountSelect').closest('.config-item').classList.remove('highlighted-option'); - } - // Handle Deezer specific options (for future use) - else if (selectedService === 'deezer') { - // Highlight Deezer section - document.getElementById('deezerQualitySelect').closest('.config-item').classList.add('highlighted-option'); - document.getElementById('deezerAccountSelect').closest('.config-item').classList.add('highlighted-option'); - - // Remove highlight from Spotify - document.getElementById('spotifyQualitySelect').closest('.config-item').classList.remove('highlighted-option'); - document.getElementById('spotifyAccountSelect').closest('.config-item').classList.remove('highlighted-option'); - } -} - -async function updateAccountSelectors() { - try { - const [spotifyResponse, deezerResponse] = await Promise.all([ - fetch('/api/credentials/spotify'), - fetch('/api/credentials/deezer') - ]); - - const spotifyAccounts = await spotifyResponse.json(); - const deezerAccounts = await deezerResponse.json(); - - // Get the select elements - const spotifySelect = document.getElementById('spotifyAccountSelect'); - const deezerSelect = document.getElementById('deezerAccountSelect'); - - // Rebuild the Spotify selector options - spotifySelect.innerHTML = spotifyAccounts - .map(a => ``) - .join(''); - - // Use the active account loaded from the config (activeSpotifyAccount) - if (spotifyAccounts.includes(activeSpotifyAccount)) { - spotifySelect.value = activeSpotifyAccount; - } else if (spotifyAccounts.length > 0) { - spotifySelect.value = spotifyAccounts[0]; - activeSpotifyAccount = spotifyAccounts[0]; - await saveConfig(); - } - - // Rebuild the Deezer selector options - deezerSelect.innerHTML = deezerAccounts - .map(a => ``) - .join(''); - - if (deezerAccounts.includes(activeDeezerAccount)) { - deezerSelect.value = activeDeezerAccount; - } else if (deezerAccounts.length > 0) { - deezerSelect.value = deezerAccounts[0]; - activeDeezerAccount = deezerAccounts[0]; - await saveConfig(); - } - - // Handle empty account lists - [spotifySelect, deezerSelect].forEach((select, index) => { - const accounts = index === 0 ? spotifyAccounts : deezerAccounts; - if (accounts.length === 0) { - select.innerHTML = ''; - select.value = ''; - } - }); - } catch (error) { - showConfigError('Error updating accounts: ' + error.message); - } -} - -async function loadCredentials(service) { - try { - const response = await fetch(`/api/credentials/all/${service}`); - if (!response.ok) { - throw new Error(`Failed to load credentials: ${response.statusText}`); - } - - const credentials = await response.json(); - renderCredentialsList(service, credentials); - } catch (error) { - showConfigError(error.message); - } -} - -function renderCredentialsList(service, credentials) { - const list = document.querySelector('.credentials-list'); - list.innerHTML = ''; - - if (!credentials.length) { - list.innerHTML = '
No accounts found. Add a new account below.
'; - return; - } - - credentials.forEach(credData => { - const credItem = document.createElement('div'); - credItem.className = 'credential-item'; - - const hasSearchCreds = credData.search && Object.keys(credData.search).length > 0; - - credItem.innerHTML = ` -
- ${credData.name} - ${service === 'spotify' ? - `
- ${hasSearchCreds ? 'API Configured' : 'No API Credentials'} -
` : ''} -
-
- - ${service === 'spotify' ? - `` : ''} - -
- `; - - list.appendChild(credItem); - }); - - // Set up event handlers - list.querySelectorAll('.delete-btn').forEach(btn => { - btn.addEventListener('click', handleDeleteCredential); - }); - - list.querySelectorAll('.edit-btn').forEach(btn => { - btn.addEventListener('click', (e) => { - isEditingSearch = false; - handleEditCredential(e); - }); - }); - - if (service === 'spotify') { - list.querySelectorAll('.edit-search-btn').forEach(btn => { - btn.addEventListener('click', handleEditSearchCredential); - }); - } -} - -async function handleDeleteCredential(e) { - try { - const service = e.target.dataset.service; - const name = e.target.dataset.name; - - if (!service || !name) { - throw new Error('Missing credential information'); - } - - if (!confirm(`Are you sure you want to delete the ${name} account?`)) { - return; - } - - const response = await fetch(`/api/credentials/${service}/${name}`, { - method: 'DELETE' - }); - - if (!response.ok) { - throw new Error('Failed to delete credential'); - } - - // If the deleted credential is the active account, clear the selection. - const accountSelect = document.getElementById(`${service}AccountSelect`); - if (accountSelect.value === name) { - accountSelect.value = ''; - if (service === 'spotify') { - activeSpotifyAccount = ''; - } else if (service === 'deezer') { - activeDeezerAccount = ''; - } - await saveConfig(); - } - - loadCredentials(service); - await updateAccountSelectors(); - } catch (error) { - showConfigError(error.message); - } -} - -async function handleEditCredential(e) { - const service = e.target.dataset.service; - const name = e.target.dataset.name; - - try { - document.querySelector(`[data-service="${service}"]`).click(); - await new Promise(resolve => setTimeout(resolve, 50)); - - const response = await fetch(`/api/credentials/${service}/${name}`); - if (!response.ok) { - throw new Error(`Failed to load credential: ${response.statusText}`); - } - - const data = await response.json(); - - currentCredential = name; - document.getElementById('credentialName').value = name; - document.getElementById('credentialName').disabled = true; - document.getElementById('formTitle').textContent = `Edit ${service.charAt(0).toUpperCase() + service.slice(1)} Account`; - document.getElementById('submitCredentialBtn').textContent = 'Update Account'; - - // Show regular fields - populateFormFields(service, data); - toggleSearchFieldsVisibility(false); - } catch (error) { - showConfigError(error.message); - } -} - -async function handleEditSearchCredential(e) { - const service = e.target.dataset.service; - const name = e.target.dataset.name; - - try { - if (service !== 'spotify') { - throw new Error('Search credentials are only available for Spotify'); - } - - document.querySelector(`[data-service="${service}"]`).click(); - await new Promise(resolve => setTimeout(resolve, 50)); - - isEditingSearch = true; - currentCredential = name; - document.getElementById('credentialName').value = name; - document.getElementById('credentialName').disabled = true; - document.getElementById('formTitle').textContent = `Spotify API Credentials for ${name}`; - document.getElementById('submitCredentialBtn').textContent = 'Save API Credentials'; - - // Try to load existing search credentials - try { - const searchResponse = await fetch(`/api/credentials/${service}/${name}?type=search`); - if (searchResponse.ok) { - const searchData = await searchResponse.json(); - // Populate search fields - serviceConfig[service].searchFields.forEach(field => { - const element = document.getElementById(field.id); - if (element) element.value = searchData[field.id] || ''; - }); - } else { - // Clear search fields if no existing search credentials - serviceConfig[service].searchFields.forEach(field => { - const element = document.getElementById(field.id); - if (element) element.value = ''; - }); - } - } catch (error) { - // Clear search fields if there was an error - serviceConfig[service].searchFields.forEach(field => { - const element = document.getElementById(field.id); - if (element) element.value = ''; - }); - } - - // Hide regular account fields, show search fields - toggleSearchFieldsVisibility(true); - } catch (error) { - showConfigError(error.message); - } -} - -function toggleSearchFieldsVisibility(showSearchFields) { - const serviceFieldsDiv = document.getElementById('serviceFields'); - 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'); - }); - } - } -} - -function updateFormFields() { - const serviceFieldsDiv = document.getElementById('serviceFields'); - const searchFieldsDiv = document.getElementById('searchFields'); - - // Clear any existing fields - serviceFieldsDiv.innerHTML = ''; - searchFieldsDiv.innerHTML = ''; - - // Add regular account fields - serviceConfig[currentService].fields.forEach(field => { - const fieldDiv = document.createElement('div'); - fieldDiv.className = 'form-group'; - fieldDiv.innerHTML = ` - - - `; - serviceFieldsDiv.appendChild(fieldDiv); - }); - - // Add search fields for Spotify - if (currentService === 'spotify' && serviceConfig[currentService].searchFields) { - serviceConfig[currentService].searchFields.forEach(field => { - const fieldDiv = document.createElement('div'); - fieldDiv.className = 'form-group'; - fieldDiv.innerHTML = ` - - - `; - searchFieldsDiv.appendChild(fieldDiv); - }); - } - - // Reset form title and button text - document.getElementById('formTitle').textContent = `Add New ${currentService.charAt(0).toUpperCase() + currentService.slice(1)} Account`; - document.getElementById('submitCredentialBtn').textContent = 'Save Account'; - - // Initially show regular fields, hide search fields - toggleSearchFieldsVisibility(false); - isEditingSearch = false; -} - -function populateFormFields(service, data) { - serviceConfig[service].fields.forEach(field => { - const element = document.getElementById(field.id); - if (element) element.value = data[field.id] || ''; - }); -} - -async function handleCredentialSubmit(e) { - e.preventDefault(); - const service = document.querySelector('.tab-button.active').dataset.service; - const nameInput = document.getElementById('credentialName'); - const name = nameInput.value.trim(); - - try { - if (!currentCredential && !name) { - throw new Error('Credential name is required'); - } - - const endpointName = currentCredential || name; - let method, data, endpoint; - - if (isEditingSearch && service === 'spotify') { - // Handle search credentials - const formData = {}; - let isValid = true; - let firstInvalidField = null; - - // Manually validate search fields - serviceConfig[service].searchFields.forEach(field => { - 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`; - - // Check if search credentials already exist for this account - const checkResponse = await fetch(endpoint); - method = checkResponse.ok ? 'PUT' : 'POST'; - } else { - // Handle regular account credentials - const formData = {}; - let isValid = true; - let firstInvalidField = null; - - // Manually validate account fields - serviceConfig[service].fields.forEach(field => { - 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}`; - method = currentCredential ? 'PUT' : 'POST'; - } - - const response = await fetch(endpoint, { - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to save credentials'); - } - - await updateAccountSelectors(); - await saveConfig(); - loadCredentials(service); - resetForm(); - - // Show success message - showConfigSuccess(isEditingSearch ? 'API credentials saved successfully' : 'Account saved successfully'); - } catch (error) { - showConfigError(error.message); - } -} - -function resetForm() { - currentCredential = null; - isEditingSearch = false; - const nameInput = document.getElementById('credentialName'); - nameInput.value = ''; - nameInput.disabled = false; - document.getElementById('credentialForm').reset(); - - // Reset form title and button text - const service = currentService.charAt(0).toUpperCase() + currentService.slice(1); - document.getElementById('formTitle').textContent = `Add New ${service} Account`; - document.getElementById('submitCredentialBtn').textContent = 'Save Account'; - - // Show regular account fields, hide search fields - toggleSearchFieldsVisibility(false); -} - -async function saveConfig() { - // Read active account values directly from the DOM (or from the globals which are kept in sync) - const config = { - service: document.getElementById('defaultServiceSelect').value, - spotify: document.getElementById('spotifyAccountSelect').value, - deezer: document.getElementById('deezerAccountSelect').value, - fallback: document.getElementById('fallbackToggle').checked, - spotifyQuality: document.getElementById('spotifyQualitySelect').value, - deezerQuality: document.getElementById('deezerQualitySelect').value, - realTime: document.getElementById('realTimeToggle').checked, - customDirFormat: document.getElementById('customDirFormat').value, - customTrackFormat: document.getElementById('customTrackFormat').value, - 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 { - const response = await fetch('/api/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(config) - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to save config'); - } - } catch (error) { - showConfigError(error.message); - } -} - -async function loadConfig() { - try { - const response = await fetch('/api/config'); - if (!response.ok) throw new Error('Failed to load config'); - - const savedConfig = await response.json(); - - // Set default service selection - document.getElementById('defaultServiceSelect').value = savedConfig.service || 'spotify'; - - // Update the service-specific options based on selected service - updateServiceSpecificOptions(); - - // Use the "spotify" and "deezer" properties from the API response to set the active accounts. - activeSpotifyAccount = savedConfig.spotify || ''; - activeDeezerAccount = savedConfig.deezer || ''; - - // (Optionally, if the account selects already exist you can set their values here, - // but updateAccountSelectors() will rebuild the options and set the proper values.) - const spotifySelect = document.getElementById('spotifyAccountSelect'); - const deezerSelect = document.getElementById('deezerAccountSelect'); - if (spotifySelect) spotifySelect.value = activeSpotifyAccount; - if (deezerSelect) deezerSelect.value = activeDeezerAccount; - - // Update other configuration fields. - document.getElementById('fallbackToggle').checked = !!savedConfig.fallback; - document.getElementById('spotifyQualitySelect').value = savedConfig.spotifyQuality || 'NORMAL'; - document.getElementById('deezerQualitySelect').value = savedConfig.deezerQuality || 'MP3_128'; - document.getElementById('realTimeToggle').checked = !!savedConfig.realTime; - document.getElementById('customDirFormat').value = savedConfig.customDirFormat || '%ar_album%/%album%'; - document.getElementById('customTrackFormat').value = savedConfig.customTrackFormat || '%tracknum%. %music%'; - document.getElementById('maxConcurrentDownloads').value = savedConfig.maxConcurrentDownloads || '3'; - 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; - - // Update explicit filter status - updateExplicitFilterStatus(savedConfig.explicitFilter); - } catch (error) { - showConfigError('Error loading config: ' + error.message); - } -} - -function updateExplicitFilterStatus(isEnabled) { - const statusElement = document.getElementById('explicitFilterStatus'); - if (statusElement) { - // Remove existing classes - statusElement.classList.remove('enabled', 'disabled'); - - // Add appropriate class and text based on whether filter is enabled - if (isEnabled) { - statusElement.textContent = 'Enabled'; - statusElement.classList.add('enabled'); - } else { - statusElement.textContent = 'Disabled'; - statusElement.classList.add('disabled'); - } - } -} - -function showConfigError(message) { - const errorDiv = document.getElementById('configError'); - errorDiv.textContent = message; - setTimeout(() => (errorDiv.textContent = ''), 5000); -} - -function showConfigSuccess(message) { - const successDiv = document.getElementById('configSuccess'); - successDiv.textContent = message; - setTimeout(() => (successDiv.textContent = ''), 5000); -} - -// Function to copy the selected placeholder to clipboard -function copyPlaceholderToClipboard(select) { - const placeholder = select.value; - - if (!placeholder) return; // If nothing selected - - // Copy to clipboard - navigator.clipboard.writeText(placeholder) - .then(() => { - // Show success notification - showCopyNotification(`Copied ${placeholder} to clipboard`); - - // Reset select to default after a short delay - setTimeout(() => { - select.selectedIndex = 0; - }, 500); - }) - .catch(err => { - console.error('Failed to copy: ', err); - }); -} - -// Function to show a notification when copying -function showCopyNotification(message) { - // Check if notification container exists, create if not - let notificationContainer = document.getElementById('copyNotificationContainer'); - if (!notificationContainer) { - notificationContainer = document.createElement('div'); - notificationContainer.id = 'copyNotificationContainer'; - document.body.appendChild(notificationContainer); - } - - // Create notification element - const notification = document.createElement('div'); - notification.className = 'copy-notification'; - notification.textContent = message; - - // Add to container - notificationContainer.appendChild(notification); - - // Trigger animation - setTimeout(() => { - notification.classList.add('show'); - }, 10); - - // Remove after animation completes - setTimeout(() => { - notification.classList.remove('show'); - setTimeout(() => { - notificationContainer.removeChild(notification); - }, 300); - }, 2000); -} diff --git a/static/js/config.ts b/static/js/config.ts new file mode 100644 index 0000000..21a4840 --- /dev/null +++ b/static/js/config.ts @@ -0,0 +1,841 @@ +import { downloadQueue } from './queue.js'; + +const serviceConfig: Record = { + spotify: { + fields: [ + { id: 'username', label: 'Username', type: 'text' }, + { id: 'credentials', label: 'Credentials', type: 'text' } + ], + validator: (data) => ({ + username: data.username, + credentials: data.credentials + }), + // Adding search credentials fields + searchFields: [ + { id: 'client_id', label: 'Client ID', type: 'text' }, + { id: 'client_secret', label: 'Client Secret', type: 'password' } + ], + searchValidator: (data) => ({ + client_id: data.client_id, + client_secret: data.client_secret + }) + }, + deezer: { + fields: [ + { id: 'arl', label: 'ARL', type: 'text' } + ], + validator: (data) => ({ + arl: data.arl + }) + } +}; + +let currentService = 'spotify'; +let currentCredential: string | null = null; +let isEditingSearch = false; + +// Global variables to hold the active accounts from the config response. +let activeSpotifyAccount = ''; +let activeDeezerAccount = ''; + +async function loadConfig() { + try { + const response = await fetch('/api/config'); + if (!response.ok) throw new Error('Failed to load config'); + + const savedConfig = await response.json(); + + // Set default service selection + const defaultServiceSelect = document.getElementById('defaultServiceSelect') as HTMLSelectElement | null; + if (defaultServiceSelect) defaultServiceSelect.value = savedConfig.service || 'spotify'; + + // Update the service-specific options based on selected service + updateServiceSpecificOptions(); + + // Use the "spotify" and "deezer" properties from the API response to set the active accounts. + activeSpotifyAccount = savedConfig.spotify || ''; + activeDeezerAccount = savedConfig.deezer || ''; + + // (Optionally, if the account selects already exist you can set their values here, + // but updateAccountSelectors() will rebuild the options and set the proper values.) + const spotifySelect = document.getElementById('spotifyAccountSelect') as HTMLSelectElement | null; + const deezerSelect = document.getElementById('deezerAccountSelect') as HTMLSelectElement | null; + if (spotifySelect) spotifySelect.value = activeSpotifyAccount; + if (deezerSelect) deezerSelect.value = activeDeezerAccount; + + // Update other configuration fields. + const fallbackToggle = document.getElementById('fallbackToggle') as HTMLInputElement | null; + if (fallbackToggle) fallbackToggle.checked = !!savedConfig.fallback; + const spotifyQualitySelect = document.getElementById('spotifyQualitySelect') as HTMLSelectElement | null; + if (spotifyQualitySelect) spotifyQualitySelect.value = savedConfig.spotifyQuality || 'NORMAL'; + const deezerQualitySelect = document.getElementById('deezerQualitySelect') as HTMLSelectElement | null; + if (deezerQualitySelect) deezerQualitySelect.value = savedConfig.deezerQuality || 'MP3_128'; + const realTimeToggle = document.getElementById('realTimeToggle') as HTMLInputElement | null; + if (realTimeToggle) realTimeToggle.checked = !!savedConfig.realTime; + const customDirFormat = document.getElementById('customDirFormat') as HTMLInputElement | null; + if (customDirFormat) customDirFormat.value = savedConfig.customDirFormat || '%ar_album%/%album%'; + const customTrackFormat = document.getElementById('customTrackFormat') as HTMLInputElement | null; + if (customTrackFormat) customTrackFormat.value = savedConfig.customTrackFormat || '%tracknum%. %music%'; + const maxConcurrentDownloads = document.getElementById('maxConcurrentDownloads') as HTMLInputElement | null; + if (maxConcurrentDownloads) maxConcurrentDownloads.value = savedConfig.maxConcurrentDownloads || '3'; + const maxRetries = document.getElementById('maxRetries') as HTMLInputElement | null; + if (maxRetries) maxRetries.value = savedConfig.maxRetries || '3'; + const retryDelaySeconds = document.getElementById('retryDelaySeconds') as HTMLInputElement | null; + if (retryDelaySeconds) retryDelaySeconds.value = savedConfig.retryDelaySeconds || '5'; + const retryDelayIncrease = document.getElementById('retryDelayIncrease') as HTMLInputElement | null; + if (retryDelayIncrease) retryDelayIncrease.value = savedConfig.retry_delay_increase || '5'; + const tracknumPaddingToggle = document.getElementById('tracknumPaddingToggle') as HTMLInputElement | null; + if (tracknumPaddingToggle) tracknumPaddingToggle.checked = savedConfig.tracknum_padding === undefined ? true : !!savedConfig.tracknum_padding; + + // Update explicit filter status + updateExplicitFilterStatus(savedConfig.explicitFilter); + } catch (error: any) { + showConfigError('Error loading config: ' + error.message); + } +} + +document.addEventListener('DOMContentLoaded', async () => { + try { + await initConfig(); + setupServiceTabs(); + setupEventListeners(); + + const queueIcon = document.getElementById('queueIcon'); + if (queueIcon) { + queueIcon.addEventListener('click', () => { + downloadQueue.toggleVisibility(); + }); + } + } catch (error: any) { + showConfigError(error.message); + } +}); + +async function initConfig() { + await loadConfig(); + await updateAccountSelectors(); + loadCredentials(currentService); + updateFormFields(); +} + +function setupServiceTabs() { + const serviceTabs = document.querySelectorAll('.tab-button'); + serviceTabs.forEach(tab => { + tab.addEventListener('click', () => { + serviceTabs.forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + currentService = (tab as HTMLElement).dataset.service || 'spotify'; + loadCredentials(currentService); + updateFormFields(); + }); + }); +} + +function setupEventListeners() { + (document.getElementById('credentialForm') as HTMLFormElement | null)?.addEventListener('submit', handleCredentialSubmit); + + // Config change listeners + (document.getElementById('defaultServiceSelect') as HTMLSelectElement | null)?.addEventListener('change', function() { + updateServiceSpecificOptions(); + saveConfig(); + }); + (document.getElementById('fallbackToggle') as HTMLInputElement | null)?.addEventListener('change', saveConfig); + (document.getElementById('realTimeToggle') as HTMLInputElement | null)?.addEventListener('change', saveConfig); + (document.getElementById('spotifyQualitySelect') as HTMLSelectElement | null)?.addEventListener('change', saveConfig); + (document.getElementById('deezerQualitySelect') as HTMLSelectElement | null)?.addEventListener('change', saveConfig); + (document.getElementById('tracknumPaddingToggle') as HTMLInputElement | null)?.addEventListener('change', saveConfig); + (document.getElementById('maxRetries') as HTMLInputElement | null)?.addEventListener('change', saveConfig); + (document.getElementById('retryDelaySeconds') as HTMLInputElement | null)?.addEventListener('change', saveConfig); + + // Update active account globals when the account selector is changed. + (document.getElementById('spotifyAccountSelect') as HTMLSelectElement | null)?.addEventListener('change', (e: Event) => { + activeSpotifyAccount = (e.target as HTMLSelectElement).value; + saveConfig(); + }); + (document.getElementById('deezerAccountSelect') as HTMLSelectElement | null)?.addEventListener('change', (e: Event) => { + activeDeezerAccount = (e.target as HTMLSelectElement).value; + saveConfig(); + }); + + // Formatting settings + (document.getElementById('customDirFormat') as HTMLInputElement | null)?.addEventListener('change', saveConfig); + (document.getElementById('customTrackFormat') as HTMLInputElement | null)?.addEventListener('change', saveConfig); + + // Copy to clipboard when selecting placeholders + (document.getElementById('dirFormatHelp') as HTMLSelectElement | null)?.addEventListener('change', function() { + copyPlaceholderToClipboard(this as HTMLSelectElement); + }); + (document.getElementById('trackFormatHelp') as HTMLSelectElement | null)?.addEventListener('change', function() { + copyPlaceholderToClipboard(this as HTMLSelectElement); + }); + + // Max concurrent downloads change listener + (document.getElementById('maxConcurrentDownloads') as HTMLInputElement | null)?.addEventListener('change', saveConfig); +} + +function updateServiceSpecificOptions() { + // Get the selected service + const selectedService = (document.getElementById('defaultServiceSelect') as HTMLSelectElement | null)?.value; + + // Get all service-specific sections + const spotifyOptions = document.querySelectorAll('.config-item.spotify-specific'); + const deezerOptions = document.querySelectorAll('.config-item.deezer-specific'); + + // Handle Spotify specific options + if (selectedService === 'spotify') { + // Highlight Spotify section + (document.getElementById('spotifyQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option'); + (document.getElementById('spotifyAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option'); + + // Remove highlight from Deezer + (document.getElementById('deezerQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option'); + (document.getElementById('deezerAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option'); + } + // Handle Deezer specific options (for future use) + else if (selectedService === 'deezer') { + // Highlight Deezer section + (document.getElementById('deezerQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option'); + (document.getElementById('deezerAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option'); + + // Remove highlight from Spotify + (document.getElementById('spotifyQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option'); + (document.getElementById('spotifyAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option'); + } +} + +async function updateAccountSelectors() { + try { + const [spotifyResponse, deezerResponse] = await Promise.all([ + fetch('/api/credentials/spotify'), + fetch('/api/credentials/deezer') + ]); + + const spotifyAccounts = await spotifyResponse.json(); + const deezerAccounts = await deezerResponse.json(); + + // Get the select elements + const spotifySelect = document.getElementById('spotifyAccountSelect') as HTMLSelectElement | null; + const deezerSelect = document.getElementById('deezerAccountSelect') as HTMLSelectElement | null; + + // Rebuild the Spotify selector options + if (spotifySelect) { + spotifySelect.innerHTML = spotifyAccounts + .map((a: string) => ``) + .join(''); + + // Use the active account loaded from the config (activeSpotifyAccount) + if (spotifyAccounts.includes(activeSpotifyAccount)) { + spotifySelect.value = activeSpotifyAccount; + } else if (spotifyAccounts.length > 0) { + spotifySelect.value = spotifyAccounts[0]; + activeSpotifyAccount = spotifyAccounts[0]; + await saveConfig(); + } + } + + // Rebuild the Deezer selector options + if (deezerSelect) { + deezerSelect.innerHTML = deezerAccounts + .map((a: string) => ``) + .join(''); + + if (deezerAccounts.includes(activeDeezerAccount)) { + deezerSelect.value = activeDeezerAccount; + } else if (deezerAccounts.length > 0) { + deezerSelect.value = deezerAccounts[0]; + activeDeezerAccount = deezerAccounts[0]; + await saveConfig(); + } + } + + // Handle empty account lists + [spotifySelect, deezerSelect].forEach((select, index) => { + const accounts = index === 0 ? spotifyAccounts : deezerAccounts; + if (select && accounts.length === 0) { + select.innerHTML = ''; + select.value = ''; + } + }); + } catch (error: any) { + showConfigError('Error updating accounts: ' + error.message); + } +} + +async function loadCredentials(service: string) { + try { + const response = await fetch(`/api/credentials/all/${service}`); + if (!response.ok) { + throw new Error(`Failed to load credentials: ${response.statusText}`); + } + + const credentials = await response.json(); + renderCredentialsList(service, credentials); + } catch (error: any) { + showConfigError(error.message); + } +} + +function renderCredentialsList(service: string, credentials: any[]) { + const list = document.querySelector('.credentials-list') as HTMLElement | null; + if (!list) return; + list.innerHTML = ''; + + if (!credentials.length) { + list.innerHTML = '
No accounts found. Add a new account below.
'; + return; + } + + credentials.forEach(credData => { + const credItem = document.createElement('div'); + credItem.className = 'credential-item'; + + const hasSearchCreds = credData.search && Object.keys(credData.search).length > 0; + + credItem.innerHTML = ` +
+ ${credData.name} + ${service === 'spotify' ? + `
+ ${hasSearchCreds ? 'API Configured' : 'No API Credentials'} +
` : ''} +
+
+ + ${service === 'spotify' ? + `` : ''} + +
+ `; + + list.appendChild(credItem); + }); + + // Set up event handlers + list.querySelectorAll('.delete-btn').forEach(btn => { + btn.addEventListener('click', handleDeleteCredential as EventListener); + }); + + list.querySelectorAll('.edit-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + isEditingSearch = false; + handleEditCredential(e as MouseEvent); + }); + }); + + if (service === 'spotify') { + list.querySelectorAll('.edit-search-btn').forEach(btn => { + btn.addEventListener('click', handleEditSearchCredential as EventListener); + }); + } +} + +async function handleDeleteCredential(e: Event) { + try { + const target = e.target as HTMLElement; + const service = target.dataset.service; + const name = target.dataset.name; + + if (!service || !name) { + throw new Error('Missing credential information'); + } + + if (!confirm(`Are you sure you want to delete the ${name} account?`)) { + return; + } + + const response = await fetch(`/api/credentials/${service}/${name}`, { + method: 'DELETE' + }); + + if (!response.ok) { + throw new Error('Failed to delete credential'); + } + + // If the deleted credential is the active account, clear the selection. + const accountSelect = document.getElementById(`${service}AccountSelect`) as HTMLSelectElement | null; + if (accountSelect && accountSelect.value === name) { + accountSelect.value = ''; + if (service === 'spotify') { + activeSpotifyAccount = ''; + } else if (service === 'deezer') { + activeDeezerAccount = ''; + } + await saveConfig(); + } + + loadCredentials(service); + await updateAccountSelectors(); + } catch (error: any) { + showConfigError(error.message); + } +} + +async function handleEditCredential(e: MouseEvent) { + const target = e.target as HTMLElement; + const service = target.dataset.service; + const name = target.dataset.name; + + try { + (document.querySelector(`[data-service="${service}"]`) as HTMLElement | null)?.click(); + await new Promise(resolve => setTimeout(resolve, 50)); + + const response = await fetch(`/api/credentials/${service}/${name}`); + if (!response.ok) { + throw new Error(`Failed to load credential: ${response.statusText}`); + } + + const data = await response.json(); + + currentCredential = name ? name : null; + const credentialNameInput = document.getElementById('credentialName') as HTMLInputElement | null; + if (credentialNameInput) { + credentialNameInput.value = name || ''; + credentialNameInput.disabled = true; + } + (document.getElementById('formTitle') as HTMLElement | null)!.textContent = `Edit ${service!.charAt(0).toUpperCase() + service!.slice(1)} Account`; + (document.getElementById('submitCredentialBtn') as HTMLElement | null)!.textContent = 'Update Account'; + + // Show regular fields + populateFormFields(service!, data); + toggleSearchFieldsVisibility(false); + } catch (error: any) { + showConfigError(error.message); + } +} + +async function handleEditSearchCredential(e: Event) { + const target = e.target as HTMLElement; + const service = target.dataset.service; + const name = target.dataset.name; + + try { + if (service !== 'spotify') { + throw new Error('Search credentials are only available for Spotify'); + } + + (document.querySelector(`[data-service="${service}"]`) as HTMLElement | null)?.click(); + await new Promise(resolve => setTimeout(resolve, 50)); + + isEditingSearch = true; + currentCredential = name ? name : null; + const credentialNameInput = document.getElementById('credentialName') as HTMLInputElement | null; + if (credentialNameInput) { + credentialNameInput.value = name || ''; + credentialNameInput.disabled = true; + } + (document.getElementById('formTitle')as HTMLElement | null)!.textContent = `Spotify API Credentials for ${name}`; + (document.getElementById('submitCredentialBtn') as HTMLElement | null)!.textContent = 'Save API Credentials'; + + // Try to load existing search credentials + try { + const searchResponse = await fetch(`/api/credentials/${service}/${name}?type=search`); + if (searchResponse.ok) { + const searchData = await searchResponse.json(); + // Populate search fields + serviceConfig[service].searchFields.forEach((field: { id: string; }) => { + const element = document.getElementById(field.id) as HTMLInputElement | null; + if (element) element.value = searchData[field.id] || ''; + }); + } else { + // Clear search fields if no existing search credentials + serviceConfig[service].searchFields.forEach((field: { id: string; }) => { + const element = document.getElementById(field.id) as HTMLInputElement | null; + if (element) element.value = ''; + }); + } + } catch (error) { + // Clear search fields if there was an error + serviceConfig[service].searchFields.forEach((field: { id: string; }) => { + const element = document.getElementById(field.id) as HTMLInputElement | null; + if (element) element.value = ''; + }); + } + + // Hide regular account fields, show search fields + toggleSearchFieldsVisibility(true); + } catch (error: any) { + showConfigError(error.message); + } +} + +function toggleSearchFieldsVisibility(showSearchFields: boolean) { + const serviceFieldsDiv = document.getElementById('serviceFields') as HTMLElement | null; + const searchFieldsDiv = document.getElementById('searchFields') as HTMLElement | null; + + if (showSearchFields) { + // Hide regular fields and remove 'required' attribute + if(serviceFieldsDiv) serviceFieldsDiv.style.display = 'none'; + // Remove required attribute from service fields + serviceConfig[currentService].fields.forEach((field: { id: string }) => { + const input = document.getElementById(field.id) as HTMLInputElement | null; + if (input) input.removeAttribute('required'); + }); + + // Show search fields and add 'required' attribute + if(searchFieldsDiv) searchFieldsDiv.style.display = 'block'; + // Make search fields required + if (currentService === 'spotify' && serviceConfig[currentService].searchFields) { + serviceConfig[currentService].searchFields.forEach((field: { id: string }) => { + const input = document.getElementById(field.id) as HTMLInputElement | null; + if (input) input.setAttribute('required', ''); + }); + } + } else { + // Show regular fields and add 'required' attribute + if(serviceFieldsDiv) serviceFieldsDiv.style.display = 'block'; + // Make service fields required + serviceConfig[currentService].fields.forEach((field: { id: string }) => { + const input = document.getElementById(field.id) as HTMLInputElement | null; + if (input) input.setAttribute('required', ''); + }); + + // Hide search fields and remove 'required' attribute + if(searchFieldsDiv) searchFieldsDiv.style.display = 'none'; + // Remove required from search fields + if (currentService === 'spotify' && serviceConfig[currentService].searchFields) { + serviceConfig[currentService].searchFields.forEach((field: { id: string }) => { + const input = document.getElementById(field.id) as HTMLInputElement | null; + if (input) input.removeAttribute('required'); + }); + } + } +} + +function updateFormFields() { + const serviceFieldsDiv = document.getElementById('serviceFields') as HTMLElement | null; + const searchFieldsDiv = document.getElementById('searchFields') as HTMLElement | null; + + // Clear any existing fields + if(serviceFieldsDiv) serviceFieldsDiv.innerHTML = ''; + if(searchFieldsDiv) searchFieldsDiv.innerHTML = ''; + + // Add regular account fields + serviceConfig[currentService].fields.forEach((field: { className: string; label: string; type: string; id: string; }) => { + const fieldDiv = document.createElement('div'); + fieldDiv.className = 'form-group'; + fieldDiv.innerHTML = ` + + + `; + serviceFieldsDiv?.appendChild(fieldDiv); + }); + + // Add search fields for Spotify + if (currentService === 'spotify' && serviceConfig[currentService].searchFields) { + serviceConfig[currentService].searchFields.forEach((field: { className: string; label: string; type: string; id: string; }) => { + const fieldDiv = document.createElement('div'); + fieldDiv.className = 'form-group'; + fieldDiv.innerHTML = ` + + + `; + searchFieldsDiv?.appendChild(fieldDiv); + }); + } + + // Reset form title and button text + (document.getElementById('formTitle') as HTMLElement | null)!.textContent = `Add New ${currentService.charAt(0).toUpperCase() + currentService.slice(1)} Account`; + (document.getElementById('submitCredentialBtn') as HTMLElement | null)!.textContent = 'Save Account'; + + // Initially show regular fields, hide search fields + toggleSearchFieldsVisibility(false); + isEditingSearch = false; +} + +function populateFormFields(service: string, data: Record) { + serviceConfig[service].fields.forEach((field: { id: string; }) => { + const element = document.getElementById(field.id) as HTMLInputElement | null; + if (element) element.value = data[field.id] || ''; + }); +} + +async function handleCredentialSubmit(e: Event) { + e.preventDefault(); + const service = (document.querySelector('.tab-button.active') as HTMLElement | null)?.dataset.service; + const nameInput = document.getElementById('credentialName') as HTMLInputElement | null; + const name = nameInput?.value.trim(); + + try { + if (!currentCredential && !name) { + throw new Error('Credential name is required'); + } + if (!service) { + throw new Error('Service not selected'); + } + + const endpointName = currentCredential || name; + let method: string, data: any, endpoint: string; + + if (isEditingSearch && service === 'spotify') { + // Handle search credentials + const formData: Record = {}; + let isValid = true; + let firstInvalidField: HTMLInputElement | null = null; + + // Manually validate search fields + serviceConfig[service!].searchFields.forEach((field: { id: string; }) => { + const input = document.getElementById(field.id) as HTMLInputElement | null; + const value = input ? input.value.trim() : ''; + formData[field.id] = value; + + if (!value) { + isValid = false; + if (!firstInvalidField && input) firstInvalidField = input; + } + }); + + if (!isValid) { + if (firstInvalidField) (firstInvalidField as HTMLInputElement).focus(); + throw new Error('All fields are required'); + } + + data = serviceConfig[service!].searchValidator(formData); + endpoint = `/api/credentials/${service}/${endpointName}?type=search`; + + // Check if search credentials already exist for this account + const checkResponse = await fetch(endpoint); + method = checkResponse.ok ? 'PUT' : 'POST'; + } else { + // Handle regular account credentials + const formData: Record = {}; + let isValid = true; + let firstInvalidField: HTMLInputElement | null = null; + + // Manually validate account fields + serviceConfig[service!].fields.forEach((field: { id: string; }) => { + const input = document.getElementById(field.id) as HTMLInputElement | null; + const value = input ? input.value.trim() : ''; + formData[field.id] = value; + + if (!value) { + isValid = false; + if (!firstInvalidField && input) firstInvalidField = input; + } + }); + + if (!isValid) { + if (firstInvalidField) (firstInvalidField as HTMLInputElement).focus(); + throw new Error('All fields are required'); + } + + data = serviceConfig[service!].validator(formData); + endpoint = `/api/credentials/${service}/${endpointName}`; + method = currentCredential ? 'PUT' : 'POST'; + } + + const response = await fetch(endpoint, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to save credentials'); + } + + await updateAccountSelectors(); + await saveConfig(); + loadCredentials(service!); + resetForm(); + + // Show success message + showConfigSuccess(isEditingSearch ? 'API credentials saved successfully' : 'Account saved successfully'); + } catch (error: any) { + showConfigError(error.message); + } +} + +function resetForm() { + currentCredential = null; + isEditingSearch = false; + const nameInput = document.getElementById('credentialName') as HTMLInputElement | null; + if (nameInput) { + nameInput.value = ''; + nameInput.disabled = false; + } + (document.getElementById('credentialForm') as HTMLFormElement | null)?.reset(); + + // Reset form title and button text + const serviceName = currentService.charAt(0).toUpperCase() + currentService.slice(1); + (document.getElementById('formTitle') as HTMLElement | null)!.textContent = `Add New ${serviceName} Account`; + (document.getElementById('submitCredentialBtn') as HTMLElement | null)!.textContent = 'Save Account'; + + // Show regular account fields, hide search fields + toggleSearchFieldsVisibility(false); +} + +async function saveConfig() { + // Read active account values directly from the DOM (or from the globals which are kept in sync) + const config = { + service: (document.getElementById('defaultServiceSelect') as HTMLSelectElement | null)?.value, + spotify: (document.getElementById('spotifyAccountSelect') as HTMLSelectElement | null)?.value, + deezer: (document.getElementById('deezerAccountSelect') as HTMLSelectElement | null)?.value, + fallback: (document.getElementById('fallbackToggle') as HTMLInputElement | null)?.checked, + spotifyQuality: (document.getElementById('spotifyQualitySelect') as HTMLSelectElement | null)?.value, + deezerQuality: (document.getElementById('deezerQualitySelect') as HTMLSelectElement | null)?.value, + realTime: (document.getElementById('realTimeToggle') as HTMLInputElement | null)?.checked, + customDirFormat: (document.getElementById('customDirFormat') as HTMLInputElement | null)?.value, + customTrackFormat: (document.getElementById('customTrackFormat') as HTMLInputElement | null)?.value, + maxConcurrentDownloads: parseInt((document.getElementById('maxConcurrentDownloads') as HTMLInputElement | null)?.value || '3', 10) || 3, + maxRetries: parseInt((document.getElementById('maxRetries') as HTMLInputElement | null)?.value || '3', 10) || 3, + retryDelaySeconds: parseInt((document.getElementById('retryDelaySeconds') as HTMLInputElement | null)?.value || '5', 10) || 5, + retry_delay_increase: parseInt((document.getElementById('retryDelayIncrease') as HTMLInputElement | null)?.value || '5', 10) || 5, + tracknum_padding: (document.getElementById('tracknumPaddingToggle') as HTMLInputElement | null)?.checked + }; + + try { + const response = await fetch('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to save config'); + } + + const savedConfig = await response.json(); + + // Set default service selection + const defaultServiceSelect = document.getElementById('defaultServiceSelect') as HTMLSelectElement | null; + if (defaultServiceSelect) defaultServiceSelect.value = savedConfig.service || 'spotify'; + + // Update the service-specific options based on selected service + updateServiceSpecificOptions(); + + // Use the "spotify" and "deezer" properties from the API response to set the active accounts. + activeSpotifyAccount = savedConfig.spotify || ''; + activeDeezerAccount = savedConfig.deezer || ''; + + // (Optionally, if the account selects already exist you can set their values here, + // but updateAccountSelectors() will rebuild the options and set the proper values.) + const spotifySelect = document.getElementById('spotifyAccountSelect') as HTMLSelectElement | null; + const deezerSelect = document.getElementById('deezerAccountSelect') as HTMLSelectElement | null; + if (spotifySelect) spotifySelect.value = activeSpotifyAccount; + if (deezerSelect) deezerSelect.value = activeDeezerAccount; + + // Update other configuration fields. + const fallbackToggle = document.getElementById('fallbackToggle') as HTMLInputElement | null; + if (fallbackToggle) fallbackToggle.checked = !!savedConfig.fallback; + const spotifyQualitySelect = document.getElementById('spotifyQualitySelect') as HTMLSelectElement | null; + if (spotifyQualitySelect) spotifyQualitySelect.value = savedConfig.spotifyQuality || 'NORMAL'; + const deezerQualitySelect = document.getElementById('deezerQualitySelect') as HTMLSelectElement | null; + if (deezerQualitySelect) deezerQualitySelect.value = savedConfig.deezerQuality || 'MP3_128'; + const realTimeToggle = document.getElementById('realTimeToggle') as HTMLInputElement | null; + if (realTimeToggle) realTimeToggle.checked = !!savedConfig.realTime; + const customDirFormat = document.getElementById('customDirFormat') as HTMLInputElement | null; + if (customDirFormat) customDirFormat.value = savedConfig.customDirFormat || '%ar_album%/%album%'; + const customTrackFormat = document.getElementById('customTrackFormat') as HTMLInputElement | null; + if (customTrackFormat) customTrackFormat.value = savedConfig.customTrackFormat || '%tracknum%. %music%'; + const maxConcurrentDownloads = document.getElementById('maxConcurrentDownloads') as HTMLInputElement | null; + if (maxConcurrentDownloads) maxConcurrentDownloads.value = savedConfig.maxConcurrentDownloads || '3'; + const maxRetries = document.getElementById('maxRetries') as HTMLInputElement | null; + if (maxRetries) maxRetries.value = savedConfig.maxRetries || '3'; + const retryDelaySeconds = document.getElementById('retryDelaySeconds') as HTMLInputElement | null; + if (retryDelaySeconds) retryDelaySeconds.value = savedConfig.retryDelaySeconds || '5'; + const retryDelayIncrease = document.getElementById('retryDelayIncrease') as HTMLInputElement | null; + if (retryDelayIncrease) retryDelayIncrease.value = savedConfig.retry_delay_increase || '5'; + const tracknumPaddingToggle = document.getElementById('tracknumPaddingToggle') as HTMLInputElement | null; + if (tracknumPaddingToggle) tracknumPaddingToggle.checked = savedConfig.tracknum_padding === undefined ? true : !!savedConfig.tracknum_padding; + + // Update explicit filter status + updateExplicitFilterStatus(savedConfig.explicitFilter); + } catch (error: any) { + showConfigError('Error loading config: ' + error.message); + } +} + +function updateExplicitFilterStatus(isEnabled: boolean) { + const statusElement = document.getElementById('explicitFilterStatus'); + if (statusElement) { + // Remove existing classes + statusElement.classList.remove('enabled', 'disabled'); + + // Add appropriate class and text based on whether filter is enabled + if (isEnabled) { + statusElement.textContent = 'Enabled'; + statusElement.classList.add('enabled'); + } else { + statusElement.textContent = 'Disabled'; + statusElement.classList.add('disabled'); + } + } +} + +function showConfigError(message: string) { + const errorDiv = document.getElementById('configError'); + if (errorDiv) errorDiv.textContent = message; + setTimeout(() => { if (errorDiv) errorDiv.textContent = '' }, 5000); +} + +function showConfigSuccess(message: string) { + const successDiv = document.getElementById('configSuccess'); + if (successDiv) successDiv.textContent = message; + setTimeout(() => { if (successDiv) successDiv.textContent = '' }, 5000); +} + +// Function to copy the selected placeholder to clipboard +function copyPlaceholderToClipboard(select: HTMLSelectElement) { + const placeholder = select.value; + + if (!placeholder) return; // If nothing selected + + // Copy to clipboard + navigator.clipboard.writeText(placeholder) + .then(() => { + // Show success notification + showCopyNotification(`Copied ${placeholder} to clipboard`); + + // Reset select to default after a short delay + setTimeout(() => { + select.selectedIndex = 0; + }, 500); + }) + .catch(err => { + console.error('Failed to copy: ', err); + }); +} + +// Function to show a notification when copying +function showCopyNotification(message: string) { + // Check if notification container exists, create if not + let notificationContainer = document.getElementById('copyNotificationContainer'); + if (!notificationContainer) { + notificationContainer = document.createElement('div'); + notificationContainer.id = 'copyNotificationContainer'; + document.body.appendChild(notificationContainer); + } + + // Create notification element + const notification = document.createElement('div'); + notification.className = 'copy-notification'; + notification.textContent = message; + + // Add to container + notificationContainer.appendChild(notification); + + // Trigger animation + setTimeout(() => { + notification.classList.add('show'); + }, 10); + + // Remove after animation completes + setTimeout(() => { + notification.classList.remove('show'); + setTimeout(() => { + notificationContainer.removeChild(notification); + }, 300); + }, 2000); +} diff --git a/static/js/main.js b/static/js/main.ts similarity index 79% rename from static/js/main.js rename to static/js/main.ts index b553718..9560186 100644 --- a/static/js/main.js +++ b/static/js/main.ts @@ -1,11 +1,11 @@ -// main.js +// main.ts import { downloadQueue } from './queue.js'; document.addEventListener('DOMContentLoaded', function() { // DOM elements - const searchInput = document.getElementById('searchInput'); - const searchButton = document.getElementById('searchButton'); - const searchType = document.getElementById('searchType'); + const searchInput = document.getElementById('searchInput') as HTMLInputElement | null; + const searchButton = document.getElementById('searchButton') as HTMLButtonElement | null; + const searchType = document.getElementById('searchType') as HTMLSelectElement | null; const resultsContainer = document.getElementById('resultsContainer'); const queueIcon = document.getElementById('queueIcon'); const emptyState = document.getElementById('emptyState'); @@ -24,18 +24,19 @@ document.addEventListener('DOMContentLoaded', function() { } if (searchInput) { - searchInput.addEventListener('keypress', function(e) { + searchInput.addEventListener('keypress', function(e: KeyboardEvent) { if (e.key === 'Enter') { performSearch(); } }); // Auto-detect and handle pasted Spotify URLs - searchInput.addEventListener('input', function(e) { - const inputVal = e.target.value.trim(); + searchInput.addEventListener('input', function(e: Event) { + const target = e.target as HTMLInputElement; + const inputVal = target.value.trim(); if (isSpotifyUrl(inputVal)) { const details = getSpotifyResourceDetails(inputVal); - if (details) { + if (details && searchType) { searchType.value = details.type; } } @@ -44,7 +45,7 @@ document.addEventListener('DOMContentLoaded', function() { // Restore last search type if no URL override const savedType = localStorage.getItem('lastSearchType'); - if (savedType && ['track','album','playlist','artist'].includes(savedType)) { + if (searchType && savedType && ['track','album','playlist','artist'].includes(savedType)) { searchType.value = savedType; } // Save last selection on change @@ -59,9 +60,9 @@ document.addEventListener('DOMContentLoaded', function() { const query = urlParams.get('q'); const type = urlParams.get('type'); - if (query) { + if (query && searchInput) { searchInput.value = query; - if (type && ['track', 'album', 'playlist', 'artist'].includes(type)) { + if (type && searchType && ['track', 'album', 'playlist', 'artist'].includes(type)) { searchType.value = type; } performSearch(); @@ -74,12 +75,12 @@ document.addEventListener('DOMContentLoaded', function() { * Performs the search based on input values */ async function performSearch() { - const query = searchInput.value.trim(); - if (!query) return; + const currentQuery = searchInput?.value.trim(); + if (!currentQuery) return; // Handle direct Spotify URLs - if (isSpotifyUrl(query)) { - const details = getSpotifyResourceDetails(query); + if (isSpotifyUrl(currentQuery)) { + const details = getSpotifyResourceDetails(currentQuery); if (details && details.id) { // Redirect to the appropriate page window.location.href = `/${details.type}/${details.id}`; @@ -88,16 +89,17 @@ document.addEventListener('DOMContentLoaded', function() { } // Update URL without reloading page - const newUrl = `${window.location.pathname}?q=${encodeURIComponent(query)}&type=${searchType.value}`; + const currentSearchType = searchType?.value || 'track'; + const newUrl = `${window.location.pathname}?q=${encodeURIComponent(currentQuery)}&type=${currentSearchType}`; window.history.pushState({ path: newUrl }, '', newUrl); // Show loading state showEmptyState(false); showLoading(true); - resultsContainer.innerHTML = ''; + if(resultsContainer) resultsContainer.innerHTML = ''; try { - const url = `/api/search?q=${encodeURIComponent(query)}&search_type=${searchType.value}&limit=40`; + const url = `/api/search?q=${encodeURIComponent(currentQuery)}&search_type=${currentSearchType}&limit=40`; const response = await fetch(url); if (!response.ok) { @@ -111,47 +113,47 @@ document.addEventListener('DOMContentLoaded', function() { // Render results if (data && data.items && data.items.length > 0) { - resultsContainer.innerHTML = ''; + if(resultsContainer) resultsContainer.innerHTML = ''; // Filter out items with null/undefined essential display parameters - const validItems = filterValidItems(data.items, searchType.value); + const validItems = filterValidItems(data.items, currentSearchType); if (validItems.length === 0) { // No valid items found after filtering - resultsContainer.innerHTML = ` + if(resultsContainer) resultsContainer.innerHTML = `
-

No valid results found for "${query}"

+

No valid results found for "${currentQuery}"

`; return; } validItems.forEach((item, index) => { - const cardElement = createResultCard(item, searchType.value, index); + const cardElement = createResultCard(item, currentSearchType, index); // Store the item data directly on the button element - const downloadBtn = cardElement.querySelector('.download-btn'); + const downloadBtn = cardElement.querySelector('.download-btn') as HTMLButtonElement | null; if (downloadBtn) { - downloadBtn.dataset.itemIndex = index; + downloadBtn.dataset.itemIndex = index.toString(); } - resultsContainer.appendChild(cardElement); + if(resultsContainer) resultsContainer.appendChild(cardElement); }); // Attach download handlers to the newly created cards attachDownloadListeners(validItems); } else { // No results found - resultsContainer.innerHTML = ` + if(resultsContainer) resultsContainer.innerHTML = `
-

No results found for "${query}"

+

No results found for "${currentQuery}"

`; } - } catch (error) { + } catch (error: any) { console.error('Error:', error); showLoading(false); - resultsContainer.innerHTML = ` + if(resultsContainer) resultsContainer.innerHTML = `

Error searching: ${error.message}

@@ -162,7 +164,7 @@ document.addEventListener('DOMContentLoaded', function() { /** * Filters out items with null/undefined essential display parameters based on search type */ - function filterValidItems(items, type) { + function filterValidItems(items: any[], type: string) { if (!items) return []; return items.filter(item => { @@ -231,19 +233,22 @@ document.addEventListener('DOMContentLoaded', function() { /** * Attaches download handlers to result cards */ - function attachDownloadListeners(items) { - document.querySelectorAll('.download-btn').forEach((btn) => { - btn.addEventListener('click', (e) => { + function attachDownloadListeners(items: any[]) { + document.querySelectorAll('.download-btn').forEach((btnElm) => { + const btn = btnElm as HTMLButtonElement; + btn.addEventListener('click', (e: Event) => { e.stopPropagation(); // Get the item index from the button's dataset - const itemIndex = parseInt(btn.dataset.itemIndex, 10); + const itemIndexStr = btn.dataset.itemIndex; + if (!itemIndexStr) return; + const itemIndex = parseInt(itemIndexStr, 10); // Get the corresponding item const item = items[itemIndex]; if (!item) return; - const type = searchType.value; + const currentSearchType = searchType?.value || 'track'; let url; // Determine the URL based on item type @@ -266,17 +271,17 @@ document.addEventListener('DOMContentLoaded', function() { btn.disabled = true; // For artist downloads, show a different message since it will queue multiple albums - if (type === 'artist') { + if (currentSearchType === 'artist') { btn.innerHTML = 'Queueing albums...'; } else { btn.innerHTML = 'Queueing...'; } // Start the download - startDownload(url, type, metadata, item.album ? item.album.album_type : null) + startDownload(url, currentSearchType, metadata, item.album ? item.album.album_type : null) .then(() => { // For artists, show how many albums were queued - if (type === 'artist') { + if (currentSearchType === 'artist') { btn.innerHTML = 'Albums queued!'; // Open the queue automatically for artist downloads downloadQueue.toggleVisibility(true); @@ -284,7 +289,7 @@ document.addEventListener('DOMContentLoaded', function() { btn.innerHTML = 'Queued!'; } }) - .catch((error) => { + .catch((error: any) => { btn.disabled = false; btn.innerHTML = 'Download'; showError('Failed to queue download: ' + error.message); @@ -296,7 +301,7 @@ document.addEventListener('DOMContentLoaded', function() { /** * Starts the download process via API */ - async function startDownload(url, type, item, albumType) { + async function startDownload(url: string, type: string, item: any, albumType: string | null) { if (!url || !type) { showError('Missing URL or type for download'); return; @@ -308,7 +313,7 @@ document.addEventListener('DOMContentLoaded', function() { // Make the queue visible after queueing downloadQueue.toggleVisibility(true); - } catch (error) { + } catch (error: any) { showError('Download failed: ' + (error.message || 'Unknown error')); throw error; } @@ -317,7 +322,7 @@ document.addEventListener('DOMContentLoaded', function() { /** * Shows an error message */ - function showError(message) { + function showError(message: string) { const errorDiv = document.createElement('div'); errorDiv.className = 'error'; errorDiv.textContent = message; @@ -330,7 +335,7 @@ document.addEventListener('DOMContentLoaded', function() { /** * Shows a success message */ - function showSuccess(message) { + function showSuccess(message: string) { const successDiv = document.createElement('div'); successDiv.className = 'success'; successDiv.textContent = message; @@ -343,7 +348,7 @@ document.addEventListener('DOMContentLoaded', function() { /** * Checks if a string is a valid Spotify URL */ - function isSpotifyUrl(url) { + function isSpotifyUrl(url: string): boolean { return url.includes('open.spotify.com') || url.includes('spotify:') || url.includes('link.tospotify.com'); @@ -352,7 +357,7 @@ document.addEventListener('DOMContentLoaded', function() { /** * Extracts details from a Spotify URL */ - function getSpotifyResourceDetails(url) { + function getSpotifyResourceDetails(url: string): { type: string; id: string } | null { // Allow optional path segments (e.g. intl-fr) before resource type const regex = /spotify\.com\/(?:[^\/]+\/)??(track|album|playlist|artist)\/([a-zA-Z0-9]+)/i; const match = url.match(regex); @@ -369,7 +374,7 @@ document.addEventListener('DOMContentLoaded', function() { /** * Formats milliseconds to MM:SS */ - function msToMinutesSeconds(ms) { + function msToMinutesSeconds(ms: number | undefined): string { if (!ms) return '0:00'; const minutes = Math.floor(ms / 60000); @@ -380,7 +385,7 @@ document.addEventListener('DOMContentLoaded', function() { /** * Creates a result card element */ - function createResultCard(item, type, index) { + function createResultCard(item: any, type: string, index: number): HTMLDivElement { const cardElement = document.createElement('div'); cardElement.className = 'result-card'; @@ -433,10 +438,11 @@ document.addEventListener('DOMContentLoaded', function() { `; // Add click event to navigate to the item's detail page - cardElement.addEventListener('click', (e) => { + cardElement.addEventListener('click', (e: MouseEvent) => { // Don't trigger if the download button was clicked - if (e.target.classList.contains('download-btn') || - e.target.parentElement.classList.contains('download-btn')) { + const target = e.target as HTMLElement; + if (target.classList.contains('download-btn') || + target.parentElement?.classList.contains('download-btn')) { return; } @@ -451,7 +457,7 @@ document.addEventListener('DOMContentLoaded', function() { /** * Show/hide the empty state */ - function showEmptyState(show) { + function showEmptyState(show: boolean) { if (emptyState) { emptyState.style.display = show ? 'flex' : 'none'; } @@ -460,7 +466,7 @@ document.addEventListener('DOMContentLoaded', function() { /** * Show/hide the loading indicator */ - function showLoading(show) { + function showLoading(show: boolean) { if (loadingResults) { loadingResults.classList.toggle('hidden', !show); } diff --git a/static/js/playlist.js b/static/js/playlist.ts similarity index 70% rename from static/js/playlist.js rename to static/js/playlist.ts index bf57266..c06fb9d 100644 --- a/static/js/playlist.js +++ b/static/js/playlist.ts @@ -34,25 +34,32 @@ document.addEventListener('DOMContentLoaded', () => { /** * Renders playlist header and tracks. */ -function renderPlaylist(playlist) { +function renderPlaylist(playlist: any) { // Hide loading and error messages - document.getElementById('loading').classList.add('hidden'); - document.getElementById('error').classList.add('hidden'); + const loadingEl = document.getElementById('loading'); + if (loadingEl) loadingEl.classList.add('hidden'); + const errorEl = document.getElementById('error'); + if (errorEl) errorEl.classList.add('hidden'); // Check if explicit filter is enabled const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled(); // Update header info - 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 = + const playlistNameEl = document.getElementById('playlist-name'); + if (playlistNameEl) playlistNameEl.textContent = playlist.name || 'Unknown Playlist'; + const playlistOwnerEl = document.getElementById('playlist-owner'); + if (playlistOwnerEl) playlistOwnerEl.textContent = `By ${playlist.owner?.display_name || 'Unknown User'}`; + const playlistStatsEl = document.getElementById('playlist-stats'); + if (playlistStatsEl) playlistStatsEl.textContent = `${playlist.followers?.total || '0'} followers • ${playlist.tracks?.total || '0'} songs`; - document.getElementById('playlist-description').textContent = playlist.description || ''; + const playlistDescriptionEl = document.getElementById('playlist-description'); + if (playlistDescriptionEl) playlistDescriptionEl.textContent = playlist.description || ''; const image = playlist.images?.[0]?.url || '/static/images/placeholder.jpg'; - document.getElementById('playlist-image').src = image; + const playlistImageEl = document.getElementById('playlist-image') as HTMLImageElement; + if (playlistImageEl) playlistImageEl.src = image; // --- Add Home Button --- - let homeButton = document.getElementById('homeButton'); + let homeButton = document.getElementById('homeButton') as HTMLButtonElement; if (!homeButton) { homeButton = document.createElement('button'); homeButton.id = 'homeButton'; @@ -77,7 +84,7 @@ function renderPlaylist(playlist) { } // --- Add "Download Whole Playlist" Button --- - let downloadPlaylistBtn = document.getElementById('downloadPlaylistBtn'); + let downloadPlaylistBtn = document.getElementById('downloadPlaylistBtn') as HTMLButtonElement; if (!downloadPlaylistBtn) { downloadPlaylistBtn = document.createElement('button'); downloadPlaylistBtn.id = 'downloadPlaylistBtn'; @@ -91,7 +98,7 @@ function renderPlaylist(playlist) { } // --- Add "Download Playlist's Albums" Button --- - let downloadAlbumsBtn = document.getElementById('downloadAlbumsBtn'); + let downloadAlbumsBtn = document.getElementById('downloadAlbumsBtn') as HTMLButtonElement; if (!downloadAlbumsBtn) { downloadAlbumsBtn = document.createElement('button'); downloadAlbumsBtn.id = 'downloadAlbumsBtn'; @@ -106,54 +113,62 @@ function renderPlaylist(playlist) { if (isExplicitFilterEnabled && hasExplicitTrack) { // Disable both playlist buttons and display messages explaining why - downloadPlaylistBtn.disabled = true; - downloadPlaylistBtn.classList.add('download-btn--disabled'); - downloadPlaylistBtn.innerHTML = `Playlist Contains Explicit Tracks`; + if (downloadPlaylistBtn) { + downloadPlaylistBtn.disabled = true; + downloadPlaylistBtn.classList.add('download-btn--disabled'); + downloadPlaylistBtn.innerHTML = `Playlist Contains Explicit Tracks`; + } - downloadAlbumsBtn.disabled = true; - downloadAlbumsBtn.classList.add('download-btn--disabled'); - downloadAlbumsBtn.innerHTML = `Albums Access Restricted`; + if (downloadAlbumsBtn) { + downloadAlbumsBtn.disabled = true; + downloadAlbumsBtn.classList.add('download-btn--disabled'); + downloadAlbumsBtn.innerHTML = `Albums Access Restricted`; + } } else { // Normal behavior when no explicit tracks are present - downloadPlaylistBtn.addEventListener('click', () => { - // Remove individual track download buttons (but leave the whole playlist button). - document.querySelectorAll('.download-btn').forEach(btn => { - if (btn.id !== 'downloadPlaylistBtn') { - btn.remove(); - } - }); - - // Disable the whole playlist button to prevent repeated clicks. - downloadPlaylistBtn.disabled = true; - downloadPlaylistBtn.textContent = 'Queueing...'; - - // Initiate the playlist download. - downloadWholePlaylist(playlist).then(() => { - downloadPlaylistBtn.textContent = 'Queued!'; - }).catch(err => { - showError('Failed to queue playlist download: ' + (err?.message || 'Unknown error')); - downloadPlaylistBtn.disabled = false; - }); - }); - - downloadAlbumsBtn.addEventListener('click', () => { - // Remove individual track download buttons (but leave this album button). - document.querySelectorAll('.download-btn').forEach(btn => { - if (btn.id !== 'downloadAlbumsBtn') btn.remove(); - }); - - downloadAlbumsBtn.disabled = true; - downloadAlbumsBtn.textContent = 'Queueing...'; - - downloadPlaylistAlbums(playlist) - .then(() => { - downloadAlbumsBtn.textContent = 'Queued!'; - }) - .catch(err => { - showError('Failed to queue album downloads: ' + (err?.message || 'Unknown error')); - downloadAlbumsBtn.disabled = false; + if (downloadPlaylistBtn) { + downloadPlaylistBtn.addEventListener('click', () => { + // Remove individual track download buttons (but leave the whole playlist button). + document.querySelectorAll('.download-btn').forEach(btn => { + if (btn.id !== 'downloadPlaylistBtn') { + btn.remove(); + } }); - }); + + // Disable the whole playlist button to prevent repeated clicks. + downloadPlaylistBtn.disabled = true; + downloadPlaylistBtn.textContent = 'Queueing...'; + + // Initiate the playlist download. + downloadWholePlaylist(playlist).then(() => { + downloadPlaylistBtn.textContent = 'Queued!'; + }).catch((err: any) => { + showError('Failed to queue playlist download: ' + (err?.message || 'Unknown error')); + downloadPlaylistBtn.disabled = false; + }); + }); + } + + if (downloadAlbumsBtn) { + downloadAlbumsBtn.addEventListener('click', () => { + // Remove individual track download buttons (but leave this album button). + document.querySelectorAll('.download-btn').forEach(btn => { + if (btn.id !== 'downloadAlbumsBtn') btn.remove(); + }); + + downloadAlbumsBtn.disabled = true; + downloadAlbumsBtn.textContent = 'Queueing...'; + + downloadPlaylistAlbums(playlist) + .then(() => { + if (downloadAlbumsBtn) downloadAlbumsBtn.textContent = 'Queued!'; + }) + .catch((err: any) => { + showError('Failed to queue album downloads: ' + (err?.message || 'Unknown error')); + if (downloadAlbumsBtn) downloadAlbumsBtn.disabled = false; + }); + }); + } } // Render tracks list @@ -220,8 +235,10 @@ function renderPlaylist(playlist) { } // Reveal header and tracks container - document.getElementById('playlist-header').classList.remove('hidden'); - document.getElementById('tracks-container').classList.remove('hidden'); + const playlistHeaderEl = document.getElementById('playlist-header'); + if (playlistHeaderEl) playlistHeaderEl.classList.remove('hidden'); + const tracksContainerEl = document.getElementById('tracks-container'); + if (tracksContainerEl) tracksContainerEl.classList.remove('hidden'); // Attach download listeners to newly rendered download buttons attachDownloadListeners(); @@ -230,7 +247,7 @@ function renderPlaylist(playlist) { /** * Converts milliseconds to minutes:seconds. */ -function msToTime(duration) { +function msToTime(duration: number) { if (!duration || isNaN(duration)) return '0:00'; const minutes = Math.floor(duration / 60000); @@ -241,7 +258,7 @@ function msToTime(duration) { /** * Displays an error message in the UI. */ -function showError(message) { +function showError(message: string) { const errorEl = document.getElementById('error'); if (errorEl) { errorEl.textContent = message || 'An error occurred'; @@ -256,14 +273,15 @@ function attachDownloadListeners() { document.querySelectorAll('.download-btn').forEach((btn) => { // Skip the whole playlist and album download buttons. if (btn.id === 'downloadPlaylistBtn' || btn.id === 'downloadAlbumsBtn') return; - btn.addEventListener('click', (e) => { + btn.addEventListener('click', (e: Event) => { e.stopPropagation(); - const url = e.currentTarget.dataset.url || ''; - const type = e.currentTarget.dataset.type || ''; - const name = e.currentTarget.dataset.name || extractName(url) || 'Unknown'; + const currentTarget = e.currentTarget as HTMLButtonElement; + const url = currentTarget.dataset.url || ''; + const type = currentTarget.dataset.type || ''; + const name = currentTarget.dataset.name || extractName(url) || 'Unknown'; // Remove the button immediately after click. - e.currentTarget.remove(); - startDownload(url, type, { name }); + currentTarget.remove(); + startDownload(url, type, { name }, ''); // Added empty string for albumType }); }); } @@ -271,7 +289,7 @@ function attachDownloadListeners() { /** * Initiates the whole playlist download by calling the playlist endpoint. */ -async function downloadWholePlaylist(playlist) { +async function downloadWholePlaylist(playlist: any) { if (!playlist) { throw new Error('Invalid playlist data'); } @@ -286,7 +304,7 @@ async function downloadWholePlaylist(playlist) { await downloadQueue.download(url, 'playlist', { name: playlist.name || 'Unknown Playlist' }); // Make the queue visible after queueing downloadQueue.toggleVisibility(true); - } catch (error) { + } catch (error: any) { showError('Playlist download failed: ' + (error?.message || 'Unknown error')); throw error; } @@ -297,7 +315,7 @@ async function downloadWholePlaylist(playlist) { * adding a 20ms delay between each album download and updating the button * with the progress (queued_albums/total_albums). */ -async function downloadPlaylistAlbums(playlist) { +async function downloadPlaylistAlbums(playlist: any) { if (!playlist?.tracks?.items) { showError('No tracks found in this playlist.'); return; @@ -322,7 +340,7 @@ async function downloadPlaylistAlbums(playlist) { } // Get a reference to the "Download Playlist's Albums" button. - const downloadAlbumsBtn = document.getElementById('downloadAlbumsBtn'); + const downloadAlbumsBtn = document.getElementById('downloadAlbumsBtn') as HTMLButtonElement | null; if (downloadAlbumsBtn) { // Initialize the progress display. downloadAlbumsBtn.textContent = `0/${totalAlbums}`; @@ -360,7 +378,7 @@ async function downloadPlaylistAlbums(playlist) { // Make the queue visible after queueing all albums downloadQueue.toggleVisibility(true); - } catch (error) { + } catch (error: any) { // Propagate any errors encountered. throw error; } @@ -369,7 +387,7 @@ async function downloadPlaylistAlbums(playlist) { /** * Starts the download process using the centralized download method from the queue. */ -async function startDownload(url, type, item, albumType) { +async function startDownload(url: string, type: string, item: any, albumType?: string) { if (!url || !type) { showError('Missing URL or type for download'); return; @@ -381,7 +399,7 @@ async function startDownload(url, type, item, albumType) { // Make the queue visible after queueing downloadQueue.toggleVisibility(true); - } catch (error) { + } catch (error: any) { showError('Download failed: ' + (error?.message || 'Unknown error')); throw error; } @@ -390,6 +408,6 @@ async function startDownload(url, type, item, albumType) { /** * A helper function to extract a display name from the URL. */ -function extractName(url) { +function extractName(url: string | null): string { return url || 'Unknown'; } diff --git a/static/js/queue.js b/static/js/queue.ts similarity index 80% rename from static/js/queue.js rename to static/js/queue.ts index e7d6760..c910ea4 100644 --- a/static/js/queue.js +++ b/static/js/queue.ts @@ -1,28 +1,181 @@ // --- MODIFIED: Custom URLSearchParams class that does not encode anything --- class CustomURLSearchParams { + params: Record; constructor() { this.params = {}; } - append(key, value) { + append(key: string, value: string): void { this.params[key] = value; } - toString() { + toString(): string { return Object.entries(this.params) - .map(([key, value]) => `${key}=${value}`) + .map(([key, value]: [string, string]) => `${key}=${value}`) .join('&'); } } // --- END MODIFIED --- -class DownloadQueue { +// Interfaces for complex objects +interface QueueItem { + name?: string; + music?: string; + song?: string; + artist?: string; + artists?: { name: string }[]; + album?: { name: string }; + owner?: string | { display_name?: string }; + total_tracks?: number; + url?: string; + type?: string; // Added for artist downloads + parent?: ParentInfo; // For tracks within albums/playlists + // For PRG file loading + display_title?: string; + display_artist?: string; + endpoint?: string; + download_type?: string; + [key: string]: any; // Allow other properties +} + +interface ParentInfo { + type: 'album' | 'playlist'; + title?: string; // for album + artist?: string; // for album + name?: string; // for playlist + owner?: string; // for playlist + total_tracks?: number; + url?: string; + [key: string]: any; // Allow other properties +} + +interface StatusData { + type?: string; + status?: string; + name?: string; + song?: string; + music?: string; + title?: string; + artist?: string; + artist_name?: string; + album?: string; + owner?: string; + total_tracks?: number | string; + current_track?: number | string; + parsed_current_track?: string; // Make sure these are handled if they are strings + parsed_total_tracks?: string; // Make sure these are handled if they are strings + progress?: number | string; // Can be string initially + percentage?: number | string; // Can be string initially + percent?: number | string; // Can be string initially + time_elapsed?: number; + error?: string; + can_retry?: boolean; + retry_count?: number; + max_retries?: number; // from config potentially + seconds_left?: number; + prg_file?: string; + url?: string; + reason?: string; // for skipped + parent?: ParentInfo; + original_url?: string; + position?: number; // For queued items + original_request?: { + url?: string; + retry_url?: string; + name?: string; + artist?: string; + type?: string; + endpoint?: string; + download_type?: string; + display_title?: string; + display_type?: string; + display_artist?: string; + service?: string; + [key: string]: any; // For other potential original_request params + }; + event?: string; // from SSE + overall_progress?: number; + display_type?: string; // from PRG data + [key: string]: any; // Allow other properties +} + +interface QueueEntry { + item: QueueItem; + type: string; + prgFile: string; + requestUrl: string | null; + element: HTMLElement; + lastStatus: StatusData; + lastUpdated: number; + hasEnded: boolean; + intervalId: number | null; // NodeJS.Timeout for setInterval/clearInterval + uniqueId: string; + retryCount: number; + autoRetryInterval: number | null; + isNew: boolean; + status: string; + lastMessage: string; + parentInfo: ParentInfo | null; + isRetrying?: boolean; + progress?: number; // for multi-track overall progress + realTimeStallDetector: { count: number; lastStatusJson: string }; + [key: string]: any; // Allow other properties +} + +interface AppConfig { + downloadQueueVisible?: boolean; + maxRetries?: number; + retryDelaySeconds?: number; + retry_delay_increase?: number; + explicitFilter?: boolean; + [key: string]: any; // Allow other config properties +} + +// Ensure DOM elements are queryable +declare global { + interface Document { + getElementById(elementId: string): HTMLElement | null; + } +} + +export class DownloadQueue { + // Constants read from the server config + MAX_RETRIES: number = 3; // Default max retries + RETRY_DELAY: number = 5; // Default retry delay in seconds + RETRY_DELAY_INCREASE: number = 5; // Default retry delay increase in seconds + + // Cache for queue items + queueCache: Record = {}; + + // Queue entry objects + queueEntries: Record = {}; + + // Polling intervals for progress tracking + pollingIntervals: Record = {}; // NodeJS.Timeout for setInterval + + // DOM elements cache (Consider if this is still needed or how it's used) + elements: Record = {}; // Example type, adjust as needed + + // Event handlers (Consider if this is still needed or how it's used) + eventHandlers: Record = {}; // Example type, adjust as needed + + // Configuration + config: AppConfig = {}; // Initialize with an empty object or a default config structure + + // Load the saved visible count (or default to 10) + visibleCount: number; + constructor() { + const storedVisibleCount = localStorage.getItem("downloadQueueVisibleCount"); + this.visibleCount = storedVisibleCount ? parseInt(storedVisibleCount, 10) : 10; + + this.queueCache = JSON.parse(localStorage.getItem("downloadQueueCache") || "{}"); + // 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 // Cache for queue items - this.queueCache = {}; + // this.queueCache = {}; // Already initialized above // Queue entry objects this.queueEntries = {}; @@ -37,14 +190,14 @@ class DownloadQueue { this.eventHandlers = {}; // Configuration - this.config = null; + this.config = {}; // Initialize config - // Load the saved visible count (or default to 10) - const storedVisibleCount = localStorage.getItem("downloadQueueVisibleCount"); - this.visibleCount = storedVisibleCount ? parseInt(storedVisibleCount, 10) : 10; + // Load the saved visible count (or default to 10) - This block is redundant + // const storedVisibleCount = localStorage.getItem("downloadQueueVisibleCount"); + // this.visibleCount = storedVisibleCount ? parseInt(storedVisibleCount, 10) : 10; - // Load the cached status info (object keyed by prgFile) - this.queueCache = JSON.parse(localStorage.getItem("downloadQueueCache") || "{}"); + // Load the cached status info (object keyed by prgFile) - This is also redundant + // this.queueCache = JSON.parse(localStorage.getItem("downloadQueueCache") || "{}"); // Wait for initDOM to complete before setting up event listeners and loading existing PRG files. this.initDOM().then(() => { @@ -79,16 +232,22 @@ class DownloadQueue { // Override the server value with locally persisted queue visibility (if present). const storedVisible = localStorage.getItem("downloadQueueVisible"); if (storedVisible !== null) { - this.config.downloadQueueVisible = storedVisible === "true"; + // Ensure config is not null before assigning + if (this.config) { + this.config.downloadQueueVisible = storedVisible === "true"; + } } const queueSidebar = document.getElementById('downloadQueue'); - queueSidebar.hidden = !this.config.downloadQueueVisible; - queueSidebar.classList.toggle('active', this.config.downloadQueueVisible); + // Ensure config is not null and queueSidebar exists + if (this.config && queueSidebar) { + queueSidebar.hidden = !this.config.downloadQueueVisible; + queueSidebar.classList.toggle('active', !!this.config.downloadQueueVisible); + } // Initialize the queue icon based on sidebar visibility const queueIcon = document.getElementById('queueIcon'); - if (queueIcon) { + if (queueIcon && this.config) { if (this.config.downloadQueueVisible) { queueIcon.innerHTML = '×'; queueIcon.setAttribute('aria-expanded', 'true'); @@ -104,9 +263,9 @@ class DownloadQueue { /* Event Handling */ initEventListeners() { // Toggle queue visibility via Escape key. - document.addEventListener('keydown', async (e) => { + document.addEventListener('keydown', async (e: KeyboardEvent) => { const queueSidebar = document.getElementById('downloadQueue'); - if (e.key === 'Escape' && queueSidebar.classList.contains('active')) { + if (e.key === 'Escape' && queueSidebar?.classList.contains('active')) { await this.toggleVisibility(); } }); @@ -117,7 +276,7 @@ class DownloadQueue { cancelAllBtn.addEventListener('click', () => { for (const queueId in this.queueEntries) { const entry = this.queueEntries[queueId]; - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; if (entry && !entry.hasEnded && entry.prgFile) { // Mark as cancelling visually if (entry.element) { @@ -135,7 +294,7 @@ class DownloadQueue { if (data.status === "cancelled" || data.status === "cancel") { entry.hasEnded = true; if (entry.intervalId) { - clearInterval(entry.intervalId); + clearInterval(entry.intervalId as number); // Cast to number for clearInterval entry.intervalId = null; } // Remove the entry as soon as the API confirms cancellation @@ -156,8 +315,9 @@ class DownloadQueue { } /* Public API */ - async toggleVisibility(force) { + async toggleVisibility(force?: boolean) { const queueSidebar = document.getElementById('downloadQueue'); + if (!queueSidebar) return; // Guard against null // If force is provided, use that value, otherwise toggle the current state const isVisible = force !== undefined ? force : !queueSidebar.classList.contains('active'); @@ -166,7 +326,7 @@ class DownloadQueue { // Update the queue icon to show X when visible or queue icon when hidden const queueIcon = document.getElementById('queueIcon'); - if (queueIcon) { + if (queueIcon && this.config) { if (isVisible) { // Replace the image with an X and add red tint queueIcon.innerHTML = '×'; @@ -181,7 +341,7 @@ class DownloadQueue { } // Persist the state locally so it survives refreshes. - localStorage.setItem("downloadQueueVisible", isVisible); + localStorage.setItem("downloadQueueVisible", String(isVisible)); try { await this.loadConfig(); @@ -194,7 +354,7 @@ class DownloadQueue { queueSidebar.classList.toggle('active', !isVisible); queueSidebar.hidden = isVisible; // Also revert the icon back - if (queueIcon) { + if (queueIcon && this.config) { if (!isVisible) { queueIcon.innerHTML = '×'; queueIcon.setAttribute('aria-expanded', 'true'); @@ -210,18 +370,18 @@ class DownloadQueue { } } - showError(message) { + showError(message: string) { const errorDiv = document.createElement('div'); errorDiv.className = 'queue-error'; errorDiv.textContent = message; - document.getElementById('queueItems').prepend(errorDiv); + document.getElementById('queueItems')?.prepend(errorDiv); // Optional chaining setTimeout(() => errorDiv.remove(), 3000); } /** * Adds a new download entry. */ - addDownload(item, type, prgFile, requestUrl = null, startMonitoring = false) { + addDownload(item: QueueItem, type: string, prgFile: string, requestUrl: string | null = null, startMonitoring: boolean = false): string { const queueId = this.generateQueueId(); const entry = this.createQueueEntry(item, type, prgFile, queueId, requestUrl); this.queueEntries[queueId] = entry; @@ -238,7 +398,7 @@ class DownloadQueue { } /* Start processing the entry. Removed visibility check to ensure all entries are monitored. */ - async startDownloadStatusMonitoring(queueId) { + async startDownloadStatusMonitoring(queueId: string) { const entry = this.queueEntries[queueId]; if (!entry || entry.hasEnded) return; @@ -246,11 +406,11 @@ class DownloadQueue { if (this.pollingIntervals[queueId]) return; // Ensure entry has data containers for parent info - entry.parentInfo = entry.parentInfo || {}; + entry.parentInfo = entry.parentInfo || null; // Show a preparing message for new entries if (entry.isNew) { - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; if (logElement) { logElement.textContent = "Initializing download..."; } @@ -262,14 +422,14 @@ class DownloadQueue { try { const response = await fetch(`/api/prgs/${entry.prgFile}`); if (response.ok) { - const data = await response.json(); + const data: StatusData = await response.json(); // Add type to data // Update entry type if available if (data.type) { entry.type = data.type; // Update type display if element exists - const typeElement = entry.element.querySelector('.type'); + const typeElement = entry.element.querySelector('.type') as HTMLElement | null; if (typeElement) { typeElement.textContent = data.type.charAt(0).toUpperCase() + data.type.slice(1); typeElement.className = `type ${data.type}`; @@ -294,10 +454,10 @@ class DownloadQueue { if (data.last_line) { entry.lastStatus = data.last_line; entry.lastUpdated = Date.now(); - entry.status = data.last_line.status; + entry.status = data.last_line.status || 'unknown'; // Ensure status is not undefined // Update status message without recreating the element - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; if (logElement) { const statusMessage = this.getStatusMessage(data.last_line); logElement.textContent = statusMessage; @@ -325,7 +485,7 @@ class DownloadQueue { entry.type = parent.type; // Update the type indicator - const typeEl = entry.element.querySelector('.type'); + const typeEl = entry.element.querySelector('.type') as HTMLElement | null; if (typeEl) { const displayType = parent.type.charAt(0).toUpperCase() + parent.type.slice(1); typeEl.textContent = displayType; @@ -333,8 +493,8 @@ class DownloadQueue { } // Update the title and subtitle based on parent type - const titleEl = entry.element.querySelector('.title'); - const artistEl = entry.element.querySelector('.artist'); + const titleEl = entry.element.querySelector('.title') as HTMLElement | null; + const artistEl = entry.element.querySelector('.artist') as HTMLElement | null; if (parent.type === 'album') { if (titleEl) titleEl.textContent = parent.title || 'Unknown album'; @@ -350,7 +510,7 @@ class DownloadQueue { localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); // If the entry is already in a terminal state, don't set up polling - if (['error', 'complete', 'cancel', 'cancelled', 'done'].includes(data.last_line.status)) { + if (['error', 'complete', 'cancel', 'cancelled', 'done'].includes(data.last_line.status || '')) { // Add null check for status entry.hasEnded = true; this.handleDownloadCompletion(entry, queueId, data.last_line); return; @@ -373,11 +533,11 @@ class DownloadQueue { /** * Creates a new queue entry. It checks localStorage for any cached info. */ - createQueueEntry(item, type, prgFile, queueId, requestUrl) { + createQueueEntry(item: QueueItem, type: string, prgFile: string, queueId: string, requestUrl: string | null): QueueEntry { console.log(`Creating queue entry with initial type: ${type}`); // Get cached data if it exists - const cachedData = this.queueCache[prgFile]; + const cachedData: StatusData | undefined = this.queueCache[prgFile]; // Add type // If we have cached data, use it to determine the true type and item properties if (cachedData) { @@ -406,19 +566,19 @@ class DownloadQueue { item = { name: cachedData.title || cachedData.album || 'Unknown album', artist: cachedData.artist || 'Unknown artist', - total_tracks: cachedData.total_tracks || 0 + total_tracks: typeof cachedData.total_tracks === 'string' ? parseInt(cachedData.total_tracks, 10) : cachedData.total_tracks || 0 }; } else if (cachedData.type === 'playlist') { item = { name: cachedData.name || 'Unknown playlist', owner: cachedData.owner || 'Unknown creator', - total_tracks: cachedData.total_tracks || 0 + total_tracks: typeof cachedData.total_tracks === 'string' ? parseInt(cachedData.total_tracks, 10) : cachedData.total_tracks || 0 }; } } // Build the basic entry with possibly updated type and item - const entry = { + const entry: QueueEntry = { // Add type to entry item, type, prgFile, @@ -432,7 +592,7 @@ class DownloadQueue { artist: item.artist || item.artists?.[0]?.name || '', album: item.album?.name || '', title: item.name || '', - owner: item.owner || item.owner?.display_name || '', + owner: typeof item.owner === 'string' ? item.owner : item.owner?.display_name || '', total_tracks: item.total_tracks || 0 }, lastUpdated: Date.now(), @@ -451,7 +611,7 @@ class DownloadQueue { // If cached info exists for this PRG file, use it. if (cachedData) { entry.lastStatus = cachedData; - const logEl = entry.element.querySelector('.log'); + const logEl = entry.element.querySelector('.log') as HTMLElement | null; // Store parent information if available if (cachedData.parent) { @@ -459,7 +619,9 @@ class DownloadQueue { } // Render status message for cached data - logEl.textContent = this.getStatusMessage(entry.lastStatus); + if (logEl) { // Check if logEl is not null + logEl.textContent = this.getStatusMessage(entry.lastStatus); + } } // Store it in our queue object @@ -471,7 +633,7 @@ class DownloadQueue { /** * Returns an HTML element for the queue entry with modern UI styling. */ -createQueueItem(item, type, prgFile, queueId) { +createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string): HTMLElement { // Track whether this is a multi-track item (album or playlist) const isMultiTrack = type === 'album' || type === 'playlist'; const defaultMessage = (type === 'playlist') ? 'Reading track list' : 'Initializing download...'; @@ -481,7 +643,7 @@ createQueueItem(item, type, prgFile, queueId) { const displayArtist = item.artist || ''; const displayType = type.charAt(0).toUpperCase() + type.slice(1); - const div = document.createElement('article'); + const div = document.createElement('article') as HTMLElement; // Cast to HTMLElement div.className = 'queue-item queue-item-new'; // Add the animation class div.setAttribute('aria-live', 'polite'); div.setAttribute('aria-atomic', 'true'); @@ -535,7 +697,7 @@ createQueueItem(item, type, prgFile, queueId) { div.innerHTML = innerHtml; - div.querySelector('.cancel-btn').addEventListener('click', (e) => this.handleCancelDownload(e)); + (div.querySelector('.cancel-btn') as HTMLButtonElement | null)?.addEventListener('click', (e: MouseEvent) => this.handleCancelDownload(e)); // Add types and optional chaining // Remove the animation class after animation completes setTimeout(() => { @@ -546,7 +708,7 @@ createQueueItem(item, type, prgFile, queueId) { } // Add a helper method to apply the right CSS classes based on status - applyStatusClasses(entry, status) { + applyStatusClasses(entry: QueueEntry, statusData: StatusData) { // Add types for statusData // If no element, nothing to do if (!entry.element) return; @@ -557,7 +719,7 @@ createQueueItem(item, type, prgFile, queueId) { ); // Handle various status types - switch (status) { + switch (statusData.status) { // Use statusData.status case 'queued': entry.element.classList.add('queued'); break; @@ -576,7 +738,7 @@ createQueueItem(item, type, prgFile, queueId) { case 'error': entry.element.classList.add('error'); // Hide error-details to prevent duplicate error display - const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`); + const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; if (errorDetailsContainer) { errorDetailsContainer.style.display = 'none'; } @@ -586,7 +748,7 @@ createQueueItem(item, type, prgFile, queueId) { entry.element.classList.add('complete'); // Hide error details if present if (entry.element) { - const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`); + const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; if (errorDetailsContainer) { errorDetailsContainer.style.display = 'none'; } @@ -596,7 +758,7 @@ createQueueItem(item, type, prgFile, queueId) { entry.element.classList.add('cancelled'); // Hide error details if present if (entry.element) { - const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`); + const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; if (errorDetailsContainer) { errorDetailsContainer.style.display = 'none'; } @@ -605,10 +767,13 @@ createQueueItem(item, type, prgFile, queueId) { } } - async handleCancelDownload(e) { - const btn = e.target.closest('button'); + async handleCancelDownload(e: MouseEvent) { // Add type for e + const btn = (e.target as HTMLElement).closest('button') as HTMLButtonElement | null; // Add types and null check + if (!btn) return; // Guard clause btn.style.display = 'none'; const { prg, type, queueid } = btn.dataset; + if (!prg || !type || !queueid) return; // Guard against undefined dataset properties + try { // Get the queue item element const entry = this.queueEntries[queueid]; @@ -618,7 +783,7 @@ createQueueItem(item, type, prgFile, queueId) { } // Show cancellation in progress - const logElement = document.getElementById(`log-${queueid}-${prg}`); + const logElement = document.getElementById(`log-${queueid}-${prg}`) as HTMLElement | null; if (logElement) { logElement.textContent = "Cancelling..."; } @@ -635,7 +800,7 @@ createQueueItem(item, type, prgFile, queueId) { this.clearPollingInterval(queueid); if (entry.intervalId) { - clearInterval(entry.intervalId); + clearInterval(entry.intervalId as number); // Cast to number entry.intervalId = null; } @@ -657,11 +822,12 @@ createQueueItem(item, type, prgFile, queueId) { updateQueueOrder() { const container = document.getElementById('queueItems'); const footer = document.getElementById('queueFooter'); + if (!container || !footer) return; // Guard against null const entries = Object.values(this.queueEntries); // Sorting: errors/canceled first (group 0), ongoing next (group 1), queued last (group 2, sorted by position). entries.sort((a, b) => { - const getGroup = (entry) => { + const getGroup = (entry: QueueEntry) => { // Add type if (entry.lastStatus && (entry.lastStatus.status === "error" || entry.lastStatus.status === "cancel")) { return 0; } else if (entry.lastStatus && entry.lastStatus.status === "queued") { @@ -685,7 +851,10 @@ createQueueItem(item, type, prgFile, queueId) { }); // Update the header with just the total count - document.getElementById('queueTotalCount').textContent = entries.length; + const queueTotalCountEl = document.getElementById('queueTotalCount') as HTMLElement | null; + if (queueTotalCountEl) { + queueTotalCountEl.textContent = entries.length.toString(); + } // Remove subtitle with detailed stats if it exists const subtitleEl = document.getElementById('queueSubtitle'); @@ -723,8 +892,8 @@ createQueueItem(item, type, prgFile, queueId) { // Create a map of current DOM elements by queue ID const existingElementMap = {}; visibleItems.forEach(el => { - const queueId = el.querySelector('.cancel-btn')?.dataset.queueid; - if (queueId) existingElementMap[queueId] = el; + const queueId = (el.querySelector('.cancel-btn') as HTMLElement | null)?.dataset.queueid; // Optional chaining + if (queueId) existingElementMap[queueId] = el as HTMLElement; // Cast to HTMLElement }); // Clear container to re-add in correct order @@ -752,7 +921,7 @@ createQueueItem(item, type, prgFile, queueId) { showMoreBtn.textContent = `Show ${remaining} more`; showMoreBtn.addEventListener('click', () => { this.visibleCount += 10; - localStorage.setItem("downloadQueueVisibleCount", this.visibleCount); + localStorage.setItem("downloadQueueVisibleCount", this.visibleCount.toString()); // toString this.updateQueueOrder(); }); footer.appendChild(showMoreBtn); @@ -760,10 +929,10 @@ createQueueItem(item, type, prgFile, queueId) { } /* Checks if an entry is visible in the queue display. */ - isEntryVisible(queueId) { + isEntryVisible(queueId: string): boolean { // Add return type const entries = Object.values(this.queueEntries); entries.sort((a, b) => { - const getGroup = (entry) => { + const getGroup = (entry: QueueEntry) => { // Add type if (entry.lastStatus && (entry.lastStatus.status === "error" || entry.lastStatus.status === "cancel")) { return 0; } else if (entry.lastStatus && entry.lastStatus.status === "queued") { @@ -789,7 +958,7 @@ createQueueItem(item, type, prgFile, queueId) { return index >= 0 && index < this.visibleCount; } - async cleanupEntry(queueId) { + async cleanupEntry(queueId: string) { const entry = this.queueEntries[queueId]; if (entry) { // Close any polling interval @@ -797,10 +966,10 @@ createQueueItem(item, type, prgFile, queueId) { // Clean up any intervals if (entry.intervalId) { - clearInterval(entry.intervalId); + clearInterval(entry.intervalId as number); // Cast to number } if (entry.autoRetryInterval) { - clearInterval(entry.autoRetryInterval); + clearInterval(entry.autoRetryInterval as number); // Cast to number } // Remove from the DOM @@ -833,12 +1002,12 @@ createQueueItem(item, type, prgFile, queueId) { } /* Event Dispatching */ - dispatchEvent(name, detail) { + dispatchEvent(name: string, detail: any) { // Add type for name document.dispatchEvent(new CustomEvent(name, { detail })); } /* Status Message Handling */ - getStatusMessage(data) { + getStatusMessage(data: StatusData): string { // Add types // Determine the true display type - if this is a track with a parent, we may want to // show it as part of the parent's download process let displayType = data.type || 'unknown'; @@ -851,7 +1020,7 @@ createQueueItem(item, type, prgFile, queueId) { } // Find the queue item this status belongs to - let queueItem = null; + let queueItem: QueueEntry | null = null; const prgFile = data.prg_file || Object.keys(this.queueCache).find(key => this.queueCache[key].status === data.status && this.queueCache[key].type === data.type ); @@ -875,7 +1044,7 @@ createQueueItem(item, type, prgFile, queueId) { const playlistName = data.name || data.parent?.name || (queueItem?.item?.name) || ''; const playlistOwner = data.owner || data.parent?.owner || - (queueItem?.item?.owner) || ''; + (queueItem?.item?.owner) || ''; // Add type check if item.owner is object const currentTrack = data.current_track || data.parsed_current_track || ''; const totalTracks = data.total_tracks || data.parsed_total_tracks || data.parent?.total_tracks || (queueItem?.item?.total_tracks) || ''; @@ -883,15 +1052,15 @@ createQueueItem(item, type, prgFile, queueId) { // Format percentage for display when available let formattedPercentage = '0'; if (data.progress !== undefined) { - formattedPercentage = parseFloat(data.progress).toFixed(1); + formattedPercentage = parseFloat(data.progress as string).toFixed(1); // Cast to string } else if (data.percentage) { - formattedPercentage = (parseFloat(data.percentage) * 100).toFixed(1); + formattedPercentage = (parseFloat(data.percentage as string) * 100).toFixed(1); // Cast to string } else if (data.percent) { - formattedPercentage = (parseFloat(data.percent) * 100).toFixed(1); + formattedPercentage = (parseFloat(data.percent as string) * 100).toFixed(1); // Cast to string } // Helper for constructing info about the parent item - const getParentInfo = () => { + const getParentInfo = (): string => { // Add return type if (!data.parent) return ''; if (data.parent.type === 'album') { @@ -1104,16 +1273,16 @@ createQueueItem(item, type, prgFile, queueId) { } /* New Methods to Handle Terminal State, Inactivity and Auto-Retry */ - handleDownloadCompletion(entry, queueId, progress) { + handleDownloadCompletion(entry: QueueEntry, queueId: string, progress: StatusData | number) { // Add types // Mark the entry as ended entry.hasEnded = true; // Update progress bar if available if (typeof progress === 'number') { - const progressBar = entry.element.querySelector('.progress-bar'); + const progressBar = entry.element.querySelector('.progress-bar') as HTMLElement | null; if (progressBar) { progressBar.style.width = '100%'; - progressBar.setAttribute('aria-valuenow', 100); + progressBar.setAttribute('aria-valuenow', "100"); // Use string for aria-valuenow progressBar.classList.add('bg-success'); } } @@ -1130,7 +1299,7 @@ createQueueItem(item, type, prgFile, queueId) { }, cleanupDelay); } - handleInactivity(entry, queueId, logElement) { + handleInactivity(entry: QueueEntry, queueId: string, logElement: HTMLElement | null) { // Add types if (entry.lastStatus && entry.lastStatus.status === 'queued') { if (logElement) { logElement.textContent = this.getStatusMessage(entry.lastStatus); @@ -1139,8 +1308,8 @@ createQueueItem(item, type, prgFile, queueId) { } const now = Date.now(); if (now - entry.lastUpdated > 300000) { - const progress = { status: 'error', message: 'Inactivity timeout' }; - this.handleDownloadCompletion(entry, queueId, progress); + const progressData: StatusData = { status: 'error', error: 'Inactivity timeout' }; // Use error property + this.handleDownloadCompletion(entry, queueId, progressData); // Pass StatusData } else { if (logElement) { logElement.textContent = this.getStatusMessage(entry.lastStatus); @@ -1148,7 +1317,7 @@ createQueueItem(item, type, prgFile, queueId) { } } - async retryDownload(queueId, logElement) { + async retryDownload(queueId: string, logElement: HTMLElement | null) { // Add type const entry = this.queueEntries[queueId]; if (!entry) { console.warn(`Retry called for non-existent queueId: ${queueId}`); @@ -1157,15 +1326,15 @@ createQueueItem(item, type, prgFile, queueId) { // The retry button is already showing "Retrying..." and is disabled by the click handler. // We will update the error message div within logElement if retry fails. - const errorMessageDiv = logElement?.querySelector('.error-message'); - const retryBtn = logElement?.querySelector('.retry-btn'); + const errorMessageDiv = logElement?.querySelector('.error-message') as HTMLElement | null; + const retryBtn = logElement?.querySelector('.retry-btn') as HTMLButtonElement | null; entry.isRetrying = true; // Mark the original entry as being retried. - + // Determine if we should use parent information for retry (existing logic) let useParent = false; - let parentType = null; - let parentUrl = null; + let parentType: string | null = null; // Add type + let parentUrl: string | null = null; // Add type if (entry.lastStatus && entry.lastStatus.parent) { const parent = entry.lastStatus.parent; if (parent.type && parent.url) { @@ -1175,8 +1344,8 @@ createQueueItem(item, type, prgFile, queueId) { console.log(`Using parent info for retry: ${parentType} with URL: ${parentUrl}`); } } - - const getRetryUrl = () => { + + const getRetryUrl = (): string | null => { // Add return type if (entry.lastStatus && entry.lastStatus.original_url) return entry.lastStatus.original_url; if (useParent && parentUrl) return parentUrl; if (entry.requestUrl) return entry.requestUrl; @@ -1187,9 +1356,9 @@ createQueueItem(item, type, prgFile, queueId) { if (entry.lastStatus && entry.lastStatus.url) return entry.lastStatus.url; return null; }; - + const retryUrl = getRetryUrl(); - + if (!retryUrl) { if (errorMessageDiv) errorMessageDiv.textContent = 'Retry not available: missing URL information.'; entry.isRetrying = false; @@ -1199,16 +1368,16 @@ createQueueItem(item, type, prgFile, queueId) { } return; } - + // Store details needed for the new entry BEFORE any async operations - const originalItem = { ...entry.item }; // Shallow copy - const apiTypeForNewEntry = useParent ? parentType : entry.type; + const originalItem: QueueItem = { ...entry.item }; // Shallow copy, add type + const apiTypeForNewEntry = useParent && parentType ? parentType : entry.type; // Ensure parentType is not null console.log(`Retrying download using type: ${apiTypeForNewEntry} with base URL: ${retryUrl}`); - - let fullRetryUrl; + + let fullRetryUrl; if (retryUrl.startsWith('http') || retryUrl.startsWith('/api/')) { // if it's already a full URL or an API path fullRetryUrl = retryUrl; - } else { + } else { // Construct full URL if retryUrl is just a resource identifier fullRetryUrl = `/api/${apiTypeForNewEntry}/download?url=${encodeURIComponent(retryUrl)}`; // Append metadata if retryUrl is raw resource URL @@ -1218,7 +1387,7 @@ createQueueItem(item, type, prgFile, queueId) { if (originalItem && originalItem.artist) { fullRetryUrl += `&artist=${encodeURIComponent(originalItem.artist)}`; } - } + } const requestUrlForNewEntry = fullRetryUrl; try { @@ -1230,16 +1399,16 @@ createQueueItem(item, type, prgFile, queueId) { const errorText = await retryResponse.text(); throw new Error(`Server returned ${retryResponse.status}${errorText ? (': ' + errorText) : ''}`); } - - const retryData = await retryResponse.json(); - + + const retryData: StatusData = await retryResponse.json(); // Add type + if (retryData.prg_file) { const newPrgFile = retryData.prg_file; - + // Clean up the old entry from UI, memory, cache, and server (PRG file) // logElement and retryBtn are part of the old entry's DOM structure and will be removed. await this.cleanupEntry(queueId); - + // Add the new download entry. This will create a new element, start monitoring, etc. this.addDownload(originalItem, apiTypeForNewEntry, newPrgFile, requestUrlForNewEntry, true); @@ -1261,11 +1430,11 @@ createQueueItem(item, type, prgFile, queueId) { const stillExistingEntry = this.queueEntries[queueId]; if (stillExistingEntry && stillExistingEntry.element) { // logElement might be stale if the element was re-rendered, so query again if possible. - const currentLogOnFailedEntry = stillExistingEntry.element.querySelector('.log'); - const errorDivOnFailedEntry = currentLogOnFailedEntry?.querySelector('.error-message') || errorMessageDiv; - const retryButtonOnFailedEntry = currentLogOnFailedEntry?.querySelector('.retry-btn') || retryBtn; + const currentLogOnFailedEntry = stillExistingEntry.element.querySelector('.log') as HTMLElement | null; + const errorDivOnFailedEntry = currentLogOnFailedEntry?.querySelector('.error-message') as HTMLElement | null || errorMessageDiv; + const retryButtonOnFailedEntry = currentLogOnFailedEntry?.querySelector('.retry-btn') as HTMLButtonElement | null || retryBtn; - if (errorDivOnFailedEntry) errorDivOnFailedEntry.textContent = 'Retry failed: ' + error.message; + if (errorDivOnFailedEntry) errorDivOnFailedEntry.textContent = 'Retry failed: ' + (error as Error).message; // Cast error to Error stillExistingEntry.isRetrying = false; if (retryButtonOnFailedEntry) { retryButtonOnFailedEntry.disabled = false; @@ -1273,7 +1442,7 @@ createQueueItem(item, type, prgFile, queueId) { } } else if (errorMessageDiv) { // Fallback if entry is gone from queue but original logElement's parts are somehow still accessible - errorMessageDiv.textContent = 'Retry failed: ' + error.message; + errorMessageDiv.textContent = 'Retry failed: ' + (error as Error).message; if (retryBtn) { retryBtn.disabled = false; retryBtn.innerHTML = 'Retry'; @@ -1300,7 +1469,7 @@ createQueueItem(item, type, prgFile, queueId) { * This method replaces the individual startTrackDownload, startAlbumDownload, etc. methods. * It will be called by all the other JS files. */ - async download(url, type, item, albumType = null) { + async download(url: string, type: string, item: QueueItem, albumType: string | null = null): Promise { // Add types and return type if (!url) { throw new Error('Missing URL for download'); } @@ -1317,8 +1486,9 @@ createQueueItem(item, type, prgFile, queueId) { try { // Show a loading indicator - if (document.getElementById('queueIcon')) { - document.getElementById('queueIcon').classList.add('queue-icon-active'); + const queueIcon = document.getElementById('queueIcon'); // No direct classList manipulation + if (queueIcon) { + queueIcon.classList.add('queue-icon-active'); } const response = await fetch(apiUrl); @@ -1326,23 +1496,23 @@ createQueueItem(item, type, prgFile, queueId) { throw new Error(`Server returned ${response.status}`); } - const data = await response.json(); + const data: StatusData | { task_ids?: string[], album_prg_files?: string[] } = await response.json(); // Add type for data // Handle artist downloads which return multiple album tasks if (type === 'artist') { // Check for new API response format - if (data.task_ids && Array.isArray(data.task_ids)) { + if ('task_ids' in data && data.task_ids && Array.isArray(data.task_ids)) { // Type guard console.log(`Queued artist discography with ${data.task_ids.length} albums`); // Make queue visible to show progress this.toggleVisibility(true); // Create entries directly from task IDs and start monitoring them - const queueIds = []; + const queueIds: string[] = []; // Add type for (const taskId of data.task_ids) { console.log(`Adding album task with ID: ${taskId}`); // Create an album item with better display information - const albumItem = { + const albumItem: QueueItem = { // Add type name: `${item.name || 'Artist'} - Album (loading...)`, artist: item.name || 'Unknown artist', type: 'album' @@ -1355,18 +1525,18 @@ createQueueItem(item, type, prgFile, queueId) { return queueIds; } // Check for older API response format - else if (data.album_prg_files && Array.isArray(data.album_prg_files)) { + else if ('album_prg_files' in data && data.album_prg_files && Array.isArray(data.album_prg_files)) { // Type guard console.log(`Queued artist discography with ${data.album_prg_files.length} albums (old format)`); // Make queue visible to show progress this.toggleVisibility(true); // Add each album to the download queue separately with forced monitoring - const queueIds = []; + const queueIds: string[] = []; // Add type data.album_prg_files.forEach(prgFile => { console.log(`Adding album with PRG file: ${prgFile}`); // Create an album item with better display information - const albumItem = { + const albumItem: QueueItem = { // Add type name: `${item.name || 'Artist'} - Album (loading...)`, artist: item.name || 'Unknown artist', type: 'album' @@ -1401,8 +1571,8 @@ createQueueItem(item, type, prgFile, queueId) { } // Handle single-file downloads (tracks, albums, playlists) - if (data.prg_file) { - console.log(`Adding ${type} with PRG file: ${data.prg_file}`); + if ('prg_file' in data && data.prg_file) { // Type guard + console.log(`Adding ${type} PRG file: ${data.prg_file}`); // Store the initial metadata in the cache so it's available // even before the first status update @@ -1412,7 +1582,7 @@ createQueueItem(item, type, prgFile, queueId) { name: item.name || 'Unknown', title: item.name || 'Unknown', artist: item.artist || (item.artists && item.artists.length > 0 ? item.artists[0].name : ''), - owner: item.owner || (item.owner ? item.owner.display_name : ''), + owner: typeof item.owner === 'string' ? item.owner : item.owner?.display_name || '', total_tracks: item.total_tracks || 0 }; @@ -1420,7 +1590,7 @@ createQueueItem(item, type, prgFile, queueId) { const queueId = this.addDownload(item, type, data.prg_file, apiUrl, true); // Make queue visible to show progress if not already visible - if (!this.config.downloadQueueVisible) { + if (this.config && !this.config.downloadQueueVisible) { // Add null check for config this.toggleVisibility(true); } @@ -1450,7 +1620,7 @@ createQueueItem(item, type, prgFile, queueId) { } const response = await fetch('/api/prgs/list'); - const prgFiles = await response.json(); + const prgFiles: string[] = await response.json(); // Add type // Sort filenames by the numeric portion (assumes format "type_number.prg"). prgFiles.sort((a, b) => { @@ -1464,7 +1634,7 @@ createQueueItem(item, type, prgFile, queueId) { try { const prgResponse = await fetch(`/api/prgs/${prgFile}`); if (!prgResponse.ok) continue; - const prgData = await prgResponse.json(); + const prgData: StatusData = await prgResponse.json(); // Add type // Skip prg files that are marked as cancelled, completed, or interrupted if (prgData.last_line && @@ -1483,7 +1653,7 @@ createQueueItem(item, type, prgFile, queueId) { } // Check cached status - if we marked it cancelled locally, delete it and skip - const cachedStatus = this.queueCache[prgFile]; + const cachedStatus: StatusData | undefined = this.queueCache[prgFile]; // Add type if (cachedStatus && (cachedStatus.status === 'cancelled' || cachedStatus.status === 'cancel' || @@ -1500,11 +1670,11 @@ createQueueItem(item, type, prgFile, queueId) { // Use the enhanced original request info from the first line const originalRequest = prgData.original_request || {}; - let lastLineData = prgData.last_line || {}; + let lastLineData: StatusData = prgData.last_line || {}; // Add type // First check if this is a track with a parent (part of an album/playlist) let itemType = lastLineData.type || prgData.display_type || originalRequest.display_type || originalRequest.type || 'unknown'; - let dummyItem = {}; + let dummyItem: QueueItem = {}; // Add type // If this is a track with a parent, treat it as the parent type for UI purposes if (lastLineData.type === 'track' && lastLineData.parent) { @@ -1516,11 +1686,10 @@ createQueueItem(item, type, prgFile, queueId) { name: parent.title || 'Unknown Album', artist: parent.artist || 'Unknown Artist', type: 'album', - total_tracks: parent.total_tracks || 0, url: parent.url || '', // Keep track of the current track info for progress display current_track: lastLineData.current_track, - total_tracks: parent.total_tracks || lastLineData.total_tracks, + total_tracks: (typeof parent.total_tracks === 'string' ? parseInt(parent.total_tracks, 10) : parent.total_tracks) || (typeof lastLineData.total_tracks === 'string' ? parseInt(lastLineData.total_tracks, 10) : lastLineData.total_tracks) || 0, // Store parent info directly in the item parent: parent }; @@ -1530,11 +1699,10 @@ createQueueItem(item, type, prgFile, queueId) { name: parent.name || 'Unknown Playlist', owner: parent.owner || 'Unknown Creator', type: 'playlist', - total_tracks: parent.total_tracks || 0, url: parent.url || '', // Keep track of the current track info for progress display current_track: lastLineData.current_track, - total_tracks: parent.total_tracks || lastLineData.total_tracks, + total_tracks: (typeof parent.total_tracks === 'string' ? parseInt(parent.total_tracks, 10) : parent.total_tracks) || (typeof lastLineData.total_tracks === 'string' ? parseInt(lastLineData.total_tracks, 10) : lastLineData.total_tracks) || 0, // Store parent info directly in the item parent: parent }; @@ -1551,7 +1719,7 @@ createQueueItem(item, type, prgFile, queueId) { // Include any available track info song: lastLineData.song, title: lastLineData.title, - total_tracks: lastLineData.total_tracks, + total_tracks: typeof lastLineData.total_tracks === 'string' ? parseInt(lastLineData.total_tracks, 10) : lastLineData.total_tracks, current_track: lastLineData.current_track }; }; @@ -1570,7 +1738,7 @@ createQueueItem(item, type, prgFile, queueId) { } // Build a potential requestUrl from the original information - let requestUrl = null; + let requestUrl: string | null = null; // Add type if (dummyItem.endpoint && dummyItem.url) { const params = new CustomURLSearchParams(); params.append('url', dummyItem.url); @@ -1582,7 +1750,7 @@ createQueueItem(item, type, prgFile, queueId) { for (const [key, value] of Object.entries(originalRequest)) { if (!['url', 'name', 'artist', 'type', 'endpoint', 'download_type', 'display_title', 'display_type', 'display_artist', 'service'].includes(key)) { - params.append(key, value); + params.append(key, value as string); // Cast value to string } } @@ -1610,12 +1778,12 @@ createQueueItem(item, type, prgFile, queueId) { this.applyStatusClasses(entry, prgData.last_line); // Update log display with current info - const logElement = entry.element.querySelector('.log'); + const logElement = entry.element.querySelector('.log') as HTMLElement | null; if (logElement) { if (prgData.last_line.song && prgData.last_line.artist && - ['progress', 'real-time', 'real_time', 'processing', 'downloading'].includes(prgData.last_line.status)) { + ['progress', 'real-time', 'real_time', 'processing', 'downloading'].includes(prgData.last_line.status || '')) { // Add null check logElement.textContent = `Currently downloading: ${prgData.last_line.song} by ${prgData.last_line.artist}`; - } else if (entry.parentInfo && !['done', 'complete', 'error', 'skipped'].includes(prgData.last_line.status)) { + } else if (entry.parentInfo && !['done', 'complete', 'error', 'skipped'].includes(prgData.last_line.status || '')) { // Show parent info for non-terminal states if (entry.parentInfo.type === 'album') { logElement.textContent = `From album: "${entry.parentInfo.title}"`; @@ -1666,11 +1834,17 @@ createQueueItem(item, type, prgFile, queueId) { 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.config = {}; + this.config = { // Initialize with a default structure on error + downloadQueueVisible: false, + maxRetries: 3, + retryDelaySeconds: 5, + retry_delay_increase: 5, + explicitFilter: false + }; } } - async saveConfig(updatedConfig) { + async saveConfig(updatedConfig: AppConfig) { // Add type try { const response = await fetch('/api/config', { method: 'POST', @@ -1686,12 +1860,12 @@ createQueueItem(item, type, prgFile, queueId) { } // Add a method to check if explicit filter is enabled - isExplicitFilterEnabled() { + isExplicitFilterEnabled(): boolean { // Add return type return !!this.config.explicitFilter; } /* Sets up a polling interval for real-time status updates */ - setupPollingInterval(queueId) { + setupPollingInterval(queueId: string) { // Add type console.log(`Setting up polling for ${queueId}`); const entry = this.queueEntries[queueId]; if (!entry || !entry.prgFile) { @@ -1712,18 +1886,18 @@ createQueueItem(item, type, prgFile, queueId) { }, 500); // Store the interval ID for later cleanup - this.pollingIntervals[queueId] = intervalId; + this.pollingIntervals[queueId] = intervalId as unknown as number; // Cast to number via unknown } catch (error) { console.error(`Error creating polling for ${queueId}:`, error); - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; if (logElement) { - logElement.textContent = `Error with download: ${error.message}`; + logElement.textContent = `Error with download: ${(error as Error).message}`; // Cast to Error entry.element.classList.add('error'); } } } - async fetchDownloadStatus(queueId) { + async fetchDownloadStatus(queueId: string) { // Add type const entry = this.queueEntries[queueId]; if (!entry || !entry.prgFile) { console.warn(`No entry or prgFile for ${queueId}`); @@ -1736,7 +1910,7 @@ createQueueItem(item, type, prgFile, queueId) { throw new Error(`HTTP error: ${response.status}`); } - const data = await response.json(); + const data: StatusData = await response.json(); // Add type // If the last_line doesn't have name/artist/title info, add it from our stored item data if (data.last_line && entry.item) { @@ -1752,7 +1926,7 @@ createQueueItem(item, type, prgFile, queueId) { data.last_line.artist = entry.item.artists[0].name; } if (!data.last_line.owner && entry.item.owner) { - data.last_line.owner = entry.item.owner; + data.last_line.owner = typeof entry.item.owner === 'string' ? entry.item.owner : entry.item.owner?.display_name ; } if (!data.last_line.total_tracks && entry.item.total_tracks) { data.last_line.total_tracks = entry.item.total_tracks; @@ -1765,7 +1939,7 @@ createQueueItem(item, type, prgFile, queueId) { entry.type = data.type; // Update type display if element exists - const typeElement = entry.element.querySelector('.type'); + const typeElement = entry.element.querySelector('.type') as HTMLElement | null; if (typeElement) { typeElement.textContent = data.type.charAt(0).toUpperCase() + data.type.slice(1); // Update type class without triggering animation @@ -1795,7 +1969,7 @@ createQueueItem(item, type, prgFile, queueId) { this.handleStatusUpdate(queueId, data); // Handle terminal states - if (data.last_line && ['complete', 'error', 'cancelled', 'done'].includes(data.last_line.status)) { + if (data.last_line && ['complete', 'error', 'cancelled', 'done'].includes(data.last_line.status || '')) { // Add null check console.log(`Terminal state detected: ${data.last_line.status} for ${queueId}`); entry.hasEnded = true; @@ -1816,9 +1990,10 @@ createQueueItem(item, type, prgFile, queueId) { if (!isRetrying) { setTimeout(() => { // Double-check the entry still exists and has not been retried before cleaning up - if (this.queueEntries[queueId] && - !this.queueEntries[queueId].isRetrying && - this.queueEntries[queueId].hasEnded) { + const currentEntry = this.queueEntries[queueId]; // Get current entry + if (currentEntry && // Check if currentEntry exists + !currentEntry.isRetrying && + currentEntry.hasEnded) { this.clearPollingInterval(queueId); this.cleanupEntry(queueId); } @@ -1830,18 +2005,18 @@ createQueueItem(item, type, prgFile, queueId) { console.error(`Error fetching status for ${queueId}:`, error); // Show error in log - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; if (logElement) { - logElement.textContent = `Error updating status: ${error.message}`; + logElement.textContent = `Error updating status: ${(error as Error).message}`; // Cast to Error } } } - clearPollingInterval(queueId) { + clearPollingInterval(queueId: string) { // Add type if (this.pollingIntervals[queueId]) { console.log(`Stopping polling for ${queueId}`); try { - clearInterval(this.pollingIntervals[queueId]); + clearInterval(this.pollingIntervals[queueId] as number); // Cast to number } catch (error) { console.error(`Error stopping polling for ${queueId}:`, error); } @@ -1850,7 +2025,7 @@ createQueueItem(item, type, prgFile, queueId) { } /* Handle status updates from the progress API */ - handleStatusUpdate(queueId, data) { + handleStatusUpdate(queueId: string, data: StatusData) { // Add types const entry = this.queueEntries[queueId]; if (!entry) { console.warn(`No entry for ${queueId}`); @@ -1858,7 +2033,7 @@ createQueueItem(item, type, prgFile, queueId) { } // Extract the actual status data from the API response - const statusData = data.last_line || {}; + const statusData: StatusData = data.last_line || {}; // Add type // Special handling for track status updates that are part of an album/playlist // We want to keep these for showing the track-by-track progress @@ -1930,7 +2105,7 @@ createQueueItem(item, type, prgFile, queueId) { // Update type if needed - could be more specific now (e.g., from 'album' to 'compilation') if (statusData.type && statusData.type !== entry.type) { entry.type = statusData.type; - const typeEl = entry.element.querySelector('.type'); + const typeEl = entry.element.querySelector('.type') as HTMLElement | null; if (typeEl) { const displayType = entry.type.charAt(0).toUpperCase() + entry.type.slice(1); typeEl.textContent = displayType; @@ -1946,7 +2121,7 @@ createQueueItem(item, type, prgFile, queueId) { // Update log message - but only if we're not handling a track update for an album/playlist // That case is handled separately in updateItemMetadata to ensure we show the right track info - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; if (logElement && status !== 'error' && !(statusData.type === 'track' && statusData.parent && (entry.type === 'album' || entry.type === 'playlist'))) { logElement.textContent = message; @@ -1967,22 +2142,22 @@ createQueueItem(item, type, prgFile, queueId) { } // Apply appropriate status classes - this.applyStatusClasses(entry, status); + this.applyStatusClasses(entry, statusData); // Pass statusData instead of status string // Special handling for error status based on new API response format if (status === 'error') { entry.hasEnded = true; // Hide cancel button - const cancelBtn = entry.element.querySelector('.cancel-btn'); + const cancelBtn = entry.element.querySelector('.cancel-btn') as HTMLButtonElement | null; if (cancelBtn) cancelBtn.style.display = 'none'; // Hide progress bars for errored items - const trackProgressContainer = entry.element.querySelector(`#track-progress-container-${entry.uniqueId}-${entry.prgFile}`); + const trackProgressContainer = entry.element.querySelector(`#track-progress-container-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; if (trackProgressContainer) trackProgressContainer.style.display = 'none'; - const overallProgressContainer = entry.element.querySelector('.overall-progress-container'); + const overallProgressContainer = entry.element.querySelector('.overall-progress-container') as HTMLElement | null; if (overallProgressContainer) overallProgressContainer.style.display = 'none'; // Hide time elapsed for errored items - const timeElapsedContainer = entry.element.querySelector(`#time-elapsed-${entry.uniqueId}-${entry.prgFile}`); + const timeElapsedContainer = entry.element.querySelector(`#time-elapsed-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; if (timeElapsedContainer) timeElapsedContainer.style.display = 'none'; // Extract error details @@ -1995,50 +2170,52 @@ createQueueItem(item, type, prgFile, queueId) { console.log(`Error for ${entry.type} download. Can retry: ${!!entry.requestUrl}. Retry URL: ${entry.requestUrl}`); - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); - if (logElement) { - let errorMessageElement = logElement.querySelector('.error-message'); + const errorLogElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; // Use a different variable name + if (errorLogElement) { // Check errorLogElement + let errorMessageElement = errorLogElement.querySelector('.error-message') as HTMLElement | null; if (!errorMessageElement) { // If error UI (message and buttons) is not built yet - // Build error UI with manual retry always available - logElement.innerHTML = ` -
${errMsg}
-
- - -
- `; - errorMessageElement = logElement.querySelector('.error-message'); // Re-select after innerHTML change + // Build error UI with manual retry always available + errorLogElement.innerHTML = ` +
${errMsg}
+
+ + +
+ `; + errorMessageElement = errorLogElement.querySelector('.error-message') as HTMLElement | null; // Re-select after innerHTML change // Attach listeners ONLY when creating the buttons - const closeErrorBtn = logElement.querySelector('.close-error-btn'); + const closeErrorBtn = errorLogElement.querySelector('.close-error-btn') as HTMLButtonElement | null; if (closeErrorBtn) { closeErrorBtn.addEventListener('click', () => { - this.cleanupEntry(queueId); - }); + this.cleanupEntry(queueId); + }); } - const retryBtnElem = logElement.querySelector('.retry-btn'); + const retryBtnElem = errorLogElement.querySelector('.retry-btn') as HTMLButtonElement | null; if (retryBtnElem) { - retryBtnElem.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - retryBtnElem.disabled = true; - retryBtnElem.innerHTML = ' Retrying...'; - this.retryDownload(queueId, logElement); - }); + retryBtnElem.addEventListener('click', (e: MouseEvent) => { // Add type for e + e.preventDefault(); + e.stopPropagation(); + if (retryBtnElem) { // Check if retryBtnElem is not null + retryBtnElem.disabled = true; + retryBtnElem.innerHTML = ' Retrying...'; + } + this.retryDownload(queueId, errorLogElement); // Pass errorLogElement + }); } // Auto cleanup after 15s - only set this timeout once when error UI is first built - setTimeout(() => { + setTimeout(() => { const currentEntryForCleanup = this.queueEntries[queueId]; if (currentEntryForCleanup && currentEntryForCleanup.hasEnded && currentEntryForCleanup.lastStatus?.status === 'error' && !currentEntryForCleanup.isRetrying) { - this.cleanupEntry(queueId); - } - }, 15000); + this.cleanupEntry(queueId); + } + }, 15000); } else { // Error UI already exists, just update the message text if it's different if (errorMessageElement.textContent !== errMsg) { @@ -2060,13 +2237,13 @@ createQueueItem(item, type, prgFile, queueId) { } // Update item metadata (title, artist, etc.) - updateItemMetadata(entry, statusData, data) { - const titleEl = entry.element.querySelector('.title'); - const artistEl = entry.element.querySelector('.artist'); + updateItemMetadata(entry: QueueEntry, statusData: StatusData, data: StatusData) { // Add types + const titleEl = entry.element.querySelector('.title') as HTMLElement | null; + const artistEl = entry.element.querySelector('.artist') as HTMLElement | null; if (titleEl) { // Check various data sources for a better title - let betterTitle = null; + let betterTitle: string | null | undefined = null; // First check the statusData if (statusData.song) { @@ -2107,16 +2284,16 @@ createQueueItem(item, type, prgFile, queueId) { } // Update real-time progress for track downloads - updateRealTimeProgress(entry, statusData) { + updateRealTimeProgress(entry: QueueEntry, statusData: StatusData) { // Add types // Get track progress bar - const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile); - const timeElapsedEl = entry.element.querySelector('#time-elapsed-' + entry.uniqueId + '-' + entry.prgFile); + const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; + const timeElapsedEl = entry.element.querySelector('#time-elapsed-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; if (trackProgressBar && statusData.progress !== undefined) { // Update track progress bar - const progress = parseFloat(statusData.progress); + const progress = parseFloat(statusData.progress as string); // Cast to string trackProgressBar.style.width = `${progress}%`; - trackProgressBar.setAttribute('aria-valuenow', progress); + trackProgressBar.setAttribute('aria-valuenow', progress.toString()); // Use string // Add success class when complete if (progress >= 100) { @@ -2137,12 +2314,13 @@ createQueueItem(item, type, prgFile, queueId) { } // Update progress for single track downloads - updateSingleTrackProgress(entry, statusData) { + updateSingleTrackProgress(entry: QueueEntry, statusData: StatusData) { // Add types // Get track progress bar and other UI elements - const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile); - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); - const titleElement = entry.element.querySelector('.title'); - const artistElement = entry.element.querySelector('.artist'); + const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; + const titleElement = entry.element.querySelector('.title') as HTMLElement | null; + const artistElement = entry.element.querySelector('.artist') as HTMLElement | null; + let progress = 0; // Declare progress here // If this track has a parent, this is actually part of an album/playlist // We should update the entry type and handle it as a multi-track download @@ -2154,7 +2332,7 @@ createQueueItem(item, type, prgFile, queueId) { entry.type = statusData.parent.type; // Update UI to reflect the parent type - const typeEl = entry.element.querySelector('.type'); + const typeEl = entry.element.querySelector('.type') as HTMLElement | null; if (typeEl) { const displayType = entry.type.charAt(0).toUpperCase() + entry.type.slice(1); typeEl.textContent = displayType; @@ -2186,7 +2364,7 @@ createQueueItem(item, type, prgFile, queueId) { } // For individual track downloads, show the parent context if available - if (!['done', 'complete', 'error', 'skipped'].includes(statusData.status)) { + if (!['done', 'complete', 'error', 'skipped'].includes(statusData.status || '')) { // Add null check // First check if we have parent data in the current status update if (statusData.parent && logElement) { // Store parent info in the entry for persistence across refreshes @@ -2219,20 +2397,20 @@ createQueueItem(item, type, prgFile, queueId) { } // Calculate progress based on available data - let progress = 0; + progress = 0; // Real-time progress for direct track download if (statusData.status === 'real-time' && statusData.progress !== undefined) { - progress = parseFloat(statusData.progress); + progress = parseFloat(statusData.progress as string); // Cast to string } else if (statusData.percent !== undefined) { - progress = parseFloat(statusData.percent) * 100; + progress = parseFloat(statusData.percent as string) * 100; // Cast to string } else if (statusData.percentage !== undefined) { - progress = parseFloat(statusData.percentage) * 100; + progress = parseFloat(statusData.percentage as string) * 100; // Cast to string } else if (statusData.status === 'done' || statusData.status === 'complete') { progress = 100; } else if (statusData.current_track && statusData.total_tracks) { // If we don't have real-time progress but do have track position - progress = (parseInt(statusData.current_track, 10) / parseInt(statusData.total_tracks, 10)) * 100; + progress = (parseInt(statusData.current_track as string, 10) / parseInt(statusData.total_tracks as string, 10)) * 100; // Cast to string } // Update track progress bar if available @@ -2241,10 +2419,10 @@ createQueueItem(item, type, prgFile, queueId) { const safeProgress = isNaN(progress) ? 0 : Math.max(0, Math.min(100, progress)); trackProgressBar.style.width = `${safeProgress}%`; - trackProgressBar.setAttribute('aria-valuenow', safeProgress); + trackProgressBar.setAttribute('aria-valuenow', safeProgress.toString()); // Use string // Make sure progress bar is visible - const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.prgFile); + const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; if (trackProgressContainer) { trackProgressContainer.style.display = 'block'; } @@ -2259,14 +2437,15 @@ createQueueItem(item, type, prgFile, queueId) { } // Update progress for multi-track downloads (albums and playlists) - updateMultiTrackProgress(entry, statusData) { + updateMultiTrackProgress(entry: QueueEntry, statusData: StatusData) { // Add types // Get progress elements - const progressCounter = document.getElementById(`progress-count-${entry.uniqueId}-${entry.prgFile}`); - const overallProgressBar = document.getElementById(`overall-bar-${entry.uniqueId}-${entry.prgFile}`); - const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile); - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); - const titleElement = entry.element.querySelector('.title'); - const artistElement = entry.element.querySelector('.artist'); + const progressCounter = document.getElementById(`progress-count-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; + const overallProgressBar = document.getElementById(`overall-bar-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; + const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; + const titleElement = entry.element.querySelector('.title') as HTMLElement | null; + const artistElement = entry.element.querySelector('.artist') as HTMLElement | null; + let progress = 0; // Declare progress here for this function's scope // Initialize track progress variables let currentTrack = 0; @@ -2299,13 +2478,13 @@ createQueueItem(item, type, prgFile, queueId) { // Get current track and total tracks from the status data if (statusData.current_track !== undefined) { - currentTrack = parseInt(statusData.current_track, 10); + currentTrack = parseInt(String(statusData.current_track), 10); // Get total tracks - try from statusData first, then from parent if (statusData.total_tracks !== undefined) { - totalTracks = parseInt(statusData.total_tracks, 10); + totalTracks = parseInt(String(statusData.total_tracks), 10); } else if (statusData.parent && statusData.parent.total_tracks !== undefined) { - totalTracks = parseInt(statusData.parent.total_tracks, 10); + totalTracks = parseInt(String(statusData.parent.total_tracks), 10); } console.log(`Track info: ${currentTrack}/${totalTracks}`); @@ -2313,7 +2492,7 @@ createQueueItem(item, type, prgFile, queueId) { // Get track progress for real-time updates if (statusData.status === 'real-time' && statusData.progress !== undefined) { - trackProgress = parseFloat(statusData.progress); + trackProgress = parseFloat(statusData.progress as string); // Cast to string } // Update the track progress counter display @@ -2348,7 +2527,7 @@ createQueueItem(item, type, prgFile, queueId) { if (overallProgressBar) { const safeProgress = Math.max(0, Math.min(100, overallProgress)); overallProgressBar.style.width = `${safeProgress}%`; - overallProgressBar.setAttribute('aria-valuenow', safeProgress); + overallProgressBar.setAttribute('aria-valuenow', safeProgress.toString()); // Use string if (safeProgress >= 100) { overallProgressBar.classList.add('complete'); @@ -2360,7 +2539,7 @@ createQueueItem(item, type, prgFile, queueId) { // Update the track-level progress bar if (trackProgressBar) { // Make sure progress bar container is visible - const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.prgFile); + const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; if (trackProgressContainer) { trackProgressContainer.style.display = 'block'; } @@ -2369,7 +2548,7 @@ createQueueItem(item, type, prgFile, queueId) { // Real-time progress for the current track const safeTrackProgress = Math.max(0, Math.min(100, trackProgress)); trackProgressBar.style.width = `${safeTrackProgress}%`; - trackProgressBar.setAttribute('aria-valuenow', safeTrackProgress); + trackProgressBar.setAttribute('aria-valuenow', safeTrackProgress.toString()); // Use string trackProgressBar.classList.add('real-time'); if (safeTrackProgress >= 100) { @@ -2381,7 +2560,7 @@ createQueueItem(item, type, prgFile, queueId) { // Indeterminate progress animation for non-real-time updates trackProgressBar.classList.add('progress-pulse'); trackProgressBar.style.width = '100%'; - trackProgressBar.setAttribute('aria-valuenow', 50); + trackProgressBar.setAttribute('aria-valuenow', "50"); // Use string } } @@ -2412,12 +2591,12 @@ createQueueItem(item, type, prgFile, queueId) { // Extract track counting data from status data if (statusData.current_track && statusData.total_tracks) { - currentTrack = parseInt(statusData.current_track, 10); - totalTracks = parseInt(statusData.total_tracks, 10); + currentTrack = parseInt(statusData.current_track as string, 10); // Cast to string + totalTracks = parseInt(statusData.total_tracks as string, 10); // Cast to string } else if (statusData.parsed_current_track && statusData.parsed_total_tracks) { - currentTrack = parseInt(statusData.parsed_current_track, 10); - totalTracks = parseInt(statusData.parsed_total_tracks, 10); - } else if (statusData.current_track && /^\d+\/\d+$/.test(statusData.current_track)) { + currentTrack = parseInt(statusData.parsed_current_track as string, 10); // Cast to string + totalTracks = parseInt(statusData.parsed_total_tracks as string, 10); // Cast to string + } else if (statusData.current_track && typeof statusData.current_track === 'string' && /^\d+\/\d+$/.test(statusData.current_track)) { // Add type check // Parse formats like "1/12" const parts = statusData.current_track.split('/'); currentTrack = parseInt(parts[0], 10); @@ -2427,13 +2606,18 @@ createQueueItem(item, type, prgFile, queueId) { // Get track progress for real-time downloads if (statusData.status === 'real-time' && statusData.progress !== undefined) { // For real-time downloads, progress comes as a percentage value (0-100) - trackProgress = parseFloat(statusData.progress); + trackProgress = parseFloat(statusData.progress as string); // Cast to string } else if (statusData.percent !== undefined) { // Handle percent values (0-1) - trackProgress = parseFloat(statusData.percent) * 100; + trackProgress = parseFloat(statusData.percent as string) * 100; // Cast to string } else if (statusData.percentage !== undefined) { // Handle percentage values (0-1) - trackProgress = parseFloat(statusData.percentage) * 100; + trackProgress = parseFloat(statusData.percentage as string) * 100; // Cast to string + } else if (statusData.status === 'done' || statusData.status === 'complete') { + progress = 100; + } else if (statusData.current_track && statusData.total_tracks) { + // If we don't have real-time progress but do have track position + progress = (parseInt(statusData.current_track as string, 10) / parseInt(statusData.total_tracks as string, 10)) * 100; // Cast to string } // Update progress counter if available @@ -2446,7 +2630,7 @@ createQueueItem(item, type, prgFile, queueId) { if (totalTracks > 0) { // Use explicit overall_progress if provided if (statusData.overall_progress !== undefined) { - overallProgress = parseFloat(statusData.overall_progress); + overallProgress = statusData.overall_progress; // overall_progress is number } else if (trackProgress !== undefined) { // For both real-time and standard multi-track downloads, use same formula const completedTracksProgress = (currentTrack - 1) / totalTracks; @@ -2462,7 +2646,7 @@ createQueueItem(item, type, prgFile, queueId) { // Ensure progress is between 0-100 const safeProgress = Math.max(0, Math.min(100, overallProgress)); overallProgressBar.style.width = `${safeProgress}%`; - overallProgressBar.setAttribute('aria-valuenow', safeProgress); + overallProgressBar.setAttribute('aria-valuenow', String(safeProgress)); // Add success class when complete if (safeProgress >= 100) { @@ -2475,7 +2659,7 @@ createQueueItem(item, type, prgFile, queueId) { // Update track progress bar for current track in multi-track items if (trackProgressBar) { // Make sure progress bar container is visible - const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.prgFile); + const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; if (trackProgressContainer) { trackProgressContainer.style.display = 'block'; } @@ -2485,7 +2669,7 @@ createQueueItem(item, type, prgFile, queueId) { // This shows download progress for the current track only const safeProgress = isNaN(trackProgress) ? 0 : Math.max(0, Math.min(100, trackProgress)); trackProgressBar.style.width = `${safeProgress}%`; - trackProgressBar.setAttribute('aria-valuenow', safeProgress); + trackProgressBar.setAttribute('aria-valuenow', String(safeProgress)); trackProgressBar.classList.add('real-time'); if (safeProgress >= 100) { @@ -2493,18 +2677,18 @@ createQueueItem(item, type, prgFile, queueId) { } else { trackProgressBar.classList.remove('complete'); } - } else if (['progress', 'processing'].includes(statusData.status)) { + } else if (['progress', 'processing'].includes(statusData.status || '')) { // For non-real-time progress updates, show an indeterminate-style progress // by using a pulsing animation via CSS trackProgressBar.classList.add('progress-pulse'); trackProgressBar.style.width = '100%'; - trackProgressBar.setAttribute('aria-valuenow', 50); // indicate in-progress + trackProgressBar.setAttribute('aria-valuenow', String(50)); // indicate in-progress } else { // For other status updates, use current track position trackProgressBar.classList.remove('progress-pulse'); const trackPositionPercent = currentTrack > 0 ? 100 : 0; trackProgressBar.style.width = `${trackPositionPercent}%`; - trackProgressBar.setAttribute('aria-valuenow', trackPositionPercent); + trackProgressBar.setAttribute('aria-valuenow', String(trackPositionPercent)); } } @@ -2522,4 +2706,4 @@ createQueueItem(item, type, prgFile, queueId) { } // Singleton instance -export const downloadQueue = new DownloadQueue(); +export const downloadQueue = new DownloadQueue(); \ No newline at end of file diff --git a/static/js/track.js b/static/js/track.ts similarity index 71% rename from static/js/track.js rename to static/js/track.ts index f62696e..044b3dc 100644 --- a/static/js/track.js +++ b/static/js/track.ts @@ -35,15 +35,18 @@ document.addEventListener('DOMContentLoaded', () => { /** * Renders the track header information. */ -function renderTrack(track) { +function renderTrack(track: any) { // Hide the loading and error messages. - document.getElementById('loading').classList.add('hidden'); - document.getElementById('error').classList.add('hidden'); + const loadingEl = document.getElementById('loading'); + if (loadingEl) loadingEl.classList.add('hidden'); + const errorEl = document.getElementById('error'); + if (errorEl) errorEl.classList.add('hidden'); // Check if track is explicit and if explicit filter is enabled if (track.explicit && downloadQueue.isExplicitFilterEnabled()) { // Show placeholder for explicit content - document.getElementById('loading').classList.add('hidden'); + const loadingElExplicit = document.getElementById('loading'); + if (loadingElExplicit) loadingElExplicit.classList.add('hidden'); const placeholderContent = `
@@ -63,30 +66,46 @@ function renderTrack(track) { } // Update track information fields. - document.getElementById('track-name').innerHTML = - `${track.name || 'Unknown Track'}`; + const trackNameEl = document.getElementById('track-name'); + if (trackNameEl) { + trackNameEl.innerHTML = + `${track.name || 'Unknown Track'}`; + } - document.getElementById('track-artist').innerHTML = - `By ${track.artists?.map(a => - `${a?.name || 'Unknown Artist'}` - ).join(', ') || 'Unknown Artist'}`; + const trackArtistEl = document.getElementById('track-artist'); + if (trackArtistEl) { + trackArtistEl.innerHTML = + `By ${track.artists?.map((a: any) => + `${a?.name || 'Unknown Artist'}` + ).join(', ') || 'Unknown Artist'}`; + } - document.getElementById('track-album').innerHTML = - `Album: ${track.album?.name || 'Unknown Album'} (${track.album?.album_type || 'album'})`; + const trackAlbumEl = document.getElementById('track-album'); + if (trackAlbumEl) { + trackAlbumEl.innerHTML = + `Album: ${track.album?.name || 'Unknown Album'} (${track.album?.album_type || 'album'})`; + } - document.getElementById('track-duration').textContent = - `Duration: ${msToTime(track.duration_ms || 0)}`; + const trackDurationEl = document.getElementById('track-duration'); + if (trackDurationEl) { + trackDurationEl.textContent = + `Duration: ${msToTime(track.duration_ms || 0)}`; + } - document.getElementById('track-explicit').textContent = - track.explicit ? 'Explicit' : 'Clean'; + const trackExplicitEl = document.getElementById('track-explicit'); + if (trackExplicitEl) { + trackExplicitEl.textContent = + track.explicit ? 'Explicit' : 'Clean'; + } const imageUrl = (track.album?.images && track.album.images[0]) ? track.album.images[0].url : '/static/images/placeholder.jpg'; - document.getElementById('track-album-image').src = imageUrl; + const trackAlbumImageEl = document.getElementById('track-album-image') as HTMLImageElement; + if (trackAlbumImageEl) trackAlbumImageEl.src = imageUrl; // --- Insert Home Button (if not already present) --- - let homeButton = document.getElementById('homeButton'); + let homeButton = document.getElementById('homeButton') as HTMLButtonElement; if (!homeButton) { homeButton = document.createElement('button'); homeButton.id = 'homeButton'; @@ -103,7 +122,7 @@ function renderTrack(track) { }); // --- Move the Download Button from #actions into #track-header --- - let downloadBtn = document.getElementById('downloadTrackBtn'); + let downloadBtn = document.getElementById('downloadTrackBtn') as HTMLButtonElement; if (downloadBtn) { // Remove the parent container (#actions) if needed. const actionsContainer = document.getElementById('actions'); @@ -139,7 +158,7 @@ function renderTrack(track) { // Make the queue visible to show the download downloadQueue.toggleVisibility(true); }) - .catch(err => { + .catch((err: any) => { showError('Failed to queue track download: ' + (err?.message || 'Unknown error')); downloadBtn.disabled = false; downloadBtn.innerHTML = `Download`; @@ -148,13 +167,14 @@ function renderTrack(track) { } // Reveal the header now that track info is loaded. - document.getElementById('track-header').classList.remove('hidden'); + const trackHeaderEl = document.getElementById('track-header'); + if (trackHeaderEl) trackHeaderEl.classList.remove('hidden'); } /** * Converts milliseconds to minutes:seconds. */ -function msToTime(duration) { +function msToTime(duration: number) { if (!duration || isNaN(duration)) return '0:00'; const minutes = Math.floor(duration / 60000); @@ -165,7 +185,7 @@ function msToTime(duration) { /** * Displays an error message in the UI. */ -function showError(message) { +function showError(message: string) { const errorEl = document.getElementById('error'); if (errorEl) { errorEl.textContent = message || 'An error occurred'; @@ -176,7 +196,7 @@ function showError(message) { /** * Starts the download process by calling the centralized downloadQueue method */ -async function startDownload(url, type, item) { +async function startDownload(url: string, type: string, item: any) { if (!url || !type) { showError('Missing URL or type for download'); return; @@ -188,7 +208,7 @@ async function startDownload(url, type, item) { // Make the queue visible after queueing downloadQueue.toggleVisibility(true); - } catch (error) { + } catch (error: any) { showError('Download failed: ' + (error?.message || 'Unknown error')); throw error; } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..34db1e4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "es2017", // Specify ECMAScript target version + "module": "ES2020", // Specify module code generation + "strict": true, // Enable all strict type-checking options + "noImplicitAny": false, // Allow implicit 'any' types + "esModuleInterop": true, // Enables emit interoperability between CommonJS and ES Modules + "skipLibCheck": true, // Skip type checking of declaration files + "forceConsistentCasingInFileNames": true // Disallow inconsistently-cased references to the same file. + }, + "include": [ + "static/js/**/*.ts" // Specifies the TypeScript files to be included in compilation + ], + "exclude": [ + "node_modules" // Specifies an array of filenames or patterns that should be skipped when resolving include. + ] +} \ No newline at end of file