From 5d15532d42010e2380e4537f55edfe8dbfb64f36 Mon Sep 17 00:00:00 2001 From: "architect.in.git" Date: Thu, 24 Apr 2025 19:52:23 -0600 Subject: [PATCH 01/18] Improve bug template --- .github/ISSUE_TEMPLATE/bug_report.md | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 575b0f0..79f6a27 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -12,11 +12,13 @@ A clear and concise description of what the bug is. **To Reproduce** Precise steps to reproduce the behavior (start from how you built your container): -1. Go to '...' -2. Click on '....' +1. Search for '...' +2. Download album/track/playlist 'https://open.spotify.com/...' 3. Scroll down to '....' 4. See error +*Note: Sometimes, an error is specific to an album, track or playlist, so preferrably share the specific url of the album you downloaded* + **Expected behavior** A clear and concise description of what you expected to happen. @@ -31,11 +33,20 @@ If applicable, add screenshots to help explain your problem. ``` Paste it here ``` + +**.env** +``` +Paste it here +``` + +**Config** + +- You can either share a screenshot of the config page or, preferably, the config file (should be under `./config/main.json`, depending on where you mapped it on your docker-compose.yaml) + **Logs** ``` Preferably, restart the app before reproducing so you can paste the logs from the bare beginning ``` -**Image** -Run -```docker container ls --format "{{.Names}}: {{.Image}}"``` and share the relevant output (e.g. spotizerr: cooldockerizer93/spotizerr:latest) \ No newline at end of file +**Version** +Go to config page and look for the version number \ No newline at end of file From b6ff994047e108c0a16c4dec781e0df55f7c34a6 Mon Sep 17 00:00:00 2001 From: "cool.gitter.not.me.again.duh" Date: Mon, 26 May 2025 14:44:30 -0600 Subject: [PATCH 02/18] Solved issue #114 --- routes/utils/celery_manager.py | 68 +++++++ static/js/queue.js | 350 +++++++++++++++++++-------------- 2 files changed, 272 insertions(+), 146 deletions(-) diff --git a/routes/utils/celery_manager.py b/routes/utils/celery_manager.py index 24aeeba..ea140ad 100644 --- a/routes/utils/celery_manager.py +++ b/routes/utils/celery_manager.py @@ -11,6 +11,16 @@ import queue import sys import uuid +# Import Celery task utilities +from .celery_tasks import ( + ProgressState, + get_task_info, + get_last_task_status, + store_task_status, + get_all_tasks as get_all_celery_tasks_info +) +from .celery_config import get_config_params + # Configure logging logger = logging.getLogger(__name__) @@ -33,6 +43,61 @@ class CeleryManager: self.log_queue = queue.Queue() self.output_threads = [] + def _cleanup_stale_tasks(self): + logger.info("Cleaning up potentially stale Celery tasks...") + try: + tasks = get_all_celery_tasks_info() + if not tasks: + logger.info("No tasks found in Redis to check for staleness.") + return + + active_stale_states = [ + ProgressState.PROCESSING, + ProgressState.INITIALIZING, + ProgressState.DOWNLOADING, + ProgressState.PROGRESS, + ProgressState.REAL_TIME, + ProgressState.RETRYING + ] + + stale_tasks_count = 0 + for task_summary in tasks: + task_id = task_summary.get("task_id") + if not task_id: + continue + + last_status_data = get_last_task_status(task_id) + if last_status_data: + current_status_str = last_status_data.get("status") + if current_status_str in active_stale_states: + logger.warning(f"Task {task_id} ('{task_summary.get('name', 'Unknown')}') found in stale state '{current_status_str}'. Marking as error.") + + task_info_details = get_task_info(task_id) + config = get_config_params() + + error_payload = { + "status": ProgressState.ERROR, + "message": "Task interrupted due to application restart.", + "error": "Task interrupted due to application restart.", + "timestamp": time.time(), + "type": task_info_details.get("type", task_summary.get("type", "unknown")), + "name": task_info_details.get("name", task_summary.get("name", "Unknown")), + "artist": task_info_details.get("artist", task_summary.get("artist", "")), + "can_retry": True, + "retry_count": last_status_data.get("retry_count", 0), + "max_retries": config.get('maxRetries', 3) + } + store_task_status(task_id, error_payload) + stale_tasks_count += 1 + + if stale_tasks_count > 0: + logger.info(f"Marked {stale_tasks_count} stale tasks as 'error'.") + else: + logger.info("No stale tasks found that needed cleanup.") + + except Exception as e: + logger.error(f"Error during stale task cleanup: {e}", exc_info=True) + def start(self): """Start the Celery manager and initial workers""" if self.running: @@ -40,6 +105,9 @@ class CeleryManager: self.running = True + # Clean up stale tasks BEFORE starting/restarting workers + self._cleanup_stale_tasks() + # Start initial workers self._update_workers() diff --git a/static/js/queue.js b/static/js/queue.js index d082674..e7d6760 100644 --- a/static/js/queue.js +++ b/static/js/queue.js @@ -444,7 +444,8 @@ class DownloadQueue { isNew: true, // Add flag to track if this is a new entry status: 'initializing', lastMessage: `Initializing ${type} download...`, - parentInfo: null // Will store parent data for tracks that are part of albums/playlists + parentInfo: null, // Will store parent data for tracks that are part of albums/playlists + realTimeStallDetector: { count: 0, lastStatusJson: '' } // For detecting stalled real_time downloads }; // If cached info exists for this PRG file, use it. @@ -1149,23 +1150,22 @@ createQueueItem(item, type, prgFile, queueId) { async retryDownload(queueId, logElement) { const entry = this.queueEntries[queueId]; - if (!entry) return; - - // Hide any existing error-details and restore log for retry - const errContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`); - if (errContainer) { errContainer.style.display = 'none'; } - logElement.style.display = ''; - - // Mark the entry as retrying to prevent automatic cleanup - entry.isRetrying = true; - logElement.textContent = 'Retrying download...'; - - // Determine if we should use parent information for retry + if (!entry) { + console.warn(`Retry called for non-existent queueId: ${queueId}`); + return; + } + + // 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'); + + 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; - - // Check if we have parent information in the lastStatus if (entry.lastStatus && entry.lastStatus.parent) { const parent = entry.lastStatus.parent; if (parent.type && parent.url) { @@ -1175,125 +1175,110 @@ createQueueItem(item, type, prgFile, queueId) { console.log(`Using parent info for retry: ${parentType} with URL: ${parentUrl}`); } } - - // Find a retry URL from various possible sources + const getRetryUrl = () => { - // Prefer full original URL from progress API - if (entry.lastStatus && entry.lastStatus.original_url) { - return entry.lastStatus.original_url; - } - // If using parent, return parent URL - if (useParent && parentUrl) { - return parentUrl; - } - - // Otherwise use the standard fallback options + if (entry.lastStatus && entry.lastStatus.original_url) return entry.lastStatus.original_url; + if (useParent && parentUrl) return parentUrl; if (entry.requestUrl) return entry.requestUrl; - - // If we have lastStatus with original_request, check there if (entry.lastStatus && entry.lastStatus.original_request) { - if (entry.lastStatus.original_request.retry_url) - return entry.lastStatus.original_request.retry_url; - if (entry.lastStatus.original_request.url) - return entry.lastStatus.original_request.url; + if (entry.lastStatus.original_request.retry_url) return entry.lastStatus.original_request.retry_url; + if (entry.lastStatus.original_request.url) return entry.lastStatus.original_request.url; } - - // Check if there's a URL directly in the lastStatus - if (entry.lastStatus && entry.lastStatus.url) - return entry.lastStatus.url; - - // Fallback to stored requestUrl - if (entry.requestUrl) { - return entry.requestUrl; - } - + if (entry.lastStatus && entry.lastStatus.url) return entry.lastStatus.url; return null; }; - + const retryUrl = getRetryUrl(); - - // If we don't have any retry URL, show error + if (!retryUrl) { - logElement.textContent = 'Retry not available: missing URL information.'; - entry.isRetrying = false; // Reset retrying flag + if (errorMessageDiv) errorMessageDiv.textContent = 'Retry not available: missing URL information.'; + entry.isRetrying = false; + if (retryBtn) { + retryBtn.disabled = false; + retryBtn.innerHTML = 'Retry'; // Reset button text + } return; } - - try { - // Close any existing polling interval - this.clearPollingInterval(queueId); - - // Determine which type to use for the API endpoint - const apiType = useParent ? parentType : entry.type; - console.log(`Retrying download using type: ${apiType} with URL: ${retryUrl}`); - - // Determine request URL: if retryUrl is already a full API URL, use it directly - let fullRetryUrl; - if (retryUrl.startsWith('http')) { - fullRetryUrl = retryUrl; - } else { - const apiUrl = `/api/${apiType}/download?url=${encodeURIComponent(retryUrl)}`; - fullRetryUrl = apiUrl; - // Append metadata if retryUrl is raw resource URL - if (entry.item && entry.item.name) { - fullRetryUrl += `&name=${encodeURIComponent(entry.item.name)}`; - } - if (entry.item && entry.item.artist) { - fullRetryUrl += `&artist=${encodeURIComponent(entry.item.artist)}`; - } - } - // Use the stored original request URL to create a new download + // Store details needed for the new entry BEFORE any async operations + const originalItem = { ...entry.item }; // Shallow copy + const apiTypeForNewEntry = useParent ? parentType : entry.type; + console.log(`Retrying download using type: ${apiTypeForNewEntry} with base URL: ${retryUrl}`); + + let fullRetryUrl; + if (retryUrl.startsWith('http') || retryUrl.startsWith('/api/')) { // if it's already a full URL or an API path + fullRetryUrl = retryUrl; + } 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 + if (originalItem && originalItem.name) { + fullRetryUrl += `&name=${encodeURIComponent(originalItem.name)}`; + } + if (originalItem && originalItem.artist) { + fullRetryUrl += `&artist=${encodeURIComponent(originalItem.artist)}`; + } + } + const requestUrlForNewEntry = fullRetryUrl; + + try { + // Clear polling for the old entry before making the request + this.clearPollingInterval(queueId); + const retryResponse = await fetch(fullRetryUrl); if (!retryResponse.ok) { - throw new Error(`Server returned ${retryResponse.status}`); + const errorText = await retryResponse.text(); + throw new Error(`Server returned ${retryResponse.status}${errorText ? (': ' + errorText) : ''}`); } - + const retryData = await retryResponse.json(); - + if (retryData.prg_file) { - // Store the old PRG file for cleanup - const oldPrgFile = entry.prgFile; + 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); - // Update the entry with the new PRG file - const logEl = entry.element.querySelector('.log'); - logEl.id = `log-${entry.uniqueId}-${retryData.prg_file}`; - entry.prgFile = retryData.prg_file; - entry.lastStatus = null; - entry.hasEnded = false; - entry.lastUpdated = Date.now(); - entry.retryCount = (entry.retryCount || 0) + 1; - entry.statusCheckFailures = 0; // Reset failure counter - logEl.textContent = 'Retry initiated...'; - - // Make sure any existing interval is cleared - if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; - } - - // Set up a new polling interval for the retried download - this.setupPollingInterval(queueId); - - // Delete the old PRG file after a short delay to ensure the new one is properly set up - if (oldPrgFile) { - setTimeout(async () => { - try { - await fetch(`/api/prgs/delete/${oldPrgFile}`, { method: 'DELETE' }); - console.log(`Cleaned up old PRG file: ${oldPrgFile}`); - } catch (deleteError) { - console.error('Error deleting old PRG file:', deleteError); - } - }, 2000); // Wait 2 seconds before deleting the old file - } + // The old setTimeout block for deleting oldPrgFile is no longer needed as cleanupEntry handles it. } else { - logElement.textContent = 'Retry failed: invalid response from server'; - entry.isRetrying = false; // Reset retrying flag + if (errorMessageDiv) errorMessageDiv.textContent = 'Retry failed: invalid response from server.'; + const currentEntry = this.queueEntries[queueId]; // Check if old entry still exists + if (currentEntry) { + currentEntry.isRetrying = false; + } + if (retryBtn) { + retryBtn.disabled = false; + retryBtn.innerHTML = 'Retry'; + } } } catch (error) { console.error('Retry error:', error); - logElement.textContent = 'Retry failed: ' + error.message; - entry.isRetrying = false; // Reset retrying flag + // The old entry might still be in the DOM if cleanupEntry wasn't called or failed. + 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; + + if (errorDivOnFailedEntry) errorDivOnFailedEntry.textContent = 'Retry failed: ' + error.message; + stillExistingEntry.isRetrying = false; + if (retryButtonOnFailedEntry) { + retryButtonOnFailedEntry.disabled = false; + retryButtonOnFailedEntry.innerHTML = 'Retry'; + } + } 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; + if (retryBtn) { + retryBtn.disabled = false; + retryBtn.innerHTML = 'Retry'; + } + } } } @@ -1892,10 +1877,54 @@ createQueueItem(item, type, prgFile, queueId) { } // Get primary status - const status = statusData.status || data.event || 'unknown'; + let status = statusData.status || data.event || 'unknown'; // Define status *before* potential modification + + // Stall detection for 'real_time' status + if (status === 'real_time') { + entry.realTimeStallDetector = entry.realTimeStallDetector || { count: 0, lastStatusJson: '' }; + const detector = entry.realTimeStallDetector; + + const currentMetrics = { + progress: statusData.progress, + time_elapsed: statusData.time_elapsed, + // For multi-track items, current_track is a key indicator of activity + current_track: (entry.type === 'album' || entry.type === 'playlist') ? statusData.current_track : undefined, + // Include other relevant fields if they signify activity, e.g., speed, eta + // For example, if statusData.song changes for an album, that's progress. + song: statusData.song + }; + const currentMetricsJson = JSON.stringify(currentMetrics); + + // Check if significant metrics are present and static + if (detector.lastStatusJson === currentMetricsJson && + (currentMetrics.progress !== undefined || currentMetrics.time_elapsed !== undefined || currentMetrics.current_track !== undefined || currentMetrics.song !== undefined)) { + // Metrics are present and haven't changed + detector.count++; + } else { + // Metrics changed, or this is the first time seeing them, or no metrics to compare (e.g. empty object from server) + detector.count = 0; + // Only update lastStatusJson if currentMetricsJson represents actual data, not an empty object if that's possible + if (currentMetricsJson !== '{}' || detector.lastStatusJson === '') { // Avoid replacing actual old data with '{}' if new data is sparse + detector.lastStatusJson = currentMetricsJson; + } + } + + const STALL_THRESHOLD = 600; // Approx 5 minutes (600 polls * 0.5s/poll) + if (detector.count >= STALL_THRESHOLD) { + console.warn(`Download ${queueId} (${entry.prgFile}) appears stalled in real_time state. Metrics: ${detector.lastStatusJson}. Stall count: ${detector.count}. Forcing error.`); + statusData.status = 'error'; + statusData.error = 'Download stalled (no progress updates for 5 minutes)'; + statusData.can_retry = true; // Allow manual retry for stalled items + status = 'error'; // Update local status variable for current execution scope + + // Reset detector for this entry in case of retry + detector.count = 0; + detector.lastStatusJson = ''; + } + } // Store the status data for potential retries - entry.lastStatus = statusData; + entry.lastStatus = statusData; // This now stores the potentially modified statusData (e.g., status changed to 'error') entry.lastUpdated = Date.now(); // Update type if needed - could be more specific now (e.g., from 'album' to 'compilation') @@ -1947,46 +1976,75 @@ createQueueItem(item, type, prgFile, queueId) { const cancelBtn = entry.element.querySelector('.cancel-btn'); if (cancelBtn) cancelBtn.style.display = 'none'; + // Hide progress bars for errored items + const trackProgressContainer = entry.element.querySelector(`#track-progress-container-${entry.uniqueId}-${entry.prgFile}`); + if (trackProgressContainer) trackProgressContainer.style.display = 'none'; + const overallProgressContainer = entry.element.querySelector('.overall-progress-container'); + if (overallProgressContainer) overallProgressContainer.style.display = 'none'; + // Hide time elapsed for errored items + const timeElapsedContainer = entry.element.querySelector(`#time-elapsed-${entry.uniqueId}-${entry.prgFile}`); + if (timeElapsedContainer) timeElapsedContainer.style.display = 'none'; + // Extract error details - const errMsg = statusData.error; - const canRetry = Boolean(statusData.can_retry) && statusData.retry_count < statusData.max_retries; - // Determine retry URL + const errMsg = statusData.error || 'An unknown error occurred.'; // Ensure errMsg is a string + // const canRetry = Boolean(statusData.can_retry) && statusData.retry_count < statusData.max_retries; // This logic is implicitly handled by retry button availability const retryUrl = data.original_url || data.original_request?.url || entry.requestUrl || null; if (retryUrl) { - entry.requestUrl = retryUrl; + entry.requestUrl = retryUrl; // Store for retry logic } - console.log(`Error for ${entry.type} download. Can retry: ${canRetry}. Retry URL: ${retryUrl}`); + 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) { - // Build error UI with manual retry always available - logElement.innerHTML = ` -
${errMsg}
-
- - -
- `; - // Close handler - logElement.querySelector('.close-error-btn').addEventListener('click', () => { - this.cleanupEntry(queueId); - }); - // Always attach manual retry handler - const retryBtn = logElement.querySelector('.retry-btn'); - retryBtn.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - retryBtn.disabled = true; - retryBtn.innerHTML = ' Retrying...'; - this.retryDownload(queueId, logElement); - }); - // Auto cleanup after 15s - setTimeout(() => { - if (this.queueEntries[queueId]?.hasEnded) { - this.cleanupEntry(queueId); + let errorMessageElement = logElement.querySelector('.error-message'); + + 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 + + // Attach listeners ONLY when creating the buttons + const closeErrorBtn = logElement.querySelector('.close-error-btn'); + if (closeErrorBtn) { + closeErrorBtn.addEventListener('click', () => { + this.cleanupEntry(queueId); + }); } - }, 15000); + + const retryBtnElem = logElement.querySelector('.retry-btn'); + if (retryBtnElem) { + retryBtnElem.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + retryBtnElem.disabled = true; + retryBtnElem.innerHTML = ' Retrying...'; + this.retryDownload(queueId, logElement); + }); + } + + // Auto cleanup after 15s - only set this timeout once when error UI is first built + setTimeout(() => { + const currentEntryForCleanup = this.queueEntries[queueId]; + if (currentEntryForCleanup && + currentEntryForCleanup.hasEnded && + currentEntryForCleanup.lastStatus?.status === 'error' && + !currentEntryForCleanup.isRetrying) { + this.cleanupEntry(queueId); + } + }, 15000); + + } else { // Error UI already exists, just update the message text if it's different + if (errorMessageElement.textContent !== errMsg) { + errorMessageElement.textContent = errMsg; + } + } } } From 62d1e91a02460d14e62a7d0b3b5dae6f280e0a57 Mon Sep 17 00:00:00 2001 From: "cool.gitter.not.me.again.duh" Date: Mon, 26 May 2025 19:52:26 -0600 Subject: [PATCH 03/18] it was time to move on... --- static/js/album.js | 282 --------- static/js/album.ts | 372 +++++++++++ static/js/artist.js | 381 ----------- static/js/artist.ts | 460 ++++++++++++++ static/js/config.js | 763 ---------------------- static/js/config.ts | 841 +++++++++++++++++++++++++ static/js/{main.js => main.ts} | 112 ++-- static/js/{playlist.js => playlist.ts} | 164 ++--- static/js/{queue.js => queue.ts} | 676 ++++++++++++-------- static/js/{track.js => track.ts} | 70 +- tsconfig.json | 17 + 11 files changed, 2315 insertions(+), 1823 deletions(-) delete mode 100644 static/js/album.js create mode 100644 static/js/album.ts delete mode 100644 static/js/artist.js create mode 100644 static/js/artist.ts delete mode 100644 static/js/config.js create mode 100644 static/js/config.ts rename static/js/{main.js => main.ts} (79%) rename static/js/{playlist.js => playlist.ts} (70%) rename static/js/{queue.js => queue.ts} (80%) rename static/js/{track.js => track.ts} (71%) create mode 100644 tsconfig.json 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.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 From 59370367bd5508bf9bab5c7fee3619a0630b0c26 Mon Sep 17 00:00:00 2001 From: "cool.gitter.not.me.again.duh" Date: Mon, 26 May 2025 20:44:25 -0600 Subject: [PATCH 04/18] polishing the edges --- .dockerignore | 21 +-- .gitignore | 3 +- Dockerfile | 10 ++ docker-compose.yaml | 2 +- {static => src}/js/album.ts | 0 {static => src}/js/artist.ts | 0 {static => src}/js/config.ts | 23 +++- {static => src}/js/main.ts | 227 +++++++++++++++++++++++++-------- {static => src}/js/playlist.ts | 97 ++++++++++++-- {static => src}/js/queue.ts | 12 +- {static => src}/js/track.ts | 0 tsconfig.json | 16 ++- 12 files changed, 313 insertions(+), 98 deletions(-) rename {static => src}/js/album.ts (100%) rename {static => src}/js/artist.ts (100%) rename {static => src}/js/config.ts (98%) rename {static => src}/js/main.ts (63%) rename {static => src}/js/playlist.ts (84%) rename {static => src}/js/queue.ts (99%) rename {static => src}/js/track.ts (100%) diff --git a/.dockerignore b/.dockerignore index c69773a..6c1fe0a 100755 --- a/.dockerignore +++ b/.dockerignore @@ -6,24 +6,10 @@ /Test.py /prgs/ /flask_server.log -/routes/__pycache__/ -routes/utils/__pycache__/ test.sh __pycache__/ -routes/__pycache__/__init__.cpython-312.pyc -routes/__pycache__/credentials.cpython-312.pyc -routes/__pycache__/search.cpython-312.pyc -routes/utils/__pycache__/__init__.cpython-312.pyc -routes/utils/__pycache__/credentials.cpython-312.pyc -routes/utils/__pycache__/search.cpython-312.pyc -routes/utils/__pycache__/__init__.cpython-312.pyc -routes/utils/__pycache__/credentials.cpython-312.pyc -routes/utils/__pycache__/search.cpython-312.pyc -routes/utils/__pycache__/credentials.cpython-312.pyc -routes/utils/__pycache__/search.cpython-312.pyc -routes/utils/__pycache__/__init__.cpython-312.pyc -routes/utils/__pycache__/credentials.cpython-312.pyc -routes/utils/__pycache__/search.cpython-312.pyc +routes/__pycache__/* +routes/utils/__pycache__/* search_test.py config/main.json .cache @@ -31,4 +17,5 @@ config/state/queue_state.json output.log queue_state.json search_demo.py -celery_worker.log \ No newline at end of file +celery_worker.log +static/js/* \ No newline at end of file diff --git a/.gitignore b/.gitignore index f53d8b2..351e496 100755 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,5 @@ queue_state.json search_demo.py celery_worker.log logs/spotizerr.log -/.venv \ No newline at end of file +/.venv +static/js diff --git a/Dockerfile b/Dockerfile index f53b86b..82659d4 100755 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ gosu \ git \ ffmpeg \ + nodejs \ + npm \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* @@ -22,6 +24,14 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy application code COPY . . +# Install TypeScript globally +RUN npm install -g typescript + +# Compile TypeScript +# tsc will use tsconfig.json from the current directory (/app) +# It will read from /app/src/js and output to /app/static/js +RUN tsc + # Create necessary directories with proper permissions RUN mkdir -p downloads config creds logs && \ chmod 777 downloads config creds logs diff --git a/docker-compose.yaml b/docker-compose.yaml index c0449af..a6d4ddb 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -9,7 +9,7 @@ services: - ./logs:/app/logs # <-- Volume for persistent logs ports: - 7171:7171 - image: cooldockerizer93/spotizerr:dev + image: test container_name: spotizerr-app restart: unless-stopped environment: diff --git a/static/js/album.ts b/src/js/album.ts similarity index 100% rename from static/js/album.ts rename to src/js/album.ts diff --git a/static/js/artist.ts b/src/js/artist.ts similarity index 100% rename from static/js/artist.ts rename to src/js/artist.ts diff --git a/static/js/config.ts b/src/js/config.ts similarity index 98% rename from static/js/config.ts rename to src/js/config.ts index 21a4840..2c13d56 100644 --- a/static/js/config.ts +++ b/src/js/config.ts @@ -1,12 +1,27 @@ import { downloadQueue } from './queue.js'; +// Interfaces for validator data +interface SpotifyValidatorData { + username: string; + credentials?: string; // Credentials might be optional if only username is used as an identifier +} + +interface SpotifySearchValidatorData { + client_id: string; + client_secret: string; +} + +interface DeezerValidatorData { + arl: string; +} + const serviceConfig: Record = { spotify: { fields: [ { id: 'username', label: 'Username', type: 'text' }, - { id: 'credentials', label: 'Credentials', type: 'text' } + { id: 'credentials', label: 'Credentials', type: 'text' } // Assuming this is password/token ], - validator: (data) => ({ + validator: (data: SpotifyValidatorData) => ({ // Typed data username: data.username, credentials: data.credentials }), @@ -15,7 +30,7 @@ const serviceConfig: Record = { { id: 'client_id', label: 'Client ID', type: 'text' }, { id: 'client_secret', label: 'Client Secret', type: 'password' } ], - searchValidator: (data) => ({ + searchValidator: (data: SpotifySearchValidatorData) => ({ // Typed data client_id: data.client_id, client_secret: data.client_secret }) @@ -24,7 +39,7 @@ const serviceConfig: Record = { fields: [ { id: 'arl', label: 'ARL', type: 'text' } ], - validator: (data) => ({ + validator: (data: DeezerValidatorData) => ({ // Typed data arl: data.arl }) } diff --git a/static/js/main.ts b/src/js/main.ts similarity index 63% rename from static/js/main.ts rename to src/js/main.ts index 9560186..14340d1 100644 --- a/static/js/main.ts +++ b/src/js/main.ts @@ -1,6 +1,75 @@ // main.ts import { downloadQueue } from './queue.js'; +// Define interfaces for API data and search results +interface Image { + url: string; + height?: number; + width?: number; +} + +interface Artist { + id?: string; // Artist ID might not always be present in search results for track artists + name: string; + external_urls?: { spotify?: string }; + genres?: string[]; // For artist type results +} + +interface Album { + id?: string; // Album ID might not always be present + name: string; + images?: Image[]; + album_type?: string; // Used in startDownload + artists?: Artist[]; // Album can have artists too + total_tracks?: number; + release_date?: string; + external_urls?: { spotify?: string }; +} + +interface Track { + id: string; + name: string; + artists: Artist[]; + album: Album; + duration_ms?: number; + explicit?: boolean; + external_urls: { spotify: string }; + href?: string; // Some spotify responses use href +} + +interface Playlist { + id: string; + name: string; + owner: { display_name?: string; id?: string }; + images?: Image[]; + tracks: { total: number }; // Simplified for search results + external_urls: { spotify: string }; + href?: string; // Some spotify responses use href + explicit?: boolean; // Playlists themselves aren't explicit, but items can be +} + +// Specific item types for search results +interface TrackResultItem extends Track {} +interface AlbumResultItem extends Album { id: string; images?: Image[]; explicit?: boolean; external_urls: { spotify: string }; href?: string; } +interface PlaylistResultItem extends Playlist {} +interface ArtistResultItem extends Artist { id: string; images?: Image[]; explicit?: boolean; external_urls: { spotify: string }; href?: string; followers?: { total: number }; } + +// Union type for any search result item +type SearchResultItem = TrackResultItem | AlbumResultItem | PlaylistResultItem | ArtistResultItem; + +// Interface for the API response structure +interface SearchResponse { + items: SearchResultItem[]; + // Add other top-level properties from the search API if needed (e.g., total, limit, offset) +} + +// Interface for the item passed to downloadQueue.download +interface DownloadQueueItem { + name: string; + artist?: string; + album?: { name: string; album_type?: string }; +} + document.addEventListener('DOMContentLoaded', function() { // DOM elements const searchInput = document.getElementById('searchInput') as HTMLInputElement | null; @@ -106,7 +175,7 @@ document.addEventListener('DOMContentLoaded', function() { throw new Error('Network response was not ok'); } - const data = await response.json(); + const data = await response.json() as SearchResponse; // Assert type for API response // Hide loading indicator showLoading(false); @@ -164,7 +233,7 @@ document.addEventListener('DOMContentLoaded', function() { /** * Filters out items with null/undefined essential display parameters based on search type */ - function filterValidItems(items: any[], type: string) { + function filterValidItems(items: SearchResultItem[], type: string): SearchResultItem[] { if (!items) return []; return items.filter(item => { @@ -172,59 +241,59 @@ document.addEventListener('DOMContentLoaded', function() { if (!item) return false; // Skip explicit content if filter is enabled - if (downloadQueue.isExplicitFilterEnabled() && item.explicit === true) { + if (downloadQueue.isExplicitFilterEnabled() && ('explicit' in item && item.explicit === true)) { return false; } // Check essential parameters based on search type switch (type) { case 'track': - // For tracks, we need name, artists, and album + const trackItem = item as TrackResultItem; return ( - item.name && - item.artists && - item.artists.length > 0 && - item.artists[0] && - item.artists[0].name && - item.album && - item.album.name && - item.external_urls && - item.external_urls.spotify + trackItem.name && + trackItem.artists && + trackItem.artists.length > 0 && + trackItem.artists[0] && + trackItem.artists[0].name && + trackItem.album && + trackItem.album.name && + trackItem.external_urls && + trackItem.external_urls.spotify ); case 'album': - // For albums, we need name, artists, and cover image + const albumItem = item as AlbumResultItem; return ( - item.name && - item.artists && - item.artists.length > 0 && - item.artists[0] && - item.artists[0].name && - item.external_urls && - item.external_urls.spotify + albumItem.name && + albumItem.artists && + albumItem.artists.length > 0 && + albumItem.artists[0] && + albumItem.artists[0].name && + albumItem.external_urls && + albumItem.external_urls.spotify ); case 'playlist': - // For playlists, we need name, owner, and tracks + const playlistItem = item as PlaylistResultItem; return ( - item.name && - item.owner && - item.owner.display_name && - item.tracks && - item.external_urls && - item.external_urls.spotify + playlistItem.name && + playlistItem.owner && + playlistItem.owner.display_name && + playlistItem.tracks && + playlistItem.external_urls && + playlistItem.external_urls.spotify ); case 'artist': - // For artists, we need name + const artistItem = item as ArtistResultItem; return ( - item.name && - item.external_urls && - item.external_urls.spotify + artistItem.name && + artistItem.external_urls && + artistItem.external_urls.spotify ); default: - // Default case - just check if the item exists + // Default case - just check if the item exists (already handled by `if (!item) return false;`) return true; } }); @@ -233,7 +302,7 @@ document.addEventListener('DOMContentLoaded', function() { /** * Attaches download handlers to result cards */ - function attachDownloadListeners(items: any[]) { + function attachDownloadListeners(items: SearchResultItem[]) { document.querySelectorAll('.download-btn').forEach((btnElm) => { const btn = btnElm as HTMLButtonElement; btn.addEventListener('click', (e: Event) => { @@ -262,10 +331,37 @@ document.addEventListener('DOMContentLoaded', function() { } // Prepare metadata for the download - const metadata = { - name: item.name || 'Unknown', - artist: item.artists ? item.artists[0]?.name : undefined - }; + let metadata: DownloadQueueItem; + if (currentSearchType === 'track') { + const trackItem = item as TrackResultItem; + metadata = { + name: trackItem.name || 'Unknown', + artist: trackItem.artists ? trackItem.artists[0]?.name : undefined, + album: trackItem.album ? { name: trackItem.album.name, album_type: trackItem.album.album_type } : undefined + }; + } else if (currentSearchType === 'album') { + const albumItem = item as AlbumResultItem; + metadata = { + name: albumItem.name || 'Unknown', + artist: albumItem.artists ? albumItem.artists[0]?.name : undefined, + album: { name: albumItem.name, album_type: albumItem.album_type} + }; + } else if (currentSearchType === 'playlist') { + const playlistItem = item as PlaylistResultItem; + metadata = { + name: playlistItem.name || 'Unknown', + // artist for playlist is owner + artist: playlistItem.owner?.display_name + }; + } else if (currentSearchType === 'artist') { + const artistItem = item as ArtistResultItem; + metadata = { + name: artistItem.name || 'Unknown', + artist: artistItem.name // For artist type, artist is the item name itself + }; + } else { + metadata = { name: item.name || 'Unknown' }; // Fallback + } // Disable the button and update text btn.disabled = true; @@ -278,7 +374,8 @@ document.addEventListener('DOMContentLoaded', function() { } // Start the download - startDownload(url, currentSearchType, metadata, item.album ? item.album.album_type : null) + startDownload(url, currentSearchType, metadata, + (item as AlbumResultItem).album_type || ((item as TrackResultItem).album ? (item as TrackResultItem).album.album_type : null)) .then(() => { // For artists, show how many albums were queued if (currentSearchType === 'artist') { @@ -301,7 +398,7 @@ document.addEventListener('DOMContentLoaded', function() { /** * Starts the download process via API */ - async function startDownload(url: string, type: string, item: any, albumType: string | null) { + async function startDownload(url: string, type: string, item: DownloadQueueItem, albumType: string | null | undefined) { if (!url || !type) { showError('Missing URL or type for download'); return; @@ -385,7 +482,7 @@ document.addEventListener('DOMContentLoaded', function() { /** * Creates a result card element */ - function createResultCard(item: any, type: string, index: number): HTMLDivElement { + function createResultCard(item: SearchResultItem, type: string, index: number): HTMLDivElement { const cardElement = document.createElement('div'); cardElement.className = 'result-card'; @@ -394,10 +491,22 @@ document.addEventListener('DOMContentLoaded', function() { // Get the appropriate image URL let imageUrl = '/static/images/placeholder.jpg'; - if (item.album && item.album.images && item.album.images.length > 0) { - imageUrl = item.album.images[0].url; - } else if (item.images && item.images.length > 0) { - imageUrl = item.images[0].url; + // Type guards to safely access images + if (type === 'album' || type === 'artist') { + const albumOrArtistItem = item as AlbumResultItem | ArtistResultItem; + if (albumOrArtistItem.images && albumOrArtistItem.images.length > 0) { + imageUrl = albumOrArtistItem.images[0].url; + } + } else if (type === 'track') { + const trackItem = item as TrackResultItem; + if (trackItem.album && trackItem.album.images && trackItem.album.images.length > 0) { + imageUrl = trackItem.album.images[0].url; + } + } else if (type === 'playlist') { + const playlistItem = item as PlaylistResultItem; + if (playlistItem.images && playlistItem.images.length > 0) { + imageUrl = playlistItem.images[0].url; + } } // Get the appropriate details based on type @@ -406,20 +515,32 @@ document.addEventListener('DOMContentLoaded', function() { switch (type) { case 'track': - subtitle = item.artists ? item.artists.map(a => a.name).join(', ') : 'Unknown Artist'; - details = item.album ? `${item.album.name}${msToMinutesSeconds(item.duration_ms)}` : ''; + { + const trackItem = item as TrackResultItem; + subtitle = trackItem.artists ? trackItem.artists.map((a: Artist) => a.name).join(', ') : 'Unknown Artist'; + details = trackItem.album ? `${trackItem.album.name}${msToMinutesSeconds(trackItem.duration_ms)}` : ''; + } break; case 'album': - subtitle = item.artists ? item.artists.map(a => a.name).join(', ') : 'Unknown Artist'; - details = `${item.total_tracks || 0} tracks${item.release_date ? new Date(item.release_date).getFullYear() : ''}`; + { + const albumItem = item as AlbumResultItem; + subtitle = albumItem.artists ? albumItem.artists.map((a: Artist) => a.name).join(', ') : 'Unknown Artist'; + details = `${albumItem.total_tracks || 0} tracks${albumItem.release_date ? new Date(albumItem.release_date).getFullYear() : ''}`; + } break; case 'playlist': - subtitle = `By ${item.owner ? item.owner.display_name : 'Unknown'}`; - details = `${item.tracks && item.tracks.total ? item.tracks.total : 0} tracks`; + { + const playlistItem = item as PlaylistResultItem; + subtitle = `By ${playlistItem.owner ? playlistItem.owner.display_name : 'Unknown'}`; + details = `${playlistItem.tracks && playlistItem.tracks.total ? playlistItem.tracks.total : 0} tracks`; + } break; case 'artist': - subtitle = 'Artist'; - details = item.genres ? `${item.genres.slice(0, 2).join(', ')}` : ''; + { + const artistItem = item as ArtistResultItem; + subtitle = 'Artist'; + details = artistItem.genres ? `${artistItem.genres.slice(0, 2).join(', ')}` : ''; + } break; } diff --git a/static/js/playlist.ts b/src/js/playlist.ts similarity index 84% rename from static/js/playlist.ts rename to src/js/playlist.ts index c06fb9d..2a80ca7 100644 --- a/static/js/playlist.ts +++ b/src/js/playlist.ts @@ -1,6 +1,68 @@ // Import the downloadQueue singleton from your working queue.js implementation. 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; + images?: Image[]; + external_urls?: { spotify?: string }; +} + +interface Track { + id: string; + name: string; + artists: Artist[]; + album: Album; + duration_ms: number; + explicit: boolean; + external_urls?: { spotify?: string }; +} + +interface PlaylistItem { + track: Track | null; + // Add other playlist item properties like added_at, added_by if needed +} + +interface Playlist { + id: string; + name: string; + description: string | null; + owner: { + display_name?: string; + id?: string; + }; + images: Image[]; + tracks: { + items: PlaylistItem[]; + total: number; + }; + followers?: { + total: number; + }; + external_urls?: { spotify?: string }; +} + +interface DownloadQueueItem { + name: string; + artist?: string; // Can be a simple string for the queue + album?: { name: string }; // Match QueueItem's album structure + owner?: string; // For playlists, owner can be a string + // Add any other properties your item might have, compatible with QueueItem +} + document.addEventListener('DOMContentLoaded', () => { // Parse playlist ID from URL const pathSegments = window.location.pathname.split('/'); @@ -15,7 +77,7 @@ document.addEventListener('DOMContentLoaded', () => { fetch(`/api/playlist/info?id=${encodeURIComponent(playlistId)}`) .then(response => { if (!response.ok) throw new Error('Network response was not ok'); - return response.json(); + return response.json() as Promise; }) .then(data => renderPlaylist(data)) .catch(error => { @@ -34,7 +96,7 @@ document.addEventListener('DOMContentLoaded', () => { /** * Renders playlist header and tracks. */ -function renderPlaylist(playlist: any) { +function renderPlaylist(playlist: Playlist) { // Hide loading and error messages const loadingEl = document.getElementById('loading'); if (loadingEl) loadingEl.classList.add('hidden'); @@ -80,7 +142,7 @@ function renderPlaylist(playlist: any) { // Check if any track in the playlist is explicit when filter is enabled let hasExplicitTrack = false; if (isExplicitFilterEnabled && playlist.tracks?.items) { - hasExplicitTrack = playlist.tracks.items.some(item => item?.track && item.track.explicit); + hasExplicitTrack = playlist.tracks.items.some((item: PlaylistItem) => item?.track && item.track.explicit); } // --- Add "Download Whole Playlist" Button --- @@ -178,7 +240,7 @@ function renderPlaylist(playlist: any) { tracksList.innerHTML = ''; // Clear any existing content if (playlist.tracks?.items) { - playlist.tracks.items.forEach((item, index) => { + playlist.tracks.items.forEach((item: PlaylistItem, index: number) => { if (!item || !item.track) return; // Skip null/undefined tracks const track = item.track; @@ -281,7 +343,10 @@ function attachDownloadListeners() { const name = currentTarget.dataset.name || extractName(url) || 'Unknown'; // Remove the button immediately after click. currentTarget.remove(); - startDownload(url, type, { name }, ''); // Added empty string for albumType + // For individual track downloads, we might not have album/artist name readily here. + // The queue.ts download method should be robust enough or we might need to fetch more data. + // For now, pass what we have. + startDownload(url, type, { name }, ''); // Pass name, artist/album are optional in DownloadQueueItem }); }); } @@ -289,7 +354,7 @@ function attachDownloadListeners() { /** * Initiates the whole playlist download by calling the playlist endpoint. */ -async function downloadWholePlaylist(playlist: any) { +async function downloadWholePlaylist(playlist: Playlist) { if (!playlist) { throw new Error('Invalid playlist data'); } @@ -301,7 +366,11 @@ async function downloadWholePlaylist(playlist: any) { try { // Use the centralized downloadQueue.download method - await downloadQueue.download(url, 'playlist', { name: playlist.name || 'Unknown Playlist' }); + await downloadQueue.download(url, 'playlist', { + name: playlist.name || 'Unknown Playlist', + owner: playlist.owner?.display_name // Pass owner as a string + // total_tracks can also be passed if QueueItem supports it directly + }); // Make the queue visible after queueing downloadQueue.toggleVisibility(true); } catch (error: any) { @@ -315,15 +384,15 @@ async function downloadWholePlaylist(playlist: any) { * adding a 20ms delay between each album download and updating the button * with the progress (queued_albums/total_albums). */ -async function downloadPlaylistAlbums(playlist: any) { +async function downloadPlaylistAlbums(playlist: Playlist) { if (!playlist?.tracks?.items) { showError('No tracks found in this playlist.'); return; } // Build a map of unique albums (using album ID as the key). - const albumMap = new Map(); - playlist.tracks.items.forEach(item => { + const albumMap = new Map(); + playlist.tracks.items.forEach((item: PlaylistItem) => { if (!item?.track?.album) return; const album = item.track.album; @@ -359,7 +428,11 @@ async function downloadPlaylistAlbums(playlist: any) { await downloadQueue.download( albumUrl, 'album', - { name: album.name || 'Unknown Album' } + { + name: album.name || 'Unknown Album', + // If artist information is available on album objects from playlist, pass it + // artist: album.artists?.[0]?.name + } ); // Update button text with current progress. @@ -387,7 +460,7 @@ async function downloadPlaylistAlbums(playlist: any) { /** * Starts the download process using the centralized download method from the queue. */ -async function startDownload(url: string, type: string, item: any, albumType?: string) { +async function startDownload(url: string, type: string, item: DownloadQueueItem, albumType?: string) { if (!url || !type) { showError('Missing URL or type for download'); return; diff --git a/static/js/queue.ts b/src/js/queue.ts similarity index 99% rename from static/js/queue.ts rename to src/js/queue.ts index c910ea4..f7d8d2f 100644 --- a/static/js/queue.ts +++ b/src/js/queue.ts @@ -826,7 +826,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) 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) => { + entries.sort((a: QueueEntry, b: QueueEntry) => { const getGroup = (entry: QueueEntry) => { // Add type if (entry.lastStatus && (entry.lastStatus.status === "error" || entry.lastStatus.status === "cancel")) { return 0; @@ -881,7 +881,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) if (visibleItems.length === 0) { // No items in container, append all visible entries container.innerHTML = ''; // Clear any empty state - visibleEntries.forEach(entry => { + visibleEntries.forEach((entry: QueueEntry) => { // We no longer automatically start monitoring here // Monitoring is now explicitly started by the methods that create downloads container.appendChild(entry.element); @@ -890,7 +890,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) // Container already has items, update more efficiently // Create a map of current DOM elements by queue ID - const existingElementMap = {}; + const existingElementMap: { [key: string]: HTMLElement } = {}; visibleItems.forEach(el => { const queueId = (el.querySelector('.cancel-btn') as HTMLElement | null)?.dataset.queueid; // Optional chaining if (queueId) existingElementMap[queueId] = el as HTMLElement; // Cast to HTMLElement @@ -900,7 +900,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) container.innerHTML = ''; // Add visible entries in correct order - visibleEntries.forEach(entry => { + visibleEntries.forEach((entry: QueueEntry) => { // We no longer automatically start monitoring here container.appendChild(entry.element); @@ -931,7 +931,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) /* Checks if an entry is visible in the queue display. */ isEntryVisible(queueId: string): boolean { // Add return type const entries = Object.values(this.queueEntries); - entries.sort((a, b) => { + entries.sort((a: QueueEntry, b: QueueEntry) => { const getGroup = (entry: QueueEntry) => { // Add type if (entry.lastStatus && (entry.lastStatus.status === "error" || entry.lastStatus.status === "cancel")) { return 0; @@ -954,7 +954,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) return a.lastUpdated - b.lastUpdated; } }); - const index = entries.findIndex(e => e.uniqueId === queueId); + const index = entries.findIndex((e: QueueEntry) => e.uniqueId === queueId); return index >= 0 && index < this.visibleCount; } diff --git a/static/js/track.ts b/src/js/track.ts similarity index 100% rename from static/js/track.ts rename to src/js/track.ts diff --git a/tsconfig.json b/tsconfig.json index 34db1e4..f147cc1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,15 +1,23 @@ { "compilerOptions": { - "target": "es2017", // Specify ECMAScript target version + "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. + "forceConsistentCasingInFileNames": true, // Disallow inconsistently-cased references to the same file. + "outDir": "./static/js", + "rootDir": "./src/js" }, "include": [ - "static/js/**/*.ts" // Specifies the TypeScript files to be included in compilation + "src/js/**/*.ts", + "src/js/album.ts", + "src/js/artist.ts", + "src/js/config.ts", + "src/js/main.ts", + "src/js/playlist.ts", + "src/js/queue.ts", + "src/js/track.ts" ], "exclude": [ "node_modules" // Specifies an array of filenames or patterns that should be skipped when resolving include. From e822284b882f032e699e68d72e986330a75ba0a2 Mon Sep 17 00:00:00 2001 From: "cool.gitter.not.me.again.duh" Date: Tue, 27 May 2025 12:01:49 -0600 Subject: [PATCH 05/18] architectural changes, preparing for playlist & artist watching --- .gitignore | 1 + routes/album.py | 42 ++++- routes/artist.py | 19 +- routes/config.py | 13 +- routes/credentials.py | 2 +- routes/playlist.py | 50 ++++-- routes/track.py | 49 +++-- routes/utils/album.py | 16 +- routes/utils/artist.py | 49 ++++- routes/utils/celery_config.py | 2 +- routes/utils/celery_manager.py | 29 ++- routes/utils/celery_queue_manager.py | 81 ++++++++- routes/utils/celery_tasks.py | 123 +++++++++++-- routes/utils/credentials.py | 255 +++++++++++++++++++++++---- routes/utils/get_info.py | 2 +- routes/utils/playlist.py | 12 +- routes/utils/search.py | 2 +- routes/utils/track.py | 16 +- src/js/config.ts | 131 ++++++++++---- src/js/queue.ts | 20 ++- static/css/config/config.css | 75 ++++++++ static/images/cross.svg | 4 + static/images/plus-circle.svg | 4 + static/images/skull-head.svg | 3 + templates/config.html | 18 +- 25 files changed, 844 insertions(+), 174 deletions(-) create mode 100644 static/images/cross.svg create mode 100644 static/images/plus-circle.svg create mode 100644 static/images/skull-head.svg diff --git a/.gitignore b/.gitignore index 351e496..5ac9910 100755 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ celery_worker.log logs/spotizerr.log /.venv static/js +data diff --git a/routes/album.py b/routes/album.py index 7ec11e5..0a973ca 100755 --- a/routes/album.py +++ b/routes/album.py @@ -2,7 +2,10 @@ from flask import Blueprint, Response, request import json import os import traceback +import uuid +import time from routes.utils.celery_queue_manager import download_queue_manager +from routes.utils.celery_tasks import store_task_info, store_task_status, ProgressState album_bp = Blueprint('album', __name__) @@ -26,13 +29,38 @@ def handle_download(): # Include full original request URL in metadata orig_params = request.args.to_dict() orig_params["original_url"] = request.url - task_id = download_queue_manager.add_task({ - "download_type": "album", - "url": url, - "name": name, - "artist": artist, - "orig_request": orig_params - }) + try: + task_id = download_queue_manager.add_task({ + "download_type": "album", + "url": url, + "name": name, + "artist": artist, + "orig_request": orig_params + }) + except Exception as e: + # Generic error handling for other issues during task submission + # Create an error task ID if add_task itself fails before returning an ID + error_task_id = str(uuid.uuid4()) + + store_task_info(error_task_id, { + "download_type": "album", + "url": url, + "name": name, + "artist": artist, + "original_request": orig_params, + "created_at": time.time(), + "is_submission_error_task": True + }) + store_task_status(error_task_id, { + "status": ProgressState.ERROR, + "error": f"Failed to queue album download: {str(e)}", + "timestamp": time.time() + }) + return Response( + json.dumps({"error": f"Failed to queue album download: {str(e)}", "task_id": error_task_id}), + status=500, + mimetype='application/json' + ) return Response( json.dumps({"prg_file": task_id}), diff --git a/routes/artist.py b/routes/artist.py index c679688..b3b747f 100644 --- a/routes/artist.py +++ b/routes/artist.py @@ -40,20 +40,25 @@ def handle_artist_download(): from routes.utils.artist import download_artist_albums # Delegate to the download_artist_albums function which will handle album filtering - task_ids = download_artist_albums( + successfully_queued_albums, duplicate_albums = download_artist_albums( url=url, album_type=album_type, request_args=request.args.to_dict() ) # Return the list of album task IDs. + response_data = { + "status": "complete", + "message": f"Artist discography processing initiated. {len(successfully_queued_albums)} albums queued.", + "queued_albums": successfully_queued_albums + } + if duplicate_albums: + response_data["duplicate_albums"] = duplicate_albums + response_data["message"] += f" {len(duplicate_albums)} albums were already in progress or queued." + return Response( - json.dumps({ - "status": "complete", - "task_ids": task_ids, - "message": f"Artist discography queued – {len(task_ids)} album tasks have been queued." - }), - status=202, + json.dumps(response_data), + status=202, # Still 202 Accepted as some operations may have succeeded mimetype='application/json' ) except Exception as e: diff --git a/routes/config.py b/routes/config.py index de38a76..66e27cb 100644 --- a/routes/config.py +++ b/routes/config.py @@ -7,7 +7,7 @@ import time import os config_bp = Blueprint('config_bp', __name__) -CONFIG_PATH = Path('./config/main.json') +CONFIG_PATH = Path('./data/config/main.json') # Flag for config change notifications config_changed = False @@ -70,7 +70,7 @@ def handle_config(): return jsonify({"error": "Could not read config file"}), 500 # Create config/state directory - Path('./config/state').mkdir(parents=True, exist_ok=True) + Path('./data/config/state').mkdir(parents=True, exist_ok=True) # Set default values for any missing config options defaults = { @@ -116,7 +116,14 @@ def update_config(): if not save_config(new_config): return jsonify({"error": "Failed to save config"}), 500 - return jsonify({"message": "Config updated successfully"}) + # Return the updated config + updated_config_values = get_config() + if updated_config_values is None: + # This case should ideally not be reached if save_config succeeded + # and get_config handles errors by returning a default or None. + return jsonify({"error": "Failed to retrieve configuration after saving"}), 500 + + return jsonify(updated_config_values) except json.JSONDecodeError: return jsonify({"error": "Invalid JSON data"}), 400 except Exception as e: diff --git a/routes/credentials.py b/routes/credentials.py index 0461de6..571d22f 100755 --- a/routes/credentials.py +++ b/routes/credentials.py @@ -69,7 +69,7 @@ def handle_search_credential(service, name): return jsonify({"error": "Both client_id and client_secret are required"}), 400 # For POST, first check if the credentials directory exists - if request.method == 'POST' and not any(Path(f'./creds/{service}/{name}').glob('*.json')): + if request.method == 'POST' and not any(Path(f'./data/{service}/{name}').glob('*.json')): return jsonify({"error": f"Account '{name}' doesn't exist. Create it first."}), 404 # Create or update search credentials diff --git a/routes/playlist.py b/routes/playlist.py index feb7eb8..144e461 100755 --- a/routes/playlist.py +++ b/routes/playlist.py @@ -2,7 +2,10 @@ from flask import Blueprint, Response, request import os import json import traceback +import uuid # For generating error task IDs +import time # For timestamps from routes.utils.celery_queue_manager import download_queue_manager +from routes.utils.celery_tasks import store_task_info, store_task_status, ProgressState # For error task creation playlist_bp = Blueprint('playlist', __name__) @@ -12,6 +15,8 @@ def handle_download(): url = request.args.get('url') name = request.args.get('name') artist = request.args.get('artist') + orig_params = request.args.to_dict() + orig_params["original_url"] = request.url # Validate required parameters if not url: @@ -21,21 +26,40 @@ def handle_download(): mimetype='application/json' ) - # Add the task to the queue with only essential parameters - # The queue manager will now handle all config parameters - # Include full original request URL in metadata - orig_params = request.args.to_dict() - orig_params["original_url"] = request.url - task_id = download_queue_manager.add_task({ - "download_type": "playlist", - "url": url, - "name": name, - "artist": artist, - "orig_request": orig_params - }) + try: + task_id = download_queue_manager.add_task({ + "download_type": "playlist", + "url": url, + "name": name, + "artist": artist, + "orig_request": orig_params + }) + # Removed DuplicateDownloadError handling, add_task now manages this by creating an error task. + except Exception as e: + # Generic error handling for other issues during task submission + error_task_id = str(uuid.uuid4()) + store_task_info(error_task_id, { + "download_type": "playlist", + "url": url, + "name": name, + "artist": artist, + "original_request": orig_params, + "created_at": time.time(), + "is_submission_error_task": True + }) + store_task_status(error_task_id, { + "status": ProgressState.ERROR, + "error": f"Failed to queue playlist download: {str(e)}", + "timestamp": time.time() + }) + return Response( + json.dumps({"error": f"Failed to queue playlist download: {str(e)}", "task_id": error_task_id}), + status=500, + mimetype='application/json' + ) return Response( - json.dumps({"prg_file": task_id}), + json.dumps({"prg_file": task_id}), # prg_file is the old name for task_id status=202, mimetype='application/json' ) diff --git a/routes/track.py b/routes/track.py index 609a441..7fdbfed 100755 --- a/routes/track.py +++ b/routes/track.py @@ -2,7 +2,10 @@ from flask import Blueprint, Response, request import os import json import traceback +import uuid # For generating error task IDs +import time # For timestamps from routes.utils.celery_queue_manager import download_queue_manager +from routes.utils.celery_tasks import store_task_info, store_task_status, ProgressState # For error task creation from urllib.parse import urlparse # for URL validation track_bp = Blueprint('track', __name__) @@ -13,6 +16,8 @@ def handle_download(): url = request.args.get('url') name = request.args.get('name') artist = request.args.get('artist') + orig_params = request.args.to_dict() + orig_params["original_url"] = request.url # Validate required parameters if not url: @@ -31,20 +36,40 @@ def handle_download(): mimetype='application/json' ) - # Add the task to the queue with only essential parameters - # The queue manager will now handle all config parameters - orig_params = request.args.to_dict() - orig_params["original_url"] = request.url - task_id = download_queue_manager.add_task({ - "download_type": "track", - "url": url, - "name": name, - "artist": artist, - "orig_request": orig_params - }) + try: + task_id = download_queue_manager.add_task({ + "download_type": "track", + "url": url, + "name": name, + "artist": artist, + "orig_request": orig_params + }) + # Removed DuplicateDownloadError handling, add_task now manages this by creating an error task. + except Exception as e: + # Generic error handling for other issues during task submission + error_task_id = str(uuid.uuid4()) + store_task_info(error_task_id, { + "download_type": "track", + "url": url, + "name": name, + "artist": artist, + "original_request": orig_params, + "created_at": time.time(), + "is_submission_error_task": True + }) + store_task_status(error_task_id, { + "status": ProgressState.ERROR, + "error": f"Failed to queue track download: {str(e)}", + "timestamp": time.time() + }) + return Response( + json.dumps({"error": f"Failed to queue track download: {str(e)}", "task_id": error_task_id}), + status=500, + mimetype='application/json' + ) return Response( - json.dumps({"prg_file": task_id}), + json.dumps({"prg_file": task_id}), # prg_file is the old name for task_id status=202, mimetype='application/json' ) diff --git a/routes/utils/album.py b/routes/utils/album.py index 2a13a43..0cf35fb 100755 --- a/routes/utils/album.py +++ b/routes/utils/album.py @@ -47,11 +47,11 @@ def download_album( # Smartly determine where to look for Spotify search credentials if service == 'spotify' and fallback: # If fallback is enabled, use the fallback account for Spotify search credentials - search_creds_path = Path(f'./creds/spotify/{fallback}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{fallback}/search.json') print(f"DEBUG: Using Spotify search credentials from fallback: {search_creds_path}") else: # Otherwise use the main account for Spotify search credentials - search_creds_path = Path(f'./creds/spotify/{main}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{main}/search.json') print(f"DEBUG: Using Spotify search credentials from main: {search_creds_path}") if search_creds_path.exists(): @@ -77,7 +77,7 @@ def download_album( deezer_error = None try: # Load Deezer credentials from 'main' under deezer directory - deezer_creds_dir = os.path.join('./creds/deezer', main) + deezer_creds_dir = os.path.join('./data/creds/deezer', main) deezer_creds_path = os.path.abspath(os.path.join(deezer_creds_dir, 'credentials.json')) # DEBUG: Print Deezer credential paths being used @@ -89,8 +89,8 @@ def download_album( # List available directories to compare print(f"DEBUG: Available Deezer credential directories:") - for dir_name in os.listdir('./creds/deezer'): - print(f"DEBUG: ./creds/deezer/{dir_name}") + for dir_name in os.listdir('./data/creds/deezer'): + print(f"DEBUG: ./data/creds/deezer/{dir_name}") with open(deezer_creds_path, 'r') as f: deezer_creds = json.load(f) @@ -129,7 +129,7 @@ def download_album( # Load fallback Spotify credentials and attempt download try: - spo_creds_dir = os.path.join('./creds/spotify', fallback) + spo_creds_dir = os.path.join('./data/creds/spotify', fallback) spo_creds_path = os.path.abspath(os.path.join(spo_creds_dir, 'credentials.json')) print(f"DEBUG: Using Spotify fallback credentials from: {spo_creds_path}") @@ -173,7 +173,7 @@ def download_album( # Original behavior: use Spotify main if quality is None: quality = 'HIGH' - creds_dir = os.path.join('./creds/spotify', main) + creds_dir = os.path.join('./data/creds/spotify', main) credentials_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json')) print(f"DEBUG: Using Spotify main credentials from: {credentials_path}") print(f"DEBUG: Credentials exist: {os.path.exists(credentials_path)}") @@ -208,7 +208,7 @@ def download_album( if quality is None: quality = 'FLAC' # Existing code remains the same, ignoring fallback - creds_dir = os.path.join('./creds/deezer', main) + creds_dir = os.path.join('./data/creds/deezer', main) creds_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json')) print(f"DEBUG: Using Deezer credentials from: {creds_path}") print(f"DEBUG: Credentials exist: {os.path.exists(creds_path)}") diff --git a/routes/utils/artist.py b/routes/utils/artist.py index 6510b0d..e0cac69 100644 --- a/routes/utils/artist.py +++ b/routes/utils/artist.py @@ -6,6 +6,7 @@ import logging from flask import Blueprint, Response, request, url_for from routes.utils.celery_queue_manager import download_queue_manager, get_config_params from routes.utils.get_info import get_spotify_info +from routes.utils.celery_tasks import get_last_task_status, ProgressState from deezspot.easy_spoty import Spo from deezspot.libutils.utils import get_ids, link_is_valid @@ -32,7 +33,7 @@ def get_artist_discography(url, main, album_type='album,single,compilation,appea # Initialize Spotify API with credentials spotify_client_id = None spotify_client_secret = None - search_creds_path = Path(f'./creds/spotify/{main}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{main}/search.json') if search_creds_path.exists(): try: with open(search_creds_path, 'r') as f: @@ -76,7 +77,7 @@ def download_artist_albums(url, album_type="album,single,compilation", request_a request_args (dict): Original request arguments for tracking Returns: - list: List of task IDs for the queued album downloads + tuple: (list of successfully queued albums, list of duplicate albums) """ if not url: raise ValueError("Missing required parameter: url") @@ -133,10 +134,12 @@ def download_artist_albums(url, album_type="album,single,compilation", request_a if not filtered_albums: logger.warning(f"No albums match the specified types: {album_type}") - return [] + return [], [] # Queue each album as a separate download task album_task_ids = [] + successfully_queued_albums = [] + duplicate_albums = [] # To store info about albums that were duplicates for album in filtered_albums: # Add detailed logging to inspect each album's structure and URLs @@ -185,10 +188,38 @@ def download_artist_albums(url, album_type="album,single,compilation", request_a # Debug log the task data being sent to the queue logger.debug(f"Album task data: url={task_data['url']}, retry_url={task_data['retry_url']}") - # Add the task to the queue manager - task_id = download_queue_manager.add_task(task_data) - album_task_ids.append(task_id) - logger.info(f"Queued album download: {album_name} ({task_id})") + try: + task_id = download_queue_manager.add_task(task_data) + + # Check the status of the newly added task to see if it was marked as a duplicate error + last_status = get_last_task_status(task_id) + + if last_status and last_status.get("status") == ProgressState.ERROR and last_status.get("existing_task_id"): + logger.warning(f"Album {album_name} (URL: {album_url}) is a duplicate. Error task ID: {task_id}. Existing task ID: {last_status.get('existing_task_id')}") + duplicate_albums.append({ + "name": album_name, + "artist": album_artist, + "url": album_url, + "error_task_id": task_id, # This is the ID of the task marked as a duplicate error + "existing_task_id": last_status.get("existing_task_id"), + "message": last_status.get("message", "Duplicate download attempt.") + }) + else: + # If not a duplicate error, it was successfully queued (or failed for other reasons handled by add_task) + # We only add to successfully_queued_albums if it wasn't a duplicate error from add_task + # Other errors from add_task (like submission failure) would also result in an error status for task_id + # but won't have 'existing_task_id'. The client can check the status of this task_id. + album_task_ids.append(task_id) # Keep track of all task_ids returned by add_task + successfully_queued_albums.append({ + "name": album_name, + "artist": album_artist, + "url": album_url, + "task_id": task_id + }) + logger.info(f"Queued album download: {album_name} ({task_id})") + except Exception as e: # Catch any other unexpected error from add_task itself (though it should be rare now) + logger.error(f"Failed to queue album {album_name} due to an unexpected error in add_task: {str(e)}") + # Optionally, collect these errors. For now, just logging and continuing. - logger.info(f"Queued {len(album_task_ids)} album downloads for artist: {artist_name}") - return album_task_ids + logger.info(f"Artist album processing: {len(successfully_queued_albums)} queued, {len(duplicate_albums)} duplicates found.") + return successfully_queued_albums, duplicate_albums diff --git a/routes/utils/celery_config.py b/routes/utils/celery_config.py index f455ae6..3b849a4 100644 --- a/routes/utils/celery_config.py +++ b/routes/utils/celery_config.py @@ -22,7 +22,7 @@ REDIS_BACKEND = os.getenv('REDIS_BACKEND', REDIS_URL) logger.info(f"Redis configuration: REDIS_URL={REDIS_URL}, REDIS_BACKEND={REDIS_BACKEND}") # Config path -CONFIG_PATH = './config/main.json' +CONFIG_PATH = './data/config/main.json' def get_config_params(): """ diff --git a/routes/utils/celery_manager.py b/routes/utils/celery_manager.py index ea140ad..59d5bba 100644 --- a/routes/utils/celery_manager.py +++ b/routes/utils/celery_manager.py @@ -17,7 +17,8 @@ from .celery_tasks import ( get_task_info, get_last_task_status, store_task_status, - get_all_tasks as get_all_celery_tasks_info + get_all_tasks as get_all_celery_tasks_info, + cleanup_stale_errors ) from .celery_config import get_config_params @@ -25,7 +26,7 @@ from .celery_config import get_config_params logger = logging.getLogger(__name__) # Configuration -CONFIG_PATH = './config/main.json' +CONFIG_PATH = './data/config/main.json' CELERY_APP = 'routes.utils.celery_tasks.celery_app' CELERY_PROCESS = None CONFIG_CHECK_INTERVAL = 30 # seconds @@ -39,6 +40,7 @@ class CeleryManager: self.celery_process = None self.current_worker_count = 0 self.monitoring_thread = None + self.error_cleanup_thread = None self.running = False self.log_queue = queue.Queue() self.output_threads = [] @@ -114,6 +116,10 @@ class CeleryManager: # Start monitoring thread for config changes self.monitoring_thread = threading.Thread(target=self._monitor_config, daemon=True) self.monitoring_thread.start() + + # Start periodic error cleanup thread + self.error_cleanup_thread = threading.Thread(target=self._run_periodic_error_cleanup, daemon=True) + self.error_cleanup_thread.start() # Register shutdown handler atexit.register(self.stop) @@ -325,5 +331,24 @@ class CeleryManager: logger.error(f"Error in config monitoring thread: {e}") time.sleep(5) # Wait before retrying + def _run_periodic_error_cleanup(self): + """Periodically triggers the cleanup_stale_errors Celery task.""" + cleanup_interval = 60 # Run cleanup task every 60 seconds + logger.info(f"Starting periodic error cleanup scheduler (runs every {cleanup_interval}s).") + while self.running: + try: + logger.info("Scheduling cleanup_stale_errors task...") + cleanup_stale_errors.delay() # Call the Celery task + except Exception as e: + logger.error(f"Error scheduling cleanup_stale_errors task: {e}", exc_info=True) + + # Wait for the next interval + # Use a loop to check self.running more frequently to allow faster shutdown + for _ in range(cleanup_interval): + if not self.running: + break + time.sleep(1) + logger.info("Periodic error cleanup scheduler stopped.") + # Create single instance celery_manager = CeleryManager() \ No newline at end of file diff --git a/routes/utils/celery_queue_manager.py b/routes/utils/celery_queue_manager.py index 010665a..eb3aafd 100644 --- a/routes/utils/celery_queue_manager.py +++ b/routes/utils/celery_queue_manager.py @@ -25,7 +25,7 @@ from routes.utils.celery_tasks import ( logger = logging.getLogger(__name__) # Load configuration -CONFIG_PATH = './config/main.json' +CONFIG_PATH = './data/config/main.json' try: with open(CONFIG_PATH, 'r') as f: config_data = json.load(f) @@ -96,16 +96,89 @@ class CeleryDownloadQueueManager: def add_task(self, task): """ - Add a new download task to the Celery queue + Add a new download task to the Celery queue. + If a duplicate active task is found, a new task ID is created and immediately set to an ERROR state. Args: task (dict): Task parameters including download_type, url, etc. Returns: - str: Task ID + str: Task ID (either for a new task or for a new error-state task if duplicate detected). """ try: - # Extract essential parameters + # Extract essential parameters for duplicate check + incoming_url = task.get("url") + incoming_type = task.get("download_type", "unknown") + + if not incoming_url: + # This should ideally be validated before calling add_task + # For now, let it proceed and potentially fail in Celery task if URL is vital and missing. + # Or, create an error task immediately if URL is strictly required for any task logging. + logger.warning("Task being added with no URL. Duplicate check might be unreliable.") + + # --- Check for Duplicates --- + NON_BLOCKING_STATES = [ + ProgressState.COMPLETE, + ProgressState.CANCELLED, + ProgressState.ERROR + ] + + all_existing_tasks_summary = get_all_tasks() + if incoming_url: # Only check for duplicates if we have a URL + for task_summary in all_existing_tasks_summary: + existing_task_id = task_summary.get("task_id") + if not existing_task_id: + continue + + existing_task_info = get_task_info(existing_task_id) + existing_last_status_obj = get_last_task_status(existing_task_id) + + if not existing_task_info or not existing_last_status_obj: + continue + + existing_url = existing_task_info.get("url") + existing_type = existing_task_info.get("download_type") + existing_status = existing_last_status_obj.get("status") + + if (existing_url == incoming_url and + existing_type == incoming_type and + existing_status not in NON_BLOCKING_STATES): + + message = f"Duplicate download: URL '{incoming_url}' (type: {incoming_type}) is already being processed by task {existing_task_id} (status: {existing_status})." + logger.warning(message) + + # Create a new task_id for this duplicate request and mark it as an error + error_task_id = str(uuid.uuid4()) + + # Store minimal info for this error task + error_task_info_payload = { + "download_type": incoming_type, + "type": task.get("type", incoming_type), + "name": task.get("name", "Duplicate Task"), + "artist": task.get("artist", ""), + "url": incoming_url, + "original_request": task.get("orig_request", task.get("original_request", {})), + "created_at": time.time(), + "is_duplicate_error_task": True + } + store_task_info(error_task_id, error_task_info_payload) + + # Store error status for this new task_id + error_status_payload = { + "status": ProgressState.ERROR, + "error": message, + "existing_task_id": existing_task_id, # So client knows which task it duplicates + "timestamp": time.time(), + "type": error_task_info_payload["type"], + "name": error_task_info_payload["name"], + "artist": error_task_info_payload["artist"] + } + store_task_status(error_task_id, error_status_payload) + + return error_task_id # Return the ID of this new error-state task + # --- End Duplicate Check --- + + # Proceed with normal task creation if no duplicate found or no URL to check download_type = task.get("download_type", "unknown") # Debug existing task data diff --git a/routes/utils/celery_tasks.py b/routes/utils/celery_tasks.py index c671328..5565752 100644 --- a/routes/utils/celery_tasks.py +++ b/routes/utils/celery_tasks.py @@ -44,6 +44,8 @@ class ProgressState: REAL_TIME = "real_time" SKIPPED = "skipped" DONE = "done" + ERROR_RETRIED = "ERROR_RETRIED" # Status for an error task that has been retried + ERROR_AUTO_CLEANED = "ERROR_AUTO_CLEANED" # Status for an error task that was auto-cleaned # Reuse the application's logging configuration for Celery workers @setup_logging.connect @@ -146,7 +148,7 @@ def cancel_task(task_id): # Mark the task as cancelled in Redis store_task_status(task_id, { "status": ProgressState.CANCELLED, - "message": "Task cancelled by user", + "error": "Task cancelled by user", "timestamp": time.time() }) @@ -165,12 +167,12 @@ def retry_task(task_id): # Get task info task_info = get_task_info(task_id) if not task_info: - return {"status": "error", "message": f"Task {task_id} not found"} + return {"status": "error", "error": f"Task {task_id} not found"} # Check if task has error status last_status = get_last_task_status(task_id) if not last_status or last_status.get("status") != ProgressState.ERROR: - return {"status": "error", "message": "Task is not in a failed state"} + return {"status": "error", "error": "Task is not in a failed state"} # Get current retry count retry_count = last_status.get("retry_count", 0) @@ -185,7 +187,7 @@ def retry_task(task_id): if retry_count >= max_retries: return { "status": "error", - "message": f"Maximum retry attempts ({max_retries}) exceeded" + "error": f"Maximum retry attempts ({max_retries}) exceeded" } # Calculate retry delay @@ -255,34 +257,51 @@ def retry_task(task_id): # Launch the appropriate task based on download_type download_type = task_info.get("download_type", "unknown") - task = None + new_celery_task_obj = None logger.info(f"Retrying task {task_id} as {new_task_id} (retry {retry_count + 1}/{max_retries})") if download_type == "track": - task = download_track.apply_async( + new_celery_task_obj = download_track.apply_async( kwargs=task_info, task_id=new_task_id, queue='downloads' ) elif download_type == "album": - task = download_album.apply_async( + new_celery_task_obj = download_album.apply_async( kwargs=task_info, task_id=new_task_id, queue='downloads' ) elif download_type == "playlist": - task = download_playlist.apply_async( + new_celery_task_obj = download_playlist.apply_async( kwargs=task_info, task_id=new_task_id, queue='downloads' ) else: logger.error(f"Unknown download type for retry: {download_type}") + store_task_status(new_task_id, { + "status": ProgressState.ERROR, + "error": f"Cannot retry: Unknown download type '{download_type}' for original task {task_id}", + "timestamp": time.time() + }) return { "status": "error", - "message": f"Unknown download type: {download_type}" + "error": f"Unknown download type: {download_type}" } + + # If retry was successfully submitted, update the original task's status + if new_celery_task_obj: + store_task_status(task_id, { + "status": "ERROR_RETRIED", + "error": f"Task superseded by retry: {new_task_id}", + "retried_as_task_id": new_task_id, + "timestamp": time.time() + }) + logger.info(f"Original task {task_id} status updated to ERROR_RETRIED, superseded by {new_task_id}") + else: + logger.error(f"Retry submission for task {task_id} (as {new_task_id}) did not return a Celery AsyncResult. Original task not marked as ERROR_RETRIED.") return { "status": "requeued", @@ -292,9 +311,8 @@ def retry_task(task_id): "retry_delay": retry_delay } except Exception as e: - logger.error(f"Error retrying task {task_id}: {e}") - traceback.print_exc() - return {"status": "error", "message": str(e)} + logger.error(f"Error retrying task {task_id}: {e}", exc_info=True) + return {"status": "error", "error": str(e)} def get_all_tasks(): """Get all active task IDs""" @@ -657,8 +675,9 @@ class ProgressTrackingTask(Task): task_info['error_count'] = error_count store_task_info(task_id, task_info) - # Set status + # Set status and error message data['status'] = ProgressState.ERROR + data['error'] = message def _handle_done(self, task_id, data, task_info): """Handle done status from deezspot""" @@ -812,22 +831,21 @@ def task_failure_handler(task_id=None, exception=None, traceback=None, *args, ** can_retry = retry_count < max_retries # Update task status to error - error_message = str(exception) + error_message_str = str(exception) store_task_status(task_id, { "status": ProgressState.ERROR, "timestamp": time.time(), "type": task_info.get("type", "unknown"), "name": task_info.get("name", "Unknown"), "artist": task_info.get("artist", ""), - "error": error_message, + "error": error_message_str, "traceback": str(traceback), "can_retry": can_retry, "retry_count": retry_count, - "max_retries": max_retries, - "message": f"Error: {error_message}" + "max_retries": max_retries }) - logger.error(f"Task {task_id} failed: {error_message}") + logger.error(f"Task {task_id} failed: {error_message_str}") if can_retry: logger.info(f"Task {task_id} can be retried ({retry_count}/{max_retries})") except Exception as e: @@ -1053,4 +1071,71 @@ def download_playlist(self, **task_data): except Exception as e: logger.error(f"Error in download_playlist task: {e}") traceback.print_exc() - raise \ No newline at end of file + raise + +# Helper function to fully delete task data from Redis +def delete_task_data_and_log(task_id, reason="Task data deleted"): + """ + Marks a task as cancelled (if not already) and deletes all its data from Redis. + """ + try: + task_info = get_task_info(task_id) # Get info before deleting + last_status = get_last_task_status(task_id) + + # Update status to cancelled if it's not already in a terminal state that implies deletion is okay + if not last_status or last_status.get("status") not in [ProgressState.CANCELLED, ProgressState.ERROR_RETRIED, ProgressState.ERROR_AUTO_CLEANED]: + store_task_status(task_id, { + "status": ProgressState.ERROR_AUTO_CLEANED, # Use specific status + "error": reason, + "timestamp": time.time() + }) + + # Delete Redis keys associated with the task + redis_client.delete(f"task:{task_id}:info") + redis_client.delete(f"task:{task_id}:status") + redis_client.delete(f"task:{task_id}:status:next_id") + + logger.info(f"Data for task {task_id} ('{task_info.get('name', 'Unknown')}') deleted from Redis. Reason: {reason}") + return True + except Exception as e: + logger.error(f"Error deleting task data for {task_id}: {e}", exc_info=True) + return False + +@celery_app.task(name="cleanup_stale_errors", queue="default") # Put on default queue, not downloads +def cleanup_stale_errors(): + """ + Periodically checks for tasks in ERROR state for more than 1 minute and cleans them up. + """ + logger.info("Running cleanup_stale_errors task...") + cleaned_count = 0 + try: + task_keys = redis_client.keys("task:*:info") + if not task_keys: + logger.info("No task keys found for cleanup.") + return {"status": "complete", "message": "No tasks to check."} + + current_time = time.time() + stale_threshold = 60 # 1 minute + + for key_bytes in task_keys: + task_id = key_bytes.decode('utf-8').split(':')[1] + last_status = get_last_task_status(task_id) + + if last_status and last_status.get("status") == ProgressState.ERROR: + error_timestamp = last_status.get("timestamp", 0) + if (current_time - error_timestamp) > stale_threshold: + # Check again to ensure it wasn't retried just before cleanup + current_last_status_before_delete = get_last_task_status(task_id) + if current_last_status_before_delete and current_last_status_before_delete.get("status") == ProgressState.ERROR_RETRIED: + logger.info(f"Task {task_id} was retried just before cleanup. Skipping delete.") + continue + + logger.info(f"Task {task_id} is in ERROR state for more than {stale_threshold}s. Cleaning up.") + if delete_task_data_and_log(task_id, reason=f"Auto-cleaned: Task was in ERROR state for over {stale_threshold} seconds without manual retry."): + cleaned_count += 1 + + logger.info(f"cleanup_stale_errors task finished. Cleaned up {cleaned_count} stale errored tasks.") + return {"status": "complete", "cleaned_count": cleaned_count} + except Exception as e: + logger.error(f"Error during cleanup_stale_errors: {e}", exc_info=True) + return {"status": "error", "error": str(e)} \ No newline at end of file diff --git a/routes/utils/credentials.py b/routes/utils/credentials.py index 1d7c97b..0ed671b 100755 --- a/routes/utils/credentials.py +++ b/routes/utils/credentials.py @@ -1,6 +1,85 @@ import json from pathlib import Path import shutil +from deezspot.spotloader import SpoLogin +from deezspot.deezloader import DeeLogin +import traceback # For logging detailed error messages +import time # For retry delays + +def _get_spotify_search_creds(creds_dir: Path): + """Helper to load client_id and client_secret from search.json for a Spotify account.""" + search_file = creds_dir / 'search.json' + if search_file.exists(): + try: + with open(search_file, 'r') as f: + search_data = json.load(f) + return search_data.get('client_id'), search_data.get('client_secret') + except Exception: + # Log error if search.json is malformed or unreadable + print(f"Warning: Could not read Spotify search credentials from {search_file}") + traceback.print_exc() + return None, None + +def _validate_with_retry(service_name, account_name, creds_dir_path, cred_file_path, data_for_validation, is_spotify): + """ + Attempts to validate credentials with retries for connection errors. + - For Spotify, cred_file_path is used. + - For Deezer, data_for_validation (which contains the 'arl' key) is used. + Returns True if validated, raises ValueError if not. + """ + max_retries = 5 + last_exception = None + + for attempt in range(max_retries): + try: + if is_spotify: + client_id, client_secret = _get_spotify_search_creds(creds_dir_path) + SpoLogin(credentials_path=str(cred_file_path), spotify_client_id=client_id, spotify_client_secret=client_secret) + else: # Deezer + arl = data_for_validation.get('arl') + if not arl: + # This should be caught by prior checks, but as a safeguard: + raise ValueError("Missing 'arl' for Deezer validation.") + DeeLogin(arl=arl) + + print(f"{service_name.capitalize()} credentials for {account_name} validated successfully (attempt {attempt + 1}).") + return True # Validation successful + except Exception as e: + last_exception = e + error_str = str(e).lower() + # More comprehensive check for connection-related errors + is_connection_error = ( + "connection refused" in error_str or + "connection error" in error_str or + "timeout" in error_str or + "temporary failure in name resolution" in error_str or + "dns lookup failed" in error_str or + "network is unreachable" in error_str or + "ssl handshake failed" in error_str or # Can be network-related + "connection reset by peer" in error_str + ) + + if is_connection_error and attempt < max_retries - 1: + retry_delay = 2 + attempt # Increasing delay (2s, 3s, 4s, 5s) + print(f"Validation for {account_name} ({service_name}) failed on attempt {attempt + 1}/{max_retries} due to connection issue: {e}. Retrying in {retry_delay}s...") + time.sleep(retry_delay) + continue # Go to next retry attempt + else: + # Not a connection error, or it's the last retry for a connection error + print(f"Validation for {account_name} ({service_name}) failed on attempt {attempt + 1} with non-retryable error or max retries reached for connection error.") + break # Exit retry loop + + # If loop finished without returning True, validation failed + print(f"ERROR: Credential validation definitively failed for {service_name} account {account_name} after {attempt + 1} attempt(s).") + if last_exception: + base_error_message = str(last_exception).splitlines()[-1] + detailed_error_message = f"Invalid {service_name} credentials. Verification failed: {base_error_message}" + if is_spotify and "incorrect padding" in base_error_message.lower(): + detailed_error_message += ". Hint: Do not throw your password here, read the docs" + # traceback.print_exc() # Already printed in create/edit, avoid duplicate full trace + raise ValueError(detailed_error_message) + else: # Should not happen if loop runs at least once + raise ValueError(f"Invalid {service_name} credentials. Verification failed (unknown reason after retries).") def get_credential(service, name, cred_type='credentials'): """ @@ -28,7 +107,7 @@ def get_credential(service, name, cred_type='credentials'): if service == 'deezer' and cred_type == 'search': raise ValueError("Search credentials are only supported for Spotify") - creds_dir = Path('./creds') / service / name + creds_dir = Path('./data/creds') / service / name file_path = creds_dir / f'{cred_type}.json' if not file_path.exists(): @@ -56,7 +135,7 @@ def list_credentials(service): if service not in ['spotify', 'deezer']: raise ValueError("Service must be 'spotify' or 'deezer'") - service_dir = Path('./creds') / service + service_dir = Path('./data/creds') / service if not service_dir.exists(): return [] @@ -116,21 +195,80 @@ def create_credential(service, name, data, cred_type='credentials'): raise ValueError(f"Missing required field for {cred_type}: {field}") # Create directory - creds_dir = Path('./creds') / service / name + creds_dir = Path('./data/creds') / service / name + file_created_now = False + dir_created_now = False + if cred_type == 'credentials': try: creds_dir.mkdir(parents=True, exist_ok=False) + dir_created_now = True except FileExistsError: - raise FileExistsError(f"Credential '{name}' already exists for {service}") - else: + # Directory already exists, which is fine for creating credentials.json + # if it doesn't exist yet, or if we are overwriting (though POST usually means new) + pass + except Exception as e: + raise ValueError(f"Could not create directory {creds_dir}: {e}") + + file_path = creds_dir / 'credentials.json' + if file_path.exists() and request.method == 'POST': # type: ignore + # Safety check for POST to not overwrite if file exists unless it's an edit (PUT) + raise FileExistsError(f"Credential file {file_path} already exists. Use PUT to modify.") + + # Write the credential file first + try: + with open(file_path, 'w') as f: + json.dump(data, f, indent=4) + file_created_now = True # Mark as created for potential cleanup + except Exception as e: + if dir_created_now: # Cleanup directory if file write failed + try: + creds_dir.rmdir() + except OSError: # rmdir fails if not empty, though it should be + pass + raise ValueError(f"Could not write credential file {file_path}: {e}") + + # --- Validation Step --- + try: + _validate_with_retry( + service_name=service, + account_name=name, + creds_dir_path=creds_dir, + cred_file_path=file_path, + data_for_validation=data, # 'data' contains the arl for Deezer + is_spotify=(service == 'spotify') + ) + except ValueError as val_err: # Catch the specific error from our helper + print(f"ERROR: Credential validation failed during creation for {service} account {name}: {val_err}") + traceback.print_exc() # Print full traceback here for creation failure context + # Clean up the created file and directory if validation fails + if file_created_now: + try: + file_path.unlink(missing_ok=True) + except OSError: + pass # Ignore if somehow already gone + if dir_created_now and not any(creds_dir.iterdir()): # Only remove if empty + try: + creds_dir.rmdir() + except OSError: + pass + raise # Re-raise the ValueError from validation + + elif cred_type == 'search': # Spotify only # For search.json, ensure the directory exists (it should if credentials.json exists) if not creds_dir.exists(): - raise FileNotFoundError(f"Credential '{name}' not found for {service}") - - # Write credentials file - file_path = creds_dir / f'{cred_type}.json' - with open(file_path, 'w') as f: - json.dump(data, f, indent=4) + # This implies credentials.json was not created first, which is an issue. + # However, the form logic might allow adding API creds to an existing empty dir. + # For now, let's create it if it's missing, assuming API creds can be standalone. + try: + creds_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + raise ValueError(f"Could not create directory for search credentials {creds_dir}: {e}") + + file_path = creds_dir / 'search.json' + # No specific validation for client_id/secret themselves, they are validated in use. + with open(file_path, 'w') as f: + json.dump(data, f, indent=4) def delete_credential(service, name, cred_type=None): """ @@ -145,7 +283,7 @@ def delete_credential(service, name, cred_type=None): Raises: FileNotFoundError: If the credential directory or specified file does not exist """ - creds_dir = Path('./creds') / service / name + creds_dir = Path('./data/creds') / service / name if cred_type: if cred_type not in ['credentials', 'search']: @@ -193,23 +331,29 @@ def edit_credential(service, name, new_data, cred_type='credentials'): raise ValueError("Search credentials are only supported for Spotify") # Get file path - creds_dir = Path('./creds') / service / name + creds_dir = Path('./data/creds') / service / name file_path = creds_dir / f'{cred_type}.json' - # For search.json, create if it doesn't exist - if cred_type == 'search' and not file_path.exists(): - if not creds_dir.exists(): - raise FileNotFoundError(f"Credential '{name}' not found for {service}") - data = {} - else: - # Load existing data - if not file_path.exists(): - raise FileNotFoundError(f"{cred_type.capitalize()} credential '{name}' not found for {service}") - + original_data_str = None # Store original data as string to revert + file_existed_before_edit = file_path.exists() + + if file_existed_before_edit: with open(file_path, 'r') as f: - data = json.load(f) - - # Validate new_data fields + original_data_str = f.read() + try: + data = json.loads(original_data_str) + except json.JSONDecodeError: + # If existing file is corrupt, treat as if we are creating it anew for edit + data = {} + original_data_str = None # Can't revert to corrupt data + else: + # If file doesn't exist, and we're editing (PUT), it's usually an error + # unless it's for search.json which can be created during an edit flow. + if cred_type == 'credentials': + raise FileNotFoundError(f"Cannot edit non-existent credentials file: {file_path}") + data = {} # Start with empty data for search.json creation + + # Validate new_data fields (data to be merged) allowed_fields = [] if cred_type == 'credentials': if service == 'spotify': @@ -223,15 +367,66 @@ def edit_credential(service, name, new_data, cred_type='credentials'): if key not in allowed_fields: raise ValueError(f"Invalid field '{key}' for {cred_type} credentials") - # Update data + # Update data (merging new_data into existing or empty data) data.update(new_data) - # For Deezer: Strip all fields except 'arl' + # --- Write and Validate Step for 'credentials' type --- + if cred_type == 'credentials': + try: + # Temporarily write new data for validation + with open(file_path, 'w') as f: + json.dump(data, f, indent=4) + + _validate_with_retry( + service_name=service, + account_name=name, + creds_dir_path=creds_dir, + cred_file_path=file_path, + data_for_validation=data, # 'data' is the merged data with 'arl' for Deezer + is_spotify=(service == 'spotify') + ) + except ValueError as val_err: # Catch the specific error from our helper + print(f"ERROR: Edited credential validation failed for {service} account {name}: {val_err}") + traceback.print_exc() # Print full traceback here for edit failure context + # Revert or delete the file + if original_data_str is not None: + with open(file_path, 'w') as f: + f.write(original_data_str) # Restore original content + elif file_existed_before_edit: # file existed but original_data_str is None (corrupt) + pass + else: # File didn't exist before this edit attempt, so remove it + try: + file_path.unlink(missing_ok=True) + except OSError: + pass # Ignore if somehow already gone + raise # Re-raise the ValueError from validation + except Exception as e: # Catch other potential errors like file IO during temp write + print(f"ERROR: Unexpected error during edit/validation for {service} account {name}: {e}") + traceback.print_exc() + # Attempt revert/delete + if original_data_str is not None: + with open(file_path, 'w') as f: f.write(original_data_str) + elif file_existed_before_edit: + pass + else: + try: + file_path.unlink(missing_ok=True) + except OSError: pass + raise ValueError(f"Failed to save edited {service} credentials due to: {str(e).splitlines()[-1]}") + + # For 'search' type, just write, no specific validation here for client_id/secret + elif cred_type == 'search': + if not creds_dir.exists(): # Should not happen if we're editing + raise FileNotFoundError(f"Credential directory {creds_dir} not found for editing search credentials.") + with open(file_path, 'w') as f: + json.dump(data, f, indent=4) # `data` here is the merged data for search + + # For Deezer: Strip all fields except 'arl' - This should use `data` which is `updated_data` if service == 'deezer' and cred_type == 'credentials': if 'arl' not in data: - raise ValueError("Missing 'arl' field for Deezer credential") + raise ValueError("Missing 'arl' field for Deezer credential after edit.") data = {'arl': data['arl']} - + # Ensure required fields are present required_fields = [] if cred_type == 'credentials': diff --git a/routes/utils/get_info.py b/routes/utils/get_info.py index 602801e..6f35bd1 100644 --- a/routes/utils/get_info.py +++ b/routes/utils/get_info.py @@ -29,7 +29,7 @@ def get_spotify_info(spotify_id, spotify_type): raise ValueError("No Spotify account configured in settings") if spotify_id: - search_creds_path = Path(f'./creds/spotify/{main}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{main}/search.json') if search_creds_path.exists(): try: with open(search_creds_path, 'r') as f: diff --git a/routes/utils/playlist.py b/routes/utils/playlist.py index 6830460..1b16df1 100755 --- a/routes/utils/playlist.py +++ b/routes/utils/playlist.py @@ -47,11 +47,11 @@ def download_playlist( # Smartly determine where to look for Spotify search credentials if service == 'spotify' and fallback: # If fallback is enabled, use the fallback account for Spotify search credentials - search_creds_path = Path(f'./creds/spotify/{fallback}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{fallback}/search.json') print(f"DEBUG: Using Spotify search credentials from fallback: {search_creds_path}") else: # Otherwise use the main account for Spotify search credentials - search_creds_path = Path(f'./creds/spotify/{main}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{main}/search.json') print(f"DEBUG: Using Spotify search credentials from main: {search_creds_path}") if search_creds_path.exists(): @@ -77,7 +77,7 @@ def download_playlist( deezer_error = None try: # Load Deezer credentials from 'main' under deezer directory - deezer_creds_dir = os.path.join('./creds/deezer', main) + deezer_creds_dir = os.path.join('./data/creds/deezer', main) deezer_creds_path = os.path.abspath(os.path.join(deezer_creds_dir, 'credentials.json')) # DEBUG: Print Deezer credential paths being used @@ -124,7 +124,7 @@ def download_playlist( # Load fallback Spotify credentials and attempt download try: - spo_creds_dir = os.path.join('./creds/spotify', fallback) + spo_creds_dir = os.path.join('./data/creds/spotify', fallback) spo_creds_path = os.path.abspath(os.path.join(spo_creds_dir, 'credentials.json')) print(f"DEBUG: Using Spotify fallback credentials from: {spo_creds_path}") @@ -168,7 +168,7 @@ def download_playlist( # Original behavior: use Spotify main if quality is None: quality = 'HIGH' - creds_dir = os.path.join('./creds/spotify', main) + creds_dir = os.path.join('./data/creds/spotify', main) credentials_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json')) print(f"DEBUG: Using Spotify main credentials from: {credentials_path}") print(f"DEBUG: Credentials exist: {os.path.exists(credentials_path)}") @@ -203,7 +203,7 @@ def download_playlist( if quality is None: quality = 'FLAC' # Existing code for Deezer, using main as Deezer account. - creds_dir = os.path.join('./creds/deezer', main) + creds_dir = os.path.join('./data/creds/deezer', main) creds_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json')) print(f"DEBUG: Using Deezer credentials from: {creds_path}") print(f"DEBUG: Credentials exist: {os.path.exists(creds_path)}") diff --git a/routes/utils/search.py b/routes/utils/search.py index 6ea5a0e..a0d20f6 100755 --- a/routes/utils/search.py +++ b/routes/utils/search.py @@ -19,7 +19,7 @@ def search( client_secret = None if main: - search_creds_path = Path(f'./creds/spotify/{main}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{main}/search.json') logger.debug(f"Looking for credentials at: {search_creds_path}") if search_creds_path.exists(): diff --git a/routes/utils/track.py b/routes/utils/track.py index ffa84aa..1ae1853 100755 --- a/routes/utils/track.py +++ b/routes/utils/track.py @@ -47,11 +47,11 @@ def download_track( # Smartly determine where to look for Spotify search credentials if service == 'spotify' and fallback: # If fallback is enabled, use the fallback account for Spotify search credentials - search_creds_path = Path(f'./creds/spotify/{fallback}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{fallback}/search.json') print(f"DEBUG: Using Spotify search credentials from fallback: {search_creds_path}") else: # Otherwise use the main account for Spotify search credentials - search_creds_path = Path(f'./creds/spotify/{main}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{main}/search.json') print(f"DEBUG: Using Spotify search credentials from main: {search_creds_path}") if search_creds_path.exists(): @@ -76,7 +76,7 @@ def download_track( # First attempt: use Deezer's download_trackspo with 'main' (Deezer credentials) deezer_error = None try: - deezer_creds_dir = os.path.join('./creds/deezer', main) + deezer_creds_dir = os.path.join('./data/creds/deezer', main) deezer_creds_path = os.path.abspath(os.path.join(deezer_creds_dir, 'credentials.json')) # DEBUG: Print Deezer credential paths being used @@ -88,8 +88,8 @@ def download_track( # List available directories to compare print(f"DEBUG: Available Deezer credential directories:") - for dir_name in os.listdir('./creds/deezer'): - print(f"DEBUG: ./creds/deezer/{dir_name}") + for dir_name in os.listdir('./data/creds/deezer'): + print(f"DEBUG: ./data/creds/deezer/{dir_name}") with open(deezer_creds_path, 'r') as f: deezer_creds = json.load(f) @@ -122,7 +122,7 @@ def download_track( # If the first attempt fails, use the fallback Spotify credentials try: - spo_creds_dir = os.path.join('./creds/spotify', fallback) + spo_creds_dir = os.path.join('./data/creds/spotify', fallback) spo_creds_path = os.path.abspath(os.path.join(spo_creds_dir, 'credentials.json')) # We've already loaded the Spotify client credentials above based on fallback @@ -159,7 +159,7 @@ def download_track( # Directly use Spotify main account if quality is None: quality = 'HIGH' - creds_dir = os.path.join('./creds/spotify', main) + creds_dir = os.path.join('./data/creds/spotify', main) credentials_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json')) spo = SpoLogin( credentials_path=credentials_path, @@ -188,7 +188,7 @@ def download_track( if quality is None: quality = 'FLAC' # Deezer download logic remains unchanged, with the custom formatting parameters passed along. - creds_dir = os.path.join('./creds/deezer', main) + creds_dir = os.path.join('./data/creds/deezer', main) creds_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json')) with open(creds_path, 'r') as f: creds = json.load(f) diff --git a/src/js/config.ts b/src/js/config.ts index 2c13d56..86556b4 100644 --- a/src/js/config.ts +++ b/src/js/config.ts @@ -53,6 +53,24 @@ let isEditingSearch = false; let activeSpotifyAccount = ''; let activeDeezerAccount = ''; +// Reference to the credentials form card and add button +let credentialsFormCard: HTMLElement | null = null; +let showAddAccountFormBtn: HTMLElement | null = null; +let cancelAddAccountBtn: HTMLElement | null = null; + +// Helper function to manage visibility of form and add button +function setFormVisibility(showForm: boolean) { + if (credentialsFormCard && showAddAccountFormBtn) { + credentialsFormCard.style.display = showForm ? 'block' : 'none'; + showAddAccountFormBtn.style.display = showForm ? 'none' : 'flex'; // Assuming flex for styled button + if (showForm) { + resetForm(); // Reset form to "add new" state when showing for add + const credentialNameInput = document.getElementById('credentialName') as HTMLInputElement | null; + if(credentialNameInput) credentialNameInput.focus(); + } + } +} + async function loadConfig() { try { const response = await fetch('/api/config'); @@ -75,6 +93,8 @@ async function loadConfig() { // 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; + const spotifyMessage = document.getElementById('spotifyAccountMessage') as HTMLElement | null; + const deezerMessage = document.getElementById('deezerAccountMessage') as HTMLElement | null; if (spotifySelect) spotifySelect.value = activeSpotifyAccount; if (deezerSelect) deezerSelect.value = activeDeezerAccount; @@ -115,6 +135,30 @@ document.addEventListener('DOMContentLoaded', async () => { setupServiceTabs(); setupEventListeners(); + // Setup for the collapsable "Add Account" form + credentialsFormCard = document.querySelector('.credentials-form.card'); + showAddAccountFormBtn = document.getElementById('showAddAccountFormBtn'); + cancelAddAccountBtn = document.getElementById('cancelAddAccountBtn'); + + if (credentialsFormCard && showAddAccountFormBtn) { + // Initially hide form, show add button (default state handled by setFormVisibility if called) + credentialsFormCard.style.display = 'none'; + showAddAccountFormBtn.style.display = 'flex'; // Assuming styled button uses flex + } + + if (showAddAccountFormBtn) { + showAddAccountFormBtn.addEventListener('click', () => { + setFormVisibility(true); + }); + } + + if (cancelAddAccountBtn && credentialsFormCard && showAddAccountFormBtn) { + cancelAddAccountBtn.addEventListener('click', () => { + setFormVisibility(false); + resetForm(); // Also reset form state on cancel + }); + } + const queueIcon = document.getElementById('queueIcon'); if (queueIcon) { queueIcon.addEventListener('click', () => { @@ -231,46 +275,65 @@ async function updateAccountSelectors() { // Get the select elements const spotifySelect = document.getElementById('spotifyAccountSelect') as HTMLSelectElement | null; const deezerSelect = document.getElementById('deezerAccountSelect') as HTMLSelectElement | null; + const spotifyMessage = document.getElementById('spotifyAccountMessage') as HTMLElement | null; + const deezerMessage = document.getElementById('deezerAccountMessage') as HTMLElement | null; // Rebuild the Spotify selector options - if (spotifySelect) { - spotifySelect.innerHTML = spotifyAccounts - .map((a: string) => ``) - .join(''); + if (spotifySelect && spotifyMessage) { + if (spotifyAccounts.length > 0) { + spotifySelect.innerHTML = spotifyAccounts + .map((a: string) => ``) + .join(''); + spotifySelect.style.display = ''; + spotifyMessage.style.display = 'none'; - // 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(); + // Use the active account loaded from the config (activeSpotifyAccount) + if (activeSpotifyAccount && spotifyAccounts.includes(activeSpotifyAccount)) { + spotifySelect.value = activeSpotifyAccount; + } else { + spotifySelect.value = spotifyAccounts[0]; + activeSpotifyAccount = spotifyAccounts[0]; + await saveConfig(); // Save if we defaulted + } + } else { + spotifySelect.innerHTML = ''; + spotifySelect.style.display = 'none'; + spotifyMessage.textContent = 'No Spotify accounts available.'; + spotifyMessage.style.display = ''; + if (activeSpotifyAccount !== '') { // Clear active account if it was set + activeSpotifyAccount = ''; + await saveConfig(); + } } } // Rebuild the Deezer selector options - if (deezerSelect) { - deezerSelect.innerHTML = deezerAccounts - .map((a: string) => ``) - .join(''); + if (deezerSelect && deezerMessage) { + if (deezerAccounts.length > 0) { + deezerSelect.innerHTML = deezerAccounts + .map((a: string) => ``) + .join(''); + deezerSelect.style.display = ''; + deezerMessage.style.display = 'none'; - if (deezerAccounts.includes(activeDeezerAccount)) { - deezerSelect.value = activeDeezerAccount; - } else if (deezerAccounts.length > 0) { - deezerSelect.value = deezerAccounts[0]; - activeDeezerAccount = deezerAccounts[0]; - await saveConfig(); + if (activeDeezerAccount && deezerAccounts.includes(activeDeezerAccount)) { + deezerSelect.value = activeDeezerAccount; + } else { + deezerSelect.value = deezerAccounts[0]; + activeDeezerAccount = deezerAccounts[0]; + await saveConfig(); // Save if we defaulted + } + } else { + deezerSelect.innerHTML = ''; + deezerSelect.style.display = 'none'; + deezerMessage.textContent = 'No Deezer accounts available.'; + deezerMessage.style.display = ''; + if (activeDeezerAccount !== '') { // Clear active account if it was set + activeDeezerAccount = ''; + 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); } @@ -291,7 +354,7 @@ async function loadCredentials(service: string) { } function renderCredentialsList(service: string, credentials: any[]) { - const list = document.querySelector('.credentials-list') as HTMLElement | null; + const list = document.querySelector('.credentials-list-items') as HTMLElement | null; if (!list) return; list.innerHTML = ''; @@ -396,6 +459,8 @@ async function handleEditCredential(e: MouseEvent) { (document.querySelector(`[data-service="${service}"]`) as HTMLElement | null)?.click(); await new Promise(resolve => setTimeout(resolve, 50)); + setFormVisibility(true); // Show form for editing, will hide add button + const response = await fetch(`/api/credentials/${service}/${name}`); if (!response.ok) { throw new Error(`Failed to load credential: ${response.statusText}`); @@ -430,6 +495,8 @@ async function handleEditSearchCredential(e: Event) { throw new Error('Search credentials are only available for Spotify'); } + setFormVisibility(true); // Show form for editing search creds, will hide add button + (document.querySelector(`[data-service="${service}"]`) as HTMLElement | null)?.click(); await new Promise(resolve => setTimeout(resolve, 50)); @@ -662,7 +729,7 @@ async function handleCredentialSubmit(e: Event) { await updateAccountSelectors(); await saveConfig(); loadCredentials(service!); - resetForm(); + setFormVisibility(false); // Hide form and show add button on successful submission // Show success message showConfigSuccess(isEditingSearch ? 'API credentials saved successfully' : 'Account saved successfully'); diff --git a/src/js/queue.ts b/src/js/queue.ts index f7d8d2f..828124e 100644 --- a/src/js/queue.ts +++ b/src/js/queue.ts @@ -215,7 +215,7 @@ export class DownloadQueue {

Download Queue (0 items)

@@ -249,7 +249,7 @@ export class DownloadQueue { const queueIcon = document.getElementById('queueIcon'); if (queueIcon && this.config) { if (this.config.downloadQueueVisible) { - queueIcon.innerHTML = '×'; + queueIcon.innerHTML = 'Close queue'; queueIcon.setAttribute('aria-expanded', 'true'); queueIcon.classList.add('queue-icon-active'); // Add red tint class } else { @@ -329,7 +329,7 @@ export class DownloadQueue { if (queueIcon && this.config) { if (isVisible) { // Replace the image with an X and add red tint - queueIcon.innerHTML = '×'; + queueIcon.innerHTML = 'Close queue'; queueIcon.setAttribute('aria-expanded', 'true'); queueIcon.classList.add('queue-icon-active'); // Add red tint class } else { @@ -356,13 +356,13 @@ export class DownloadQueue { // Also revert the icon back if (queueIcon && this.config) { if (!isVisible) { - queueIcon.innerHTML = '×'; + queueIcon.innerHTML = 'Close queue'; queueIcon.setAttribute('aria-expanded', 'true'); queueIcon.classList.add('queue-icon-active'); // Add red tint class } else { - queueIcon.innerHTML = 'Queue Icon'; - queueIcon.setAttribute('aria-expanded', 'false'); - queueIcon.classList.remove('queue-icon-active'); // Remove red tint class + queueIcon.innerHTML = 'Close queue'; + queueIcon.setAttribute('aria-expanded', 'true'); + queueIcon.classList.add('queue-icon-active'); // Add red tint class } } this.dispatchEvent('queueVisibilityChanged', { visible: !isVisible }); @@ -658,7 +658,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
${displayType}
@@ -2179,7 +2179,9 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) errorLogElement.innerHTML = `
${errMsg}
- +
`; diff --git a/static/css/config/config.css b/static/css/config/config.css index 23e7b57..36ab501 100644 --- a/static/css/config/config.css +++ b/static/css/config/config.css @@ -928,3 +928,78 @@ input:checked + .slider:before { opacity: 1; transform: translateY(0); } + +/* Credentials List Wrapper */ +.credentials-list-wrapper { + background: #181818; /* Same as original .credentials-list.card */ + border-radius: 12px; + box-shadow: 0 4px 8px rgba(0,0,0,0.15); + padding: 1.5rem; /* Add padding here if you want it around the whole block */ + margin-bottom: 2rem; +} + +/* Where individual credential items will be rendered */ +.credentials-list-items { + /* No specific styles needed here unless items need separation from the add button */ +} + +/* Styling for the Add New Account button to make it look like a list item */ +.add-account-item { + margin-top: 0.75rem; /* Space above the add button if there are items */ +} + +.btn-add-account-styled { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 1.25rem; + background-color: #1db954; /* Green background */ + color: #ffffff; + border: none; + border-radius: 8px; /* Same as credential-item */ + font-size: 1.1rem; /* Similar to credential-name */ + font-weight: 600; + cursor: pointer; + transition: background-color 0.3s ease, transform 0.2s ease; + text-align: center; + opacity: 1; /* Ensure it's not transparent by default */ +} + +.btn-add-account-styled img { + width: 20px; /* Adjust as needed */ + height: 20px; /* Adjust as needed */ + margin-right: 10px; + filter: brightness(0) invert(1); /* Make icon white if it's not already */ +} + +.btn-add-account-styled:hover { + background-color: #1aa34a; /* Darker green on hover */ + transform: translateY(-1px); +} + +/* New styles for the icon-based cancel button */ +.btn-cancel-icon { + background-color: #c0392b !important; /* Red background */ + padding: 0.6rem !important; /* Adjust padding for icon */ + width: auto; /* Allow button to size to icon */ + min-width: 40px; /* Ensure a minimum touch target size */ + height: 40px; /* Ensure a minimum touch target size */ + display: flex; + align-items: center; + justify-content: center; + border-radius: 50% !important; /* Make it circular */ + opacity: 1 !important; /* Ensure it's always visible when its container is */ + visibility: visible !important; /* Ensure it's not hidden by visibility property */ +} + +.btn-cancel-icon img { + width: 16px; /* Adjust icon size as needed */ + height: 16px; + filter: brightness(0) invert(1); /* Make icon white */ +} + +.btn-cancel-icon:hover { + background-color: #e74c3c !important; /* Lighter red on hover */ + transform: translateY(-2px) scale(1.05); +} diff --git a/static/images/cross.svg b/static/images/cross.svg new file mode 100644 index 0000000..cc2cd18 --- /dev/null +++ b/static/images/cross.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/static/images/plus-circle.svg b/static/images/plus-circle.svg new file mode 100644 index 0000000..05357df --- /dev/null +++ b/static/images/plus-circle.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/static/images/skull-head.svg b/static/images/skull-head.svg new file mode 100644 index 0000000..96b0922 --- /dev/null +++ b/static/images/skull-head.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/templates/config.html b/templates/config.html index 7cd30b7..2f6c857 100644 --- a/templates/config.html +++ b/templates/config.html @@ -32,6 +32,7 @@
+
@@ -44,6 +45,7 @@
+
@@ -229,7 +231,18 @@
-
+ +
+
+ + +
+ +

Add New Spotify Account

@@ -241,6 +254,9 @@
+
From ee261c28f498a45b642d621dc1d9bd69ba07fc31 Mon Sep 17 00:00:00 2001 From: "cool.gitter.not.me.again.duh" Date: Wed, 28 May 2025 15:31:58 -0600 Subject: [PATCH 06/18] huh? --- .dockerignore | 3 +- .gitignore | 1 + app.py | 4 +- docker-compose.yaml | 3 +- routes/utils/celery_tasks.py | 23 ++++++++ static/css/config/config.css | 71 +---------------------- {templates => static/html}/album.html | 0 {templates => static/html}/artist.html | 0 {templates => static/html}/config.html | 1 + {templates => static/html}/favicon.ico | Bin {templates => static/html}/main.html | 0 {templates => static/html}/playlist.html | 0 {templates => static/html}/track.html | 0 13 files changed, 32 insertions(+), 74 deletions(-) rename {templates => static/html}/album.html (100%) rename {templates => static/html}/artist.html (100%) rename {templates => static/html}/config.html (99%) rename {templates => static/html}/favicon.ico (100%) rename {templates => static/html}/main.html (100%) rename {templates => static/html}/playlist.html (100%) rename {templates => static/html}/track.html (100%) diff --git a/.dockerignore b/.dockerignore index 6c1fe0a..6ed6843 100755 --- a/.dockerignore +++ b/.dockerignore @@ -18,4 +18,5 @@ output.log queue_state.json search_demo.py celery_worker.log -static/js/* \ No newline at end of file +static/js/* +logs/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5ac9910..7f9e133 100755 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ logs/spotizerr.log /.venv static/js data +logs/ \ No newline at end of file diff --git a/app.py b/app.py index 2b87998..27b0b41 100755 --- a/app.py +++ b/app.py @@ -131,7 +131,7 @@ def check_redis_connection(): return False def create_app(): - app = Flask(__name__) + app = Flask(__name__, template_folder='static/html') # Set up CORS CORS(app) @@ -184,7 +184,7 @@ def create_app(): # Serve favicon.ico from the same directory as index.html (templates) @app.route('/favicon.ico') def serve_favicon(): - return send_from_directory('templates', 'favicon.ico') + return send_from_directory('static/html', 'favicon.ico') # Add request logging middleware @app.before_request diff --git a/docker-compose.yaml b/docker-compose.yaml index a6d4ddb..44d56d9 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -3,8 +3,7 @@ name: spotizerr services: spotizerr: volumes: - - ./creds:/app/creds - - ./config:/app/config + - ./data:/app/data - ./downloads:/app/downloads # <-- Change this for your music library dir - ./logs:/app/logs # <-- Volume for persistent logs ports: diff --git a/routes/utils/celery_tasks.py b/routes/utils/celery_tasks.py index 5565752..8259b9a 100644 --- a/routes/utils/celery_tasks.py +++ b/routes/utils/celery_tasks.py @@ -7,6 +7,8 @@ from datetime import datetime from celery import Celery, Task, states from celery.signals import task_prerun, task_postrun, task_failure, worker_ready, worker_init, setup_logging from celery.exceptions import Retry +import os # Added for path operations +from pathlib import Path # Added for path operations # Configure logging logger = logging.getLogger(__name__) @@ -358,6 +360,27 @@ class ProgressTrackingTask(Task): """ task_id = self.request.id + # Ensure ./logs/tasks directory exists + logs_tasks_dir = Path('./logs/tasks') # Using relative path as per your update + try: + logs_tasks_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + logger.error(f"Task {task_id}: Could not create log directory {logs_tasks_dir}: {e}") + + # Define log file path + log_file_path = logs_tasks_dir / f"{task_id}.log" + + # Log progress_data to the task-specific file + try: + with open(log_file_path, 'a') as log_file: + # Add a timestamp to the log entry if not present, for consistency in the file + log_entry = progress_data.copy() + if 'timestamp' not in log_entry: + log_entry['timestamp'] = time.time() + print(json.dumps(log_entry), file=log_file) # Use print to file + except Exception as e: + logger.error(f"Task {task_id}: Could not write to task log file {log_file_path}: {e}") + # Add timestamp if not present if 'timestamp' not in progress_data: progress_data['timestamp'] = time.time() diff --git a/static/css/config/config.css b/static/css/config/config.css index 36ab501..81b1599 100644 --- a/static/css/config/config.css +++ b/static/css/config/config.css @@ -95,55 +95,6 @@ body { margin: 0; } -/* Queue button as floating icon */ -.queue-icon.floating-icon { - position: fixed; - width: 56px; - height: 56px; - bottom: 20px; - right: 20px; - background-color: var(--color-primary); - border-radius: 50%; - box-shadow: var(--shadow-lg); - z-index: 9999; - display: flex; - align-items: center; - justify-content: center; - transition: transform 0.2s ease, background-color 0.2s ease; - text-decoration: none !important; -} - -.queue-icon.floating-icon:hover { - background-color: var(--color-primary-hover); - transform: scale(1.05); -} - -.queue-icon.floating-icon img { - width: 24px; - height: 24px; - filter: brightness(0) invert(1); - margin: 0; -} - -/* Queue Icon Active State */ -.queue-icon.queue-icon-active { - background-color: #d13838 !important; - transition: background-color 0.3s ease; -} - -.queue-icon.queue-icon-active:hover { - background-color: #e04c4c !important; -} - -.queue-icon .queue-x { - font-size: 28px; - font-weight: bold; - color: white; - line-height: 24px; - display: inline-block; - transform: translateY(-2px); -} - /* Queue Sidebar for Config Page */ #downloadQueue { position: fixed; @@ -849,25 +800,7 @@ input:checked + .slider:before { width: 28px; height: 28px; } - - /* Queue icon mobile styles */ - .queue-icon.floating-icon { - width: 50px; - height: 50px; - right: 16px; - bottom: 16px; - } - - .queue-icon.floating-icon img { - width: 22px; - height: 22px; - } - - .queue-icon .queue-x { - font-size: 24px; - line-height: 20px; - } -} + /* Format help styles */ .format-help { @@ -985,7 +918,6 @@ input:checked + .slider:before { width: auto; /* Allow button to size to icon */ min-width: 40px; /* Ensure a minimum touch target size */ height: 40px; /* Ensure a minimum touch target size */ - display: flex; align-items: center; justify-content: center; border-radius: 50% !important; /* Make it circular */ @@ -1003,3 +935,4 @@ input:checked + .slider:before { background-color: #e74c3c !important; /* Lighter red on hover */ transform: translateY(-2px) scale(1.05); } +} \ No newline at end of file diff --git a/templates/album.html b/static/html/album.html similarity index 100% rename from templates/album.html rename to static/html/album.html diff --git a/templates/artist.html b/static/html/artist.html similarity index 100% rename from templates/artist.html rename to static/html/artist.html diff --git a/templates/config.html b/static/html/config.html similarity index 99% rename from templates/config.html rename to static/html/config.html index 2f6c857..42d65db 100644 --- a/templates/config.html +++ b/static/html/config.html @@ -7,6 +7,7 @@ + diff --git a/templates/favicon.ico b/static/html/favicon.ico similarity index 100% rename from templates/favicon.ico rename to static/html/favicon.ico diff --git a/templates/main.html b/static/html/main.html similarity index 100% rename from templates/main.html rename to static/html/main.html diff --git a/templates/playlist.html b/static/html/playlist.html similarity index 100% rename from templates/playlist.html rename to static/html/playlist.html diff --git a/templates/track.html b/static/html/track.html similarity index 100% rename from templates/track.html rename to static/html/track.html From 5f3e78e5f4b76686f0e5fabd726d995ea247d2a7 Mon Sep 17 00:00:00 2001 From: "cool.gitter.not.me.again.duh" Date: Wed, 28 May 2025 23:32:45 -0600 Subject: [PATCH 07/18] 2.0 is coming --- .env | 2 +- app.py | 12 +- routes/__init__.py | 24 + routes/album.py | 8 +- routes/artist.py | 227 ++++++++- routes/config.py | 71 ++- routes/playlist.py | 208 +++++++- routes/track.py | 10 +- routes/utils/celery_queue_manager.py | 161 +++--- routes/utils/celery_tasks.py | 18 + routes/utils/get_info.py | 13 +- routes/utils/watch/db.py | 703 +++++++++++++++++++++++++++ routes/utils/watch/manager.py | 415 ++++++++++++++++ src/js/album.ts | 35 +- src/js/artist.ts | 571 ++++++++++++++++------ src/js/config.ts | 64 +++ src/js/main.ts | 21 +- src/js/playlist.ts | 319 ++++++++++-- src/js/queue.ts | 39 +- src/js/track.ts | 17 +- static/css/artist/artist.css | 96 ++++ static/css/config/config.css | 52 +- static/css/playlist/playlist.css | 100 ++++ static/html/artist.html | 5 + static/html/config.html | 35 ++ static/html/playlist.html | 8 + static/images/check.svg | 4 + static/images/eye-crossed.svg | 5 + static/images/eye.svg | 4 + static/images/missing.svg | 12 + static/images/refresh.svg | 4 + 31 files changed, 2897 insertions(+), 366 deletions(-) create mode 100644 routes/utils/watch/db.py create mode 100644 routes/utils/watch/manager.py create mode 100644 static/images/check.svg create mode 100644 static/images/eye-crossed.svg create mode 100644 static/images/eye.svg create mode 100644 static/images/missing.svg create mode 100644 static/images/refresh.svg diff --git a/.env b/.env index 1ca1a5d..8373b07 100644 --- a/.env +++ b/.env @@ -1,7 +1,7 @@ # Docker Compose environment variables # Redis connection (external or internal) -REDIS_HOST=redis +REDIS_HOST=localhost REDIS_PORT=6379 REDIS_DB=0 REDIS_PASSWORD=CHANGE_ME diff --git a/app.py b/app.py index 27b0b41..133ad18 100755 --- a/app.py +++ b/app.py @@ -38,6 +38,10 @@ def setup_logging(): root_logger = logging.getLogger() root_logger.setLevel(logging.INFO) + # Clear any existing handlers from the root logger + if root_logger.hasHandlers(): + root_logger.handlers.clear() + # Log formatting log_format = logging.Formatter( '%(asctime)s [%(processName)s:%(threadName)s] [%(name)s] [%(levelname)s] - %(message)s', @@ -141,10 +145,10 @@ def create_app(): app.register_blueprint(search_bp, url_prefix='/api') app.register_blueprint(credentials_bp, url_prefix='/api/credentials') app.register_blueprint(album_bp, url_prefix='/api/album') - app.register_blueprint(track_bp, url_prefix='/api/track') + app.register_blueprint(track_bp, url_prefix='/api/track') app.register_blueprint(playlist_bp, url_prefix='/api/playlist') app.register_blueprint(artist_bp, url_prefix='/api/artist') - app.register_blueprint(prgs_bp, url_prefix='/api/prgs') + app.register_blueprint(prgs_bp, url_prefix='/api/prgs') # Serve frontend @app.route('/') @@ -230,6 +234,10 @@ if __name__ == '__main__': # Check Redis connection before starting workers if check_redis_connection(): + # Start Watch Manager + from routes.utils.watch.manager import start_watch_manager + start_watch_manager() + # Start Celery workers start_celery_workers() diff --git a/routes/__init__.py b/routes/__init__.py index e69de29..9bde965 100755 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -0,0 +1,24 @@ +import logging +import atexit + +# Configure basic logging for the application if not already configured +# This is a good place for it if routes are a central part of your app structure. +logging.basicConfig(level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') + +logger = logging.getLogger(__name__) + +try: + from routes.utils.watch.manager import start_watch_manager, stop_watch_manager + # Start the playlist watch manager when the application/blueprint is initialized + start_watch_manager() + # Register the stop function to be called on application exit + atexit.register(stop_watch_manager) + logger.info("Playlist Watch Manager initialized and registered for shutdown.") +except ImportError as e: + logger.error(f"Could not import or start Playlist Watch Manager: {e}. Playlist watching will be disabled.") +except Exception as e: + logger.error(f"An unexpected error occurred during Playlist Watch Manager setup: {e}", exc_info=True) + +from .artist import artist_bp +from .prgs import prgs_bp diff --git a/routes/album.py b/routes/album.py index 0a973ca..e2cf496 100755 --- a/routes/album.py +++ b/routes/album.py @@ -9,13 +9,15 @@ from routes.utils.celery_tasks import store_task_info, store_task_status, Progre album_bp = Blueprint('album', __name__) -@album_bp.route('/download', methods=['GET']) -def handle_download(): +@album_bp.route('/download/', methods=['GET']) +def handle_download(album_id): # Retrieve essential parameters from the request. - url = request.args.get('url') name = request.args.get('name') artist = request.args.get('artist') + # Construct the URL from album_id + url = f"https://open.spotify.com/album/{album_id}" + # Validate required parameters if not url: return Response( diff --git a/routes/artist.py b/routes/artist.py index b3b747f..f811250 100644 --- a/routes/artist.py +++ b/routes/artist.py @@ -3,32 +3,53 @@ Artist endpoint blueprint. """ -from flask import Blueprint, Response, request +from flask import Blueprint, Response, request, jsonify import json import os import traceback from routes.utils.celery_queue_manager import download_queue_manager +from routes.utils.artist import download_artist_albums -artist_bp = Blueprint('artist', __name__) +# Imports for merged watch functionality +import logging +import threading +from routes.utils.watch.db import ( + add_artist_to_watch as add_artist_db, + remove_artist_from_watch as remove_artist_db, + get_watched_artist, + get_watched_artists, + add_specific_albums_to_artist_table, + remove_specific_albums_from_artist_table, + is_album_in_artist_db +) +from routes.utils.watch.manager import check_watched_artists +from routes.utils.get_info import get_spotify_info + +artist_bp = Blueprint('artist', __name__, url_prefix='/api/artist') + +# Existing log_json can be used, or a logger instance. +# Let's initialize a logger for consistency with merged code. +logger = logging.getLogger(__name__) def log_json(message_dict): print(json.dumps(message_dict)) -@artist_bp.route('/download', methods=['GET']) -def handle_artist_download(): +@artist_bp.route('/download/', methods=['GET']) +def handle_artist_download(artist_id): """ Enqueues album download tasks for the given artist. Expected query parameters: - - url: string (a Spotify artist URL) - album_type: string(s); comma-separated values such as "album,single,appears_on,compilation" """ + # Construct the artist URL from artist_id + url = f"https://open.spotify.com/artist/{artist_id}" + # Retrieve essential parameters from the request. - url = request.args.get('url') album_type = request.args.get('album_type', "album,single,compilation") # Validate required parameters - if not url: + if not url: # This check is mostly for safety, as url is constructed return Response( json.dumps({"error": "Missing required parameter: url"}), status=400, @@ -37,7 +58,7 @@ def handle_artist_download(): try: # Import and call the updated download_artist_albums() function. - from routes.utils.artist import download_artist_albums + # from routes.utils.artist import download_artist_albums # Already imported at top # Delegate to the download_artist_albums function which will handle album filtering successfully_queued_albums, duplicate_albums = download_artist_albums( @@ -104,6 +125,21 @@ def get_artist_info(): try: from routes.utils.get_info import get_spotify_info artist_info = get_spotify_info(spotify_id, "artist") + + # If artist_info is successfully fetched (it contains album items), + # check if the artist is watched and augment album items with is_locally_known status + if artist_info and artist_info.get('items'): + watched_artist_details = get_watched_artist(spotify_id) # spotify_id is the artist ID + if watched_artist_details: # Artist is being watched + for album_item in artist_info['items']: + if album_item and album_item.get('id'): + album_id = album_item['id'] + album_item['is_locally_known'] = is_album_in_artist_db(spotify_id, album_id) + elif album_item: # Album object exists but no ID + album_item['is_locally_known'] = False + # If not watched, or no albums, is_locally_known will not be added. + # Frontend should handle absence of this key as false. + return Response( json.dumps(artist_info), status=200, @@ -118,3 +154,178 @@ def get_artist_info(): status=500, mimetype='application/json' ) + +# --- Merged Artist Watch Routes --- + +@artist_bp.route('/watch/', methods=['PUT']) +def add_artist_to_watchlist(artist_spotify_id): + """Adds an artist to the watchlist.""" + logger.info(f"Attempting to add artist {artist_spotify_id} to watchlist.") + try: + if get_watched_artist(artist_spotify_id): + return jsonify({"message": f"Artist {artist_spotify_id} is already being watched."}), 200 + + # This call returns an album list-like structure based on logs + artist_album_list_data = get_spotify_info(artist_spotify_id, "artist") + + # Check if we got any data and if it has items + if not artist_album_list_data or not isinstance(artist_album_list_data.get('items'), list): + logger.error(f"Could not fetch album list details for artist {artist_spotify_id} from Spotify using get_spotify_info('artist'). Data: {artist_album_list_data}") + return jsonify({"error": f"Could not fetch sufficient details for artist {artist_spotify_id} to initiate watch."}), 404 + + # Attempt to extract artist name and verify ID + # The actual artist name might be consistently found in the items, if they exist + artist_name_from_albums = "Unknown Artist" # Default + if artist_album_list_data['items']: + first_album = artist_album_list_data['items'][0] + if first_album and isinstance(first_album.get('artists'), list) and first_album['artists']: + # Find the artist in the list that matches the artist_spotify_id + found_artist = next((art for art in first_album['artists'] if art.get('id') == artist_spotify_id), None) + if found_artist and found_artist.get('name'): + artist_name_from_albums = found_artist['name'] + elif first_album['artists'][0].get('name'): # Fallback to first artist if specific match not found or no ID + artist_name_from_albums = first_album['artists'][0]['name'] + logger.warning(f"Could not find exact artist ID {artist_spotify_id} in first album's artists list. Using name '{artist_name_from_albums}'.") + else: + logger.warning(f"No album items found for artist {artist_spotify_id} to extract name. Using default.") + + # Construct the artist_data object expected by add_artist_db + # We use the provided artist_spotify_id as the primary ID. + artist_data_for_db = { + "id": artist_spotify_id, # This is the crucial part + "name": artist_name_from_albums, + "albums": { # Mimic structure if add_artist_db expects it for total_albums + "total": artist_album_list_data.get('total', 0) + } + # Add any other fields add_artist_db might expect from a true artist object if necessary + } + + add_artist_db(artist_data_for_db) + + logger.info(f"Artist {artist_spotify_id} ('{artist_name_from_albums}') added to watchlist. Their albums will be processed by the watch manager.") + return jsonify({"message": f"Artist {artist_spotify_id} added to watchlist. Albums will be processed shortly."}), 201 + except Exception as e: + logger.error(f"Error adding artist {artist_spotify_id} to watchlist: {e}", exc_info=True) + return jsonify({"error": f"Could not add artist to watchlist: {str(e)}"}), 500 + +@artist_bp.route('/watch//status', methods=['GET']) +def get_artist_watch_status(artist_spotify_id): + """Checks if a specific artist is being watched.""" + logger.info(f"Checking watch status for artist {artist_spotify_id}.") + try: + artist = get_watched_artist(artist_spotify_id) + if artist: + return jsonify({"is_watched": True, "artist_data": dict(artist)}), 200 + else: + return jsonify({"is_watched": False}), 200 + except Exception as e: + logger.error(f"Error checking watch status for artist {artist_spotify_id}: {e}", exc_info=True) + return jsonify({"error": f"Could not check watch status: {str(e)}"}), 500 + +@artist_bp.route('/watch/', methods=['DELETE']) +def remove_artist_from_watchlist(artist_spotify_id): + """Removes an artist from the watchlist.""" + logger.info(f"Attempting to remove artist {artist_spotify_id} from watchlist.") + try: + if not get_watched_artist(artist_spotify_id): + return jsonify({"error": f"Artist {artist_spotify_id} not found in watchlist."}), 404 + + remove_artist_db(artist_spotify_id) + logger.info(f"Artist {artist_spotify_id} removed from watchlist successfully.") + return jsonify({"message": f"Artist {artist_spotify_id} removed from watchlist."}), 200 + except Exception as e: + logger.error(f"Error removing artist {artist_spotify_id} from watchlist: {e}", exc_info=True) + return jsonify({"error": f"Could not remove artist from watchlist: {str(e)}"}), 500 + +@artist_bp.route('/watch/list', methods=['GET']) +def list_watched_artists_endpoint(): + """Lists all artists currently in the watchlist.""" + try: + artists = get_watched_artists() + return jsonify([dict(artist) for artist in artists]), 200 + except Exception as e: + logger.error(f"Error listing watched artists: {e}", exc_info=True) + return jsonify({"error": f"Could not list watched artists: {str(e)}"}), 500 + +@artist_bp.route('/watch/trigger_check', methods=['POST']) +def trigger_artist_check_endpoint(): + """Manually triggers the artist checking mechanism for all watched artists.""" + logger.info("Manual trigger for artist check received for all artists.") + try: + thread = threading.Thread(target=check_watched_artists, args=(None,)) + thread.start() + return jsonify({"message": "Artist check triggered successfully in the background for all artists."}), 202 + except Exception as e: + logger.error(f"Error manually triggering artist check for all: {e}", exc_info=True) + return jsonify({"error": f"Could not trigger artist check for all: {str(e)}"}), 500 + +@artist_bp.route('/watch/trigger_check/', methods=['POST']) +def trigger_specific_artist_check_endpoint(artist_spotify_id: str): + """Manually triggers the artist checking mechanism for a specific artist.""" + logger.info(f"Manual trigger for specific artist check received for ID: {artist_spotify_id}") + try: + watched_artist = get_watched_artist(artist_spotify_id) + if not watched_artist: + logger.warning(f"Trigger specific check: Artist ID {artist_spotify_id} not found in watchlist.") + return jsonify({"error": f"Artist {artist_spotify_id} is not in the watchlist. Add it first."}), 404 + + thread = threading.Thread(target=check_watched_artists, args=(artist_spotify_id,)) + thread.start() + logger.info(f"Artist check triggered in background for specific artist ID: {artist_spotify_id}") + return jsonify({"message": f"Artist check triggered successfully in the background for {artist_spotify_id}."}), 202 + except Exception as e: + logger.error(f"Error manually triggering specific artist check for {artist_spotify_id}: {e}", exc_info=True) + return jsonify({"error": f"Could not trigger artist check for {artist_spotify_id}: {str(e)}"}), 500 + +@artist_bp.route('/watch//albums', methods=['POST']) +def mark_albums_as_known_for_artist(artist_spotify_id): + """Fetches details for given album IDs and adds/updates them in the artist's local DB table.""" + logger.info(f"Attempting to mark albums as known for artist {artist_spotify_id}.") + try: + album_ids = request.json + if not isinstance(album_ids, list) or not all(isinstance(aid, str) for aid in album_ids): + return jsonify({"error": "Invalid request body. Expecting a JSON array of album Spotify IDs."}), 400 + + if not get_watched_artist(artist_spotify_id): + return jsonify({"error": f"Artist {artist_spotify_id} is not being watched."}), 404 + + fetched_albums_details = [] + for album_id in album_ids: + try: + # We need full album details. get_spotify_info with type "album" should provide this. + album_detail = get_spotify_info(album_id, "album") + if album_detail and album_detail.get('id'): + fetched_albums_details.append(album_detail) + else: + logger.warning(f"Could not fetch details for album {album_id} when marking as known for artist {artist_spotify_id}.") + except Exception as e: + logger.error(f"Failed to fetch Spotify details for album {album_id}: {e}") + + if not fetched_albums_details: + return jsonify({"message": "No valid album details could be fetched to mark as known.", "processed_count": 0}), 200 + + processed_count = add_specific_albums_to_artist_table(artist_spotify_id, fetched_albums_details) + logger.info(f"Successfully marked/updated {processed_count} albums as known for artist {artist_spotify_id}.") + return jsonify({"message": f"Successfully processed {processed_count} albums for artist {artist_spotify_id}."}), 200 + except Exception as e: + logger.error(f"Error marking albums as known for artist {artist_spotify_id}: {e}", exc_info=True) + return jsonify({"error": f"Could not mark albums as known: {str(e)}"}), 500 + +@artist_bp.route('/watch//albums', methods=['DELETE']) +def mark_albums_as_missing_locally_for_artist(artist_spotify_id): + """Removes specified albums from the artist's local DB table.""" + logger.info(f"Attempting to mark albums as missing (delete locally) for artist {artist_spotify_id}.") + try: + album_ids = request.json + if not isinstance(album_ids, list) or not all(isinstance(aid, str) for aid in album_ids): + return jsonify({"error": "Invalid request body. Expecting a JSON array of album Spotify IDs."}), 400 + + if not get_watched_artist(artist_spotify_id): + return jsonify({"error": f"Artist {artist_spotify_id} is not being watched."}), 404 + + deleted_count = remove_specific_albums_from_artist_table(artist_spotify_id, album_ids) + logger.info(f"Successfully removed {deleted_count} albums locally for artist {artist_spotify_id}.") + return jsonify({"message": f"Successfully removed {deleted_count} albums locally for artist {artist_spotify_id}."}), 200 + except Exception as e: + logger.error(f"Error marking albums as missing (deleting locally) for artist {artist_spotify_id}: {e}", exc_info=True) + return jsonify({"error": f"Could not mark albums as missing: {str(e)}"}), 500 diff --git a/routes/config.py b/routes/config.py index 66e27cb..71a47f5 100644 --- a/routes/config.py +++ b/routes/config.py @@ -8,6 +8,7 @@ import os config_bp = Blueprint('config_bp', __name__) CONFIG_PATH = Path('./data/config/main.json') +CONFIG_PATH_WATCH = Path('./data/config/watch.json') # Flag for config change notifications config_changed = False @@ -63,6 +64,39 @@ def save_config(config_data): logging.error(f"Error saving config: {str(e)}") return False +def get_watch_config(): + """Reads watch.json and returns its content or defaults.""" + try: + if not CONFIG_PATH_WATCH.exists(): + CONFIG_PATH_WATCH.parent.mkdir(parents=True, exist_ok=True) + # Default watch config + defaults = { + 'watchedArtistAlbumGroup': ["album", "single"], + 'watchPollIntervalSeconds': 3600 + } + CONFIG_PATH_WATCH.write_text(json.dumps(defaults, indent=2)) + return defaults + with open(CONFIG_PATH_WATCH, 'r') as f: + return json.load(f) + except Exception as e: + logging.error(f"Error reading watch config: {str(e)}") + # Return defaults on error to prevent crashes + return { + 'watchedArtistAlbumGroup': ["album", "single"], + 'watchPollIntervalSeconds': 3600 + } + +def save_watch_config(watch_config_data): + """Saves data to watch.json.""" + try: + CONFIG_PATH_WATCH.parent.mkdir(parents=True, exist_ok=True) + with open(CONFIG_PATH_WATCH, 'w') as f: + json.dump(watch_config_data, f, indent=2) + return True + except Exception as e: + logging.error(f"Error saving watch config: {str(e)}") + return False + @config_bp.route('/config', methods=['GET']) def handle_config(): config = get_config() @@ -148,4 +182,39 @@ def check_config_changes(): return jsonify({ "changed": has_changed, "last_config": last_config - }) \ No newline at end of file + }) + +@config_bp.route('/config/watch', methods=['GET']) +def handle_watch_config(): + watch_config = get_watch_config() + # Ensure defaults are applied if file was corrupted or missing fields + defaults = { + 'watchedArtistAlbumGroup': ["album", "single"], + 'watchPollIntervalSeconds': 3600 + } + for key, default_value in defaults.items(): + if key not in watch_config: + watch_config[key] = default_value + + return jsonify(watch_config) + +@config_bp.route('/config/watch', methods=['POST', 'PUT']) +def update_watch_config(): + try: + new_watch_config = request.get_json() + if not isinstance(new_watch_config, dict): + return jsonify({"error": "Invalid watch config format"}), 400 + + if not save_watch_config(new_watch_config): + return jsonify({"error": "Failed to save watch config"}), 500 + + updated_watch_config_values = get_watch_config() + if updated_watch_config_values is None: + return jsonify({"error": "Failed to retrieve watch configuration after saving"}), 500 + + return jsonify(updated_watch_config_values) + except json.JSONDecodeError: + return jsonify({"error": "Invalid JSON data for watch config"}), 400 + except Exception as e: + logging.error(f"Error updating watch config: {str(e)}") + return jsonify({"error": "Failed to update watch config"}), 500 \ No newline at end of file diff --git a/routes/playlist.py b/routes/playlist.py index 144e461..9cb6f09 100755 --- a/routes/playlist.py +++ b/routes/playlist.py @@ -1,25 +1,43 @@ -from flask import Blueprint, Response, request +from flask import Blueprint, Response, request, jsonify import os import json import traceback +import logging # Added logging import import uuid # For generating error task IDs import time # For timestamps from routes.utils.celery_queue_manager import download_queue_manager from routes.utils.celery_tasks import store_task_info, store_task_status, ProgressState # For error task creation +import threading # For playlist watch trigger -playlist_bp = Blueprint('playlist', __name__) +# Imports from playlist_watch.py +from routes.utils.watch.db import ( + add_playlist_to_watch as add_playlist_db, + remove_playlist_from_watch as remove_playlist_db, + get_watched_playlist, + get_watched_playlists, + add_specific_tracks_to_playlist_table, + remove_specific_tracks_from_playlist_table, + is_track_in_playlist_db # Added import +) +from routes.utils.get_info import get_spotify_info # Already used, but ensure it's here +from routes.utils.watch.manager import check_watched_playlists # For manual trigger -@playlist_bp.route('/download', methods=['GET']) -def handle_download(): +logger = logging.getLogger(__name__) # Added logger initialization +playlist_bp = Blueprint('playlist', __name__, url_prefix='/api/playlist') + +@playlist_bp.route('/download/', methods=['GET']) +def handle_download(playlist_id): # Retrieve essential parameters from the request. - url = request.args.get('url') name = request.args.get('name') artist = request.args.get('artist') orig_params = request.args.to_dict() - orig_params["original_url"] = request.url - + + # Construct the URL from playlist_id + url = f"https://open.spotify.com/playlist/{playlist_id}" + orig_params["original_url"] = url # Update original_url to the constructed one + # Validate required parameters - if not url: + if not url: # This check might be redundant now but kept for safety return Response( json.dumps({"error": "Missing required parameter: url"}), status=400, @@ -104,8 +122,23 @@ def get_playlist_info(): try: # Import and use the get_spotify_info function from the utility module. - from routes.utils.get_info import get_spotify_info playlist_info = get_spotify_info(spotify_id, "playlist") + + # If playlist_info is successfully fetched, check if it's watched + # and augment track items with is_locally_known status + if playlist_info and playlist_info.get('id'): + watched_playlist_details = get_watched_playlist(playlist_info['id']) + if watched_playlist_details: # Playlist is being watched + if playlist_info.get('tracks') and playlist_info['tracks'].get('items'): + for item in playlist_info['tracks']['items']: + if item and item.get('track') and item['track'].get('id'): + track_id = item['track']['id'] + item['track']['is_locally_known'] = is_track_in_playlist_db(playlist_info['id'], track_id) + elif item and item.get('track'): # Track object exists but no ID + item['track']['is_locally_known'] = False + # If not watched, or no tracks, is_locally_known will not be added, or tracks won't exist to add it to. + # Frontend should handle absence of this key as false. + return Response( json.dumps(playlist_info), status=200, @@ -121,3 +154,160 @@ def get_playlist_info(): status=500, mimetype='application/json' ) + +@playlist_bp.route('/watch/', methods=['PUT']) +def add_to_watchlist(playlist_spotify_id): + """Adds a playlist to the watchlist.""" + logger.info(f"Attempting to add playlist {playlist_spotify_id} to watchlist.") + try: + # Check if already watched + if get_watched_playlist(playlist_spotify_id): + return jsonify({"message": f"Playlist {playlist_spotify_id} is already being watched."}), 200 + + # Fetch playlist details from Spotify to populate our DB + playlist_data = get_spotify_info(playlist_spotify_id, "playlist") + if not playlist_data or 'id' not in playlist_data: + logger.error(f"Could not fetch details for playlist {playlist_spotify_id} from Spotify.") + return jsonify({"error": f"Could not fetch details for playlist {playlist_spotify_id} from Spotify."}), 404 + + add_playlist_db(playlist_data) # This also creates the tracks table + + # REMOVED: Do not add initial tracks directly to DB. + # The playlist watch manager will pick them up as new and queue downloads. + # Tracks will be added to DB only after successful download via Celery task callback. + # initial_track_items = playlist_data.get('tracks', {}).get('items', []) + # if initial_track_items: + # from routes.utils.watch.db import add_tracks_to_playlist_db # Keep local import for clarity + # add_tracks_to_playlist_db(playlist_spotify_id, initial_track_items) + + logger.info(f"Playlist {playlist_spotify_id} added to watchlist. Its tracks will be processed by the watch manager.") + return jsonify({"message": f"Playlist {playlist_spotify_id} added to watchlist. Tracks will be processed shortly."}), 201 + except Exception as e: + logger.error(f"Error adding playlist {playlist_spotify_id} to watchlist: {e}", exc_info=True) + return jsonify({"error": f"Could not add playlist to watchlist: {str(e)}"}), 500 + +@playlist_bp.route('/watch//status', methods=['GET']) +def get_playlist_watch_status(playlist_spotify_id): + """Checks if a specific playlist is being watched.""" + logger.info(f"Checking watch status for playlist {playlist_spotify_id}.") + try: + playlist = get_watched_playlist(playlist_spotify_id) + if playlist: + return jsonify({"is_watched": True, "playlist_data": playlist}), 200 + else: + # Return 200 with is_watched: false, so frontend can clearly distinguish + # between "not watched" and an actual error fetching status. + return jsonify({"is_watched": False}), 200 + except Exception as e: + logger.error(f"Error checking watch status for playlist {playlist_spotify_id}: {e}", exc_info=True) + return jsonify({"error": f"Could not check watch status: {str(e)}"}), 500 + +@playlist_bp.route('/watch/', methods=['DELETE']) +def remove_from_watchlist(playlist_spotify_id): + """Removes a playlist from the watchlist.""" + logger.info(f"Attempting to remove playlist {playlist_spotify_id} from watchlist.") + try: + if not get_watched_playlist(playlist_spotify_id): + return jsonify({"error": f"Playlist {playlist_spotify_id} not found in watchlist."}), 404 + + remove_playlist_db(playlist_spotify_id) + logger.info(f"Playlist {playlist_spotify_id} removed from watchlist successfully.") + return jsonify({"message": f"Playlist {playlist_spotify_id} removed from watchlist."}), 200 + except Exception as e: + logger.error(f"Error removing playlist {playlist_spotify_id} from watchlist: {e}", exc_info=True) + return jsonify({"error": f"Could not remove playlist from watchlist: {str(e)}"}), 500 + +@playlist_bp.route('/watch//tracks', methods=['POST']) +def mark_tracks_as_known(playlist_spotify_id): + """Fetches details for given track IDs and adds/updates them in the playlist's local DB table.""" + logger.info(f"Attempting to mark tracks as known for playlist {playlist_spotify_id}.") + try: + track_ids = request.json + if not isinstance(track_ids, list) or not all(isinstance(tid, str) for tid in track_ids): + return jsonify({"error": "Invalid request body. Expecting a JSON array of track Spotify IDs."}), 400 + + if not get_watched_playlist(playlist_spotify_id): + return jsonify({"error": f"Playlist {playlist_spotify_id} is not being watched."}), 404 + + fetched_tracks_details = [] + for track_id in track_ids: + try: + track_detail = get_spotify_info(track_id, "track") + if track_detail and track_detail.get('id'): + fetched_tracks_details.append(track_detail) + else: + logger.warning(f"Could not fetch details for track {track_id} when marking as known for playlist {playlist_spotify_id}.") + except Exception as e: + logger.error(f"Failed to fetch Spotify details for track {track_id}: {e}") + + if not fetched_tracks_details: + return jsonify({"message": "No valid track details could be fetched to mark as known.", "processed_count": 0}), 200 + + add_specific_tracks_to_playlist_table(playlist_spotify_id, fetched_tracks_details) + logger.info(f"Successfully marked/updated {len(fetched_tracks_details)} tracks as known for playlist {playlist_spotify_id}.") + return jsonify({"message": f"Successfully processed {len(fetched_tracks_details)} tracks for playlist {playlist_spotify_id}."}), 200 + except Exception as e: + logger.error(f"Error marking tracks as known for playlist {playlist_spotify_id}: {e}", exc_info=True) + return jsonify({"error": f"Could not mark tracks as known: {str(e)}"}), 500 + +@playlist_bp.route('/watch//tracks', methods=['DELETE']) +def mark_tracks_as_missing_locally(playlist_spotify_id): + """Removes specified tracks from the playlist's local DB table.""" + logger.info(f"Attempting to mark tracks as missing (delete locally) for playlist {playlist_spotify_id}.") + try: + track_ids = request.json + if not isinstance(track_ids, list) or not all(isinstance(tid, str) for tid in track_ids): + return jsonify({"error": "Invalid request body. Expecting a JSON array of track Spotify IDs."}), 400 + + if not get_watched_playlist(playlist_spotify_id): + return jsonify({"error": f"Playlist {playlist_spotify_id} is not being watched."}), 404 + + deleted_count = remove_specific_tracks_from_playlist_table(playlist_spotify_id, track_ids) + logger.info(f"Successfully removed {deleted_count} tracks locally for playlist {playlist_spotify_id}.") + return jsonify({"message": f"Successfully removed {deleted_count} tracks locally for playlist {playlist_spotify_id}."}), 200 + except Exception as e: + logger.error(f"Error marking tracks as missing (deleting locally) for playlist {playlist_spotify_id}: {e}", exc_info=True) + return jsonify({"error": f"Could not mark tracks as missing: {str(e)}"}), 500 + +@playlist_bp.route('/watch/list', methods=['GET']) +def list_watched_playlists_endpoint(): + """Lists all playlists currently in the watchlist.""" + try: + playlists = get_watched_playlists() + return jsonify(playlists), 200 + except Exception as e: + logger.error(f"Error listing watched playlists: {e}", exc_info=True) + return jsonify({"error": f"Could not list watched playlists: {str(e)}"}), 500 + +@playlist_bp.route('/watch/trigger_check', methods=['POST']) +def trigger_playlist_check_endpoint(): + """Manually triggers the playlist checking mechanism for all watched playlists.""" + logger.info("Manual trigger for playlist check received for all playlists.") + try: + # Run check_watched_playlists without an ID to check all + thread = threading.Thread(target=check_watched_playlists, args=(None,)) + thread.start() + return jsonify({"message": "Playlist check triggered successfully in the background for all playlists."}), 202 + except Exception as e: + logger.error(f"Error manually triggering playlist check for all: {e}", exc_info=True) + return jsonify({"error": f"Could not trigger playlist check for all: {str(e)}"}), 500 + +@playlist_bp.route('/watch/trigger_check/', methods=['POST']) +def trigger_specific_playlist_check_endpoint(playlist_spotify_id: str): + """Manually triggers the playlist checking mechanism for a specific playlist.""" + logger.info(f"Manual trigger for specific playlist check received for ID: {playlist_spotify_id}") + try: + # Check if the playlist is actually in the watchlist first + watched_playlist = get_watched_playlist(playlist_spotify_id) + if not watched_playlist: + logger.warning(f"Trigger specific check: Playlist ID {playlist_spotify_id} not found in watchlist.") + return jsonify({"error": f"Playlist {playlist_spotify_id} is not in the watchlist. Add it first."}), 404 + + # Run check_watched_playlists with the specific ID + thread = threading.Thread(target=check_watched_playlists, args=(playlist_spotify_id,)) + thread.start() + logger.info(f"Playlist check triggered in background for specific playlist ID: {playlist_spotify_id}") + return jsonify({"message": f"Playlist check triggered successfully in the background for {playlist_spotify_id}."}), 202 + except Exception as e: + logger.error(f"Error manually triggering specific playlist check for {playlist_spotify_id}: {e}", exc_info=True) + return jsonify({"error": f"Could not trigger playlist check for {playlist_spotify_id}: {str(e)}"}), 500 diff --git a/routes/track.py b/routes/track.py index 7fdbfed..b48be7b 100755 --- a/routes/track.py +++ b/routes/track.py @@ -10,14 +10,16 @@ from urllib.parse import urlparse # for URL validation track_bp = Blueprint('track', __name__) -@track_bp.route('/download', methods=['GET']) -def handle_download(): +@track_bp.route('/download/', methods=['GET']) +def handle_download(track_id): # Retrieve essential parameters from the request. - url = request.args.get('url') name = request.args.get('name') artist = request.args.get('artist') orig_params = request.args.to_dict() - orig_params["original_url"] = request.url + + # Construct the URL from track_id + url = f"https://open.spotify.com/track/{track_id}" + orig_params["original_url"] = url # Update original_url to the constructed one # Validate required parameters if not url: diff --git a/routes/utils/celery_queue_manager.py b/routes/utils/celery_queue_manager.py index eb3aafd..362ec48 100644 --- a/routes/utils/celery_queue_manager.py +++ b/routes/utils/celery_queue_manager.py @@ -94,16 +94,20 @@ class CeleryDownloadQueueManager: self.paused = False print(f"Celery Download Queue Manager initialized with max_concurrent={self.max_concurrent}") - def add_task(self, task): + def add_task(self, task: dict, from_watch_job: bool = False): """ Add a new download task to the Celery queue. - If a duplicate active task is found, a new task ID is created and immediately set to an ERROR state. + - If from_watch_job is True and an active duplicate is found, the task is not queued and None is returned. + - If from_watch_job is False and an active duplicate is found, a new task ID is created, + set to an ERROR state indicating the duplicate, and this new error task's ID is returned. Args: task (dict): Task parameters including download_type, url, etc. + from_watch_job (bool): If True, duplicate active tasks are skipped. Defaults to False. Returns: - str: Task ID (either for a new task or for a new error-state task if duplicate detected). + str | None: Task ID if successfully queued or an error task ID for non-watch duplicates. + None if from_watch_job is True and an active duplicate was found. """ try: # Extract essential parameters for duplicate check @@ -111,20 +115,18 @@ class CeleryDownloadQueueManager: incoming_type = task.get("download_type", "unknown") if not incoming_url: - # This should ideally be validated before calling add_task - # For now, let it proceed and potentially fail in Celery task if URL is vital and missing. - # Or, create an error task immediately if URL is strictly required for any task logging. logger.warning("Task being added with no URL. Duplicate check might be unreliable.") - # --- Check for Duplicates --- NON_BLOCKING_STATES = [ ProgressState.COMPLETE, ProgressState.CANCELLED, - ProgressState.ERROR + ProgressState.ERROR, + ProgressState.ERROR_RETRIED, + ProgressState.ERROR_AUTO_CLEANED ] - all_existing_tasks_summary = get_all_tasks() - if incoming_url: # Only check for duplicates if we have a URL + all_existing_tasks_summary = get_all_tasks() + if incoming_url: for task_summary in all_existing_tasks_summary: existing_task_id = task_summary.get("task_id") if not existing_task_id: @@ -147,97 +149,65 @@ class CeleryDownloadQueueManager: message = f"Duplicate download: URL '{incoming_url}' (type: {incoming_type}) is already being processed by task {existing_task_id} (status: {existing_status})." logger.warning(message) - # Create a new task_id for this duplicate request and mark it as an error - error_task_id = str(uuid.uuid4()) - - # Store minimal info for this error task - error_task_info_payload = { - "download_type": incoming_type, - "type": task.get("type", incoming_type), - "name": task.get("name", "Duplicate Task"), - "artist": task.get("artist", ""), - "url": incoming_url, - "original_request": task.get("orig_request", task.get("original_request", {})), - "created_at": time.time(), - "is_duplicate_error_task": True - } - store_task_info(error_task_id, error_task_info_payload) - - # Store error status for this new task_id - error_status_payload = { - "status": ProgressState.ERROR, - "error": message, - "existing_task_id": existing_task_id, # So client knows which task it duplicates - "timestamp": time.time(), - "type": error_task_info_payload["type"], - "name": error_task_info_payload["name"], - "artist": error_task_info_payload["artist"] - } - store_task_status(error_task_id, error_status_payload) - - return error_task_id # Return the ID of this new error-state task - # --- End Duplicate Check --- + if from_watch_job: + logger.info(f"Task from watch job for {incoming_url} not queued due to active duplicate {existing_task_id}.") + return None # Skip execution for watch jobs + else: + # Create a new task_id for this duplicate request and mark it as an error + error_task_id = str(uuid.uuid4()) + error_task_info_payload = { + "download_type": incoming_type, + "type": task.get("type", incoming_type), + "name": task.get("name", "Duplicate Task"), + "artist": task.get("artist", ""), + "url": incoming_url, + "original_request": task.get("orig_request", task.get("original_request", {})), + "created_at": time.time(), + "is_duplicate_error_task": True + } + store_task_info(error_task_id, error_task_info_payload) + error_status_payload = { + "status": ProgressState.ERROR, + "error": message, + "existing_task_id": existing_task_id, + "timestamp": time.time(), + "type": error_task_info_payload["type"], + "name": error_task_info_payload["name"], + "artist": error_task_info_payload["artist"] + } + store_task_status(error_task_id, error_status_payload) + return error_task_id # Return the ID of this new error-state task - # Proceed with normal task creation if no duplicate found or no URL to check - download_type = task.get("download_type", "unknown") - - # Debug existing task data - logger.debug(f"Adding {download_type} task with data: {json.dumps({k: v for k, v in task.items() if k != 'orig_request'})}") - - # Create a unique task ID task_id = str(uuid.uuid4()) - - # Get config parameters and process original request config_params = get_config_params() - - # Extract original request or use empty dict original_request = task.get("orig_request", task.get("original_request", {})) - # Debug retry_url if present - if "retry_url" in task: - logger.debug(f"Task has retry_url: {task['retry_url']}") - - # Build the complete task with config parameters complete_task = { - "download_type": download_type, - "type": task.get("type", download_type), + "download_type": incoming_type, + "type": task.get("type", incoming_type), "name": task.get("name", ""), "artist": task.get("artist", ""), "url": task.get("url", ""), - - # Preserve retry_url if present "retry_url": task.get("retry_url", ""), - - # Use main account from config "main": original_request.get("main", config_params['deezer']), - - # Set fallback if enabled in config "fallback": original_request.get("fallback", config_params['spotify'] if config_params['fallback'] else None), - - # Use default quality settings "quality": original_request.get("quality", config_params['deezerQuality']), - "fall_quality": original_request.get("fall_quality", config_params['spotifyQuality']), - - # Parse boolean parameters from string values "real_time": self._parse_bool_param(original_request.get("real_time"), config_params['realTime']), - "custom_dir_format": original_request.get("custom_dir_format", config_params['customDirFormat']), "custom_track_format": original_request.get("custom_track_format", config_params['customTrackFormat']), - - # Parse boolean parameters from string values "pad_tracks": self._parse_bool_param(original_request.get("tracknum_padding"), config_params['tracknum_padding']), - "retry_count": 0, "original_request": original_request, "created_at": time.time() } + + # If from_watch_job is True, ensure track_details_for_db is passed through + if from_watch_job and "track_details_for_db" in task: + complete_task["track_details_for_db"] = task["track_details_for_db"] - # Store the task info in Redis for later retrieval store_task_info(task_id, complete_task) - - # Store initial queued status store_task_status(task_id, { "status": ProgressState.QUEUED, "timestamp": time.time(), @@ -245,46 +215,35 @@ class CeleryDownloadQueueManager: "name": complete_task["name"], "artist": complete_task["artist"], "retry_count": 0, - "queue_position": len(get_all_tasks()) + 1 # Approximate queue position + "queue_position": len(get_all_tasks()) + 1 }) - # Launch the appropriate Celery task based on download_type - celery_task = None + celery_task_map = { + "track": download_track, + "album": download_album, + "playlist": download_playlist + } - if download_type == "track": - celery_task = download_track.apply_async( - kwargs=complete_task, - task_id=task_id, - countdown=0 if not self.paused else 3600 # Delay task if paused - ) - elif download_type == "album": - celery_task = download_album.apply_async( - kwargs=complete_task, - task_id=task_id, - countdown=0 if not self.paused else 3600 - ) - elif download_type == "playlist": - celery_task = download_playlist.apply_async( + task_func = celery_task_map.get(incoming_type) + if task_func: + task_func.apply_async( kwargs=complete_task, task_id=task_id, countdown=0 if not self.paused else 3600 ) + logger.info(f"Added {incoming_type} download task {task_id} to Celery queue.") + return task_id else: - # Store error status for unknown download type store_task_status(task_id, { "status": ProgressState.ERROR, - "message": f"Unsupported download type: {download_type}", + "message": f"Unsupported download type: {incoming_type}", "timestamp": time.time() }) - logger.error(f"Unsupported download type: {download_type}") - return task_id # Still return the task_id so the error can be tracked - - logger.info(f"Added {download_type} download task {task_id} to Celery queue") - return task_id + logger.error(f"Unsupported download type: {incoming_type}") + return task_id except Exception as e: logger.error(f"Error adding task to Celery queue: {e}", exc_info=True) - # Generate a task ID even for failed tasks so we can track the error error_task_id = str(uuid.uuid4()) store_task_status(error_task_id, { "status": ProgressState.ERROR, diff --git a/routes/utils/celery_tasks.py b/routes/utils/celery_tasks.py index 8259b9a..d83185e 100644 --- a/routes/utils/celery_tasks.py +++ b/routes/utils/celery_tasks.py @@ -15,6 +15,8 @@ logger = logging.getLogger(__name__) # Setup Redis and Celery from routes.utils.celery_config import REDIS_URL, REDIS_BACKEND, REDIS_PASSWORD, get_config_params +# Import for playlist watch DB update +from routes.utils.watch.db import add_single_track_to_playlist_db # Initialize Celery app celery_app = Celery('download_tasks', @@ -826,6 +828,22 @@ def task_postrun_handler(task_id=None, task=None, retval=None, state=None, *args "message": "Download completed successfully." }) logger.info(f"Task {task_id} completed successfully: {task_info.get('name', 'Unknown')}") + + # If from playlist_watch and successful, add track to DB + original_request = task_info.get("original_request", {}) + if original_request.get("source") == "playlist_watch": + playlist_id = original_request.get("playlist_id") + track_item_for_db = original_request.get("track_item_for_db") + + if playlist_id and track_item_for_db and track_item_for_db.get('track'): + logger.info(f"Task {task_id} was from playlist watch for playlist {playlist_id}. Adding track to DB.") + try: + add_single_track_to_playlist_db(playlist_id, track_item_for_db) + except Exception as db_add_err: + logger.error(f"Failed to add track to DB for playlist {playlist_id} after successful download task {task_id}: {db_add_err}", exc_info=True) + else: + logger.warning(f"Task {task_id} was from playlist_watch but missing playlist_id or track_item_for_db for DB update. Original Request: {original_request}") + except Exception as e: logger.error(f"Error in task_postrun_handler: {e}") diff --git a/routes/utils/get_info.py b/routes/utils/get_info.py index 6f35bd1..9b8b49c 100644 --- a/routes/utils/get_info.py +++ b/routes/utils/get_info.py @@ -7,13 +7,15 @@ from routes.utils.celery_queue_manager import get_config_params # We'll rely on get_config_params() instead of directly loading the config file -def get_spotify_info(spotify_id, spotify_type): +def get_spotify_info(spotify_id, spotify_type, limit=None, offset=None): """ Get info from Spotify API using the default Spotify account configured in main.json Args: spotify_id: The Spotify ID of the entity spotify_type: The type of entity (track, album, playlist, artist) + limit (int, optional): The maximum number of items to return. Only used if spotify_type is "artist". + offset (int, optional): The index of the first item to return. Only used if spotify_type is "artist". Returns: Dictionary with the entity information @@ -51,7 +53,14 @@ def get_spotify_info(spotify_id, spotify_type): elif spotify_type == "playlist": return Spo.get_playlist(spotify_id) elif spotify_type == "artist": - return Spo.get_artist(spotify_id) + if limit is not None and offset is not None: + return Spo.get_artist(spotify_id, limit=limit, offset=offset) + elif limit is not None: + return Spo.get_artist(spotify_id, limit=limit) + elif offset is not None: + return Spo.get_artist(spotify_id, offset=offset) + else: + return Spo.get_artist(spotify_id) elif spotify_type == "episode": return Spo.get_episode(spotify_id) else: diff --git a/routes/utils/watch/db.py b/routes/utils/watch/db.py new file mode 100644 index 0000000..d82129e --- /dev/null +++ b/routes/utils/watch/db.py @@ -0,0 +1,703 @@ +import sqlite3 +import json +from pathlib import Path +import logging +import time + +logger = logging.getLogger(__name__) + +DB_DIR = Path('./data/watch') +# Define separate DB paths +PLAYLISTS_DB_PATH = DB_DIR / 'playlists.db' +ARTISTS_DB_PATH = DB_DIR / 'artists.db' + +# Config path remains the same +CONFIG_PATH = Path('./data/config/watch.json') + +def _get_playlists_db_connection(): + DB_DIR.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(PLAYLISTS_DB_PATH, timeout=10) + conn.row_factory = sqlite3.Row + return conn + +def _get_artists_db_connection(): + DB_DIR.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(ARTISTS_DB_PATH, timeout=10) + conn.row_factory = sqlite3.Row + return conn + +def init_playlists_db(): + """Initializes the playlists database and creates the main watched_playlists table if it doesn't exist.""" + try: + with _get_playlists_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS watched_playlists ( + spotify_id TEXT PRIMARY KEY, + name TEXT, + owner_id TEXT, + owner_name TEXT, + total_tracks INTEGER, + link TEXT, + snapshot_id TEXT, + last_checked INTEGER, + added_at INTEGER, + is_active INTEGER DEFAULT 1 + ) + """) + conn.commit() + logger.info(f"Playlists database initialized successfully at {PLAYLISTS_DB_PATH}") + except sqlite3.Error as e: + logger.error(f"Error initializing watched_playlists table: {e}", exc_info=True) + raise + +def _create_playlist_tracks_table(playlist_spotify_id: str): + """Creates a table for a specific playlist to store its tracks if it doesn't exist in playlists.db.""" + table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" # Sanitize table name + try: + with _get_playlists_db_connection() as conn: # Use playlists connection + cursor = conn.cursor() + cursor.execute(f""" + CREATE TABLE IF NOT EXISTS {table_name} ( + spotify_track_id TEXT PRIMARY KEY, + title TEXT, + artist_names TEXT, -- Comma-separated artist names + album_name TEXT, + album_artist_names TEXT, -- Comma-separated album artist names + track_number INTEGER, + album_spotify_id TEXT, + duration_ms INTEGER, + added_at_playlist TEXT, -- When track was added to Spotify playlist + added_to_db INTEGER, -- Timestamp when track was added to this DB table + is_present_in_spotify INTEGER DEFAULT 1, -- Flag to mark if still in Spotify playlist + last_seen_in_spotify INTEGER -- Timestamp when last confirmed in Spotify playlist + ) + """) + conn.commit() + logger.info(f"Tracks table '{table_name}' created or already exists in {PLAYLISTS_DB_PATH}.") + except sqlite3.Error as e: + logger.error(f"Error creating playlist tracks table {table_name} in {PLAYLISTS_DB_PATH}: {e}", exc_info=True) + raise + +def add_playlist_to_watch(playlist_data: dict): + """Adds a playlist to the watched_playlists table and creates its tracks table in playlists.db.""" + try: + _create_playlist_tracks_table(playlist_data['id']) + with _get_playlists_db_connection() as conn: # Use playlists connection + cursor = conn.cursor() + cursor.execute(""" + INSERT OR REPLACE INTO watched_playlists + (spotify_id, name, owner_id, owner_name, total_tracks, link, snapshot_id, last_checked, added_at, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1) + """, ( + playlist_data['id'], + playlist_data['name'], + playlist_data['owner']['id'], + playlist_data['owner'].get('display_name', playlist_data['owner']['id']), + playlist_data['tracks']['total'], + playlist_data['external_urls']['spotify'], + playlist_data.get('snapshot_id'), + int(time.time()), + int(time.time()) + )) + conn.commit() + logger.info(f"Playlist '{playlist_data['name']}' ({playlist_data['id']}) added to watchlist in {PLAYLISTS_DB_PATH}.") + except sqlite3.Error as e: + logger.error(f"Error adding playlist {playlist_data.get('id')} to watchlist in {PLAYLISTS_DB_PATH}: {e}", exc_info=True) + raise + +def remove_playlist_from_watch(playlist_spotify_id: str): + """Removes a playlist from watched_playlists and drops its tracks table in playlists.db.""" + table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" + try: + with _get_playlists_db_connection() as conn: # Use playlists connection + cursor = conn.cursor() + cursor.execute("DELETE FROM watched_playlists WHERE spotify_id = ?", (playlist_spotify_id,)) + cursor.execute(f"DROP TABLE IF EXISTS {table_name}") + conn.commit() + logger.info(f"Playlist {playlist_spotify_id} removed from watchlist and its table '{table_name}' dropped in {PLAYLISTS_DB_PATH}.") + except sqlite3.Error as e: + logger.error(f"Error removing playlist {playlist_spotify_id} from watchlist in {PLAYLISTS_DB_PATH}: {e}", exc_info=True) + raise + +def get_watched_playlists(): + """Retrieves all active playlists from the watched_playlists table in playlists.db.""" + try: + with _get_playlists_db_connection() as conn: # Use playlists connection + cursor = conn.cursor() + cursor.execute("SELECT * FROM watched_playlists WHERE is_active = 1") + playlists = [dict(row) for row in cursor.fetchall()] + return playlists + except sqlite3.Error as e: + logger.error(f"Error retrieving watched playlists from {PLAYLISTS_DB_PATH}: {e}", exc_info=True) + return [] + +def get_watched_playlist(playlist_spotify_id: str): + """Retrieves a specific playlist from the watched_playlists table in playlists.db.""" + try: + with _get_playlists_db_connection() as conn: # Use playlists connection + cursor = conn.cursor() + cursor.execute("SELECT * FROM watched_playlists WHERE spotify_id = ?", (playlist_spotify_id,)) + row = cursor.fetchone() + return dict(row) if row else None + except sqlite3.Error as e: + logger.error(f"Error retrieving playlist {playlist_spotify_id} from {PLAYLISTS_DB_PATH}: {e}", exc_info=True) + return None + +def update_playlist_snapshot(playlist_spotify_id: str, snapshot_id: str, total_tracks: int): + """Updates the snapshot_id and total_tracks for a watched playlist in playlists.db.""" + try: + with _get_playlists_db_connection() as conn: # Use playlists connection + cursor = conn.cursor() + cursor.execute(""" + UPDATE watched_playlists + SET snapshot_id = ?, total_tracks = ?, last_checked = ? + WHERE spotify_id = ? + """, (snapshot_id, total_tracks, int(time.time()), playlist_spotify_id)) + conn.commit() + except sqlite3.Error as e: + logger.error(f"Error updating snapshot for playlist {playlist_spotify_id} in {PLAYLISTS_DB_PATH}: {e}", exc_info=True) + +def get_playlist_track_ids_from_db(playlist_spotify_id: str): + """Retrieves all track Spotify IDs from a specific playlist's tracks table in playlists.db.""" + table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" + track_ids = set() + try: + with _get_playlists_db_connection() as conn: # Use playlists connection + cursor = conn.cursor() + cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';") + if cursor.fetchone() is None: + logger.warning(f"Track table {table_name} does not exist in {PLAYLISTS_DB_PATH}. Cannot fetch track IDs.") + return track_ids + cursor.execute(f"SELECT spotify_track_id FROM {table_name} WHERE is_present_in_spotify = 1") + rows = cursor.fetchall() + for row in rows: + track_ids.add(row['spotify_track_id']) + return track_ids + except sqlite3.Error as e: + logger.error(f"Error retrieving track IDs for playlist {playlist_spotify_id} from table {table_name} in {PLAYLISTS_DB_PATH}: {e}", exc_info=True) + return track_ids + +def add_tracks_to_playlist_db(playlist_spotify_id: str, tracks_data: list): + """Adds or updates a list of tracks in the specified playlist's tracks table in playlists.db.""" + table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" + if not tracks_data: + return + + current_time = int(time.time()) + tracks_to_insert = [] + for track_item in tracks_data: + track = track_item.get('track') + if not track or not track.get('id'): + logger.warning(f"Skipping track due to missing data or ID in playlist {playlist_spotify_id}: {track_item}") + continue + + # Ensure 'artists' and 'album' -> 'artists' are lists and extract names + artist_names = ", ".join([artist['name'] for artist in track.get('artists', []) if artist.get('name')]) + album_artist_names = ", ".join([artist['name'] for artist in track.get('album', {}).get('artists', []) if artist.get('name')]) + + tracks_to_insert.append(( + track['id'], + track.get('name', 'N/A'), + artist_names, + track.get('album', {}).get('name', 'N/A'), + album_artist_names, + track.get('track_number'), + track.get('album', {}).get('id'), + track.get('duration_ms'), + track_item.get('added_at'), # From playlist item + current_time, # added_to_db + 1, # is_present_in_spotify + current_time # last_seen_in_spotify + )) + + if not tracks_to_insert: + logger.info(f"No valid tracks to insert for playlist {playlist_spotify_id}.") + return + + try: + with _get_playlists_db_connection() as conn: # Use playlists connection + cursor = conn.cursor() + _create_playlist_tracks_table(playlist_spotify_id) # Ensure table exists + + cursor.executemany(f""" + INSERT OR REPLACE INTO {table_name} + (spotify_track_id, title, artist_names, album_name, album_artist_names, track_number, album_spotify_id, duration_ms, added_at_playlist, added_to_db, is_present_in_spotify, last_seen_in_spotify) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, tracks_to_insert) + conn.commit() + logger.info(f"Added/updated {len(tracks_to_insert)} tracks in DB for playlist {playlist_spotify_id} in {PLAYLISTS_DB_PATH}.") + except sqlite3.Error as e: + logger.error(f"Error adding tracks to playlist {playlist_spotify_id} in table {table_name} in {PLAYLISTS_DB_PATH}: {e}", exc_info=True) + # Not raising here to allow other operations to continue if one batch fails. + +def mark_tracks_as_not_present_in_spotify(playlist_spotify_id: str, track_ids_to_mark: list): + """Marks specified tracks as not present in the Spotify playlist anymore in playlists.db.""" + table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" + if not track_ids_to_mark: + return + try: + with _get_playlists_db_connection() as conn: # Use playlists connection + cursor = conn.cursor() + placeholders = ','.join('?' for _ in track_ids_to_mark) + sql = f"UPDATE {table_name} SET is_present_in_spotify = 0 WHERE spotify_track_id IN ({placeholders})" + cursor.execute(sql, track_ids_to_mark) + conn.commit() + logger.info(f"Marked {cursor.rowcount} tracks as not present in Spotify for playlist {playlist_spotify_id} in {PLAYLISTS_DB_PATH}.") + except sqlite3.Error as e: + logger.error(f"Error marking tracks as not present for playlist {playlist_spotify_id} in {PLAYLISTS_DB_PATH}: {e}", exc_info=True) + +def add_specific_tracks_to_playlist_table(playlist_spotify_id: str, track_details_list: list): + """ + Adds specific tracks (with full details fetched separately) to the playlist's table. + This is used when a user manually marks tracks as "downloaded" or "known". + """ + table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" + if not track_details_list: + return + + current_time = int(time.time()) + tracks_to_insert = [] + + for track in track_details_list: # track here is assumed to be a full Spotify TrackObject + if not track or not track.get('id'): + logger.warning(f"Skipping track due to missing data or ID (manual add) in playlist {playlist_spotify_id}: {track}") + continue + + artist_names = ", ".join([artist['name'] for artist in track.get('artists', []) if artist.get('name')]) + album_artist_names = ", ".join([artist['name'] for artist in track.get('album', {}).get('artists', []) if artist.get('name')]) + + tracks_to_insert.append(( + track['id'], + track.get('name', 'N/A'), + artist_names, + track.get('album', {}).get('name', 'N/A'), + album_artist_names, + track.get('track_number'), + track.get('album', {}).get('id'), + track.get('duration_ms'), + None, # added_at_playlist - not known for manually added tracks this way + current_time, # added_to_db + 1, # is_present_in_spotify (assume user wants it considered present) + current_time # last_seen_in_spotify + )) + + if not tracks_to_insert: + logger.info(f"No valid tracks to insert (manual add) for playlist {playlist_spotify_id}.") + return + + try: + with _get_playlists_db_connection() as conn: # Use playlists connection + cursor = conn.cursor() + _create_playlist_tracks_table(playlist_spotify_id) # Ensure table exists + cursor.executemany(f""" + INSERT OR REPLACE INTO {table_name} + (spotify_track_id, title, artist_names, album_name, album_artist_names, track_number, album_spotify_id, duration_ms, added_at_playlist, added_to_db, is_present_in_spotify, last_seen_in_spotify) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, tracks_to_insert) + conn.commit() + logger.info(f"Manually added/updated {len(tracks_to_insert)} tracks in DB for playlist {playlist_spotify_id} in {PLAYLISTS_DB_PATH}.") + except sqlite3.Error as e: + logger.error(f"Error manually adding tracks to playlist {playlist_spotify_id} in table {table_name} in {PLAYLISTS_DB_PATH}: {e}", exc_info=True) + +def remove_specific_tracks_from_playlist_table(playlist_spotify_id: str, track_spotify_ids: list): + """Removes specific tracks from the playlist's local track table.""" + table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" + if not track_spotify_ids: + return 0 + + try: + with _get_playlists_db_connection() as conn: + cursor = conn.cursor() + placeholders = ','.join('?' for _ in track_spotify_ids) + # Check if table exists first + cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';") + if cursor.fetchone() is None: + logger.warning(f"Track table {table_name} does not exist. Cannot remove tracks.") + return 0 + + cursor.execute(f"DELETE FROM {table_name} WHERE spotify_track_id IN ({placeholders})", track_spotify_ids) + conn.commit() + deleted_count = cursor.rowcount + logger.info(f"Manually removed {deleted_count} tracks from DB for playlist {playlist_spotify_id}.") + return deleted_count + except sqlite3.Error as e: + logger.error(f"Error manually removing tracks for playlist {playlist_spotify_id} from table {table_name}: {e}", exc_info=True) + return 0 + +def add_single_track_to_playlist_db(playlist_spotify_id: str, track_item_for_db: dict): + """Adds or updates a single track in the specified playlist's tracks table in playlists.db.""" + table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" + track_detail = track_item_for_db.get('track') + if not track_detail or not track_detail.get('id'): + logger.warning(f"Skipping single track due to missing data for playlist {playlist_spotify_id}: {track_item_for_db}") + return + + current_time = int(time.time()) + artist_names = ", ".join([a['name'] for a in track_detail.get('artists', []) if a.get('name')]) + album_artist_names = ", ".join([a['name'] for a in track_detail.get('album', {}).get('artists', []) if a.get('name')]) + + track_data_tuple = ( + track_detail['id'], + track_detail.get('name', 'N/A'), + artist_names, + track_detail.get('album', {}).get('name', 'N/A'), + album_artist_names, + track_detail.get('track_number'), + track_detail.get('album', {}).get('id'), + track_detail.get('duration_ms'), + track_item_for_db.get('added_at'), + current_time, + 1, + current_time + ) + try: + with _get_playlists_db_connection() as conn: # Use playlists connection + cursor = conn.cursor() + _create_playlist_tracks_table(playlist_spotify_id) + cursor.execute(f""" + INSERT OR REPLACE INTO {table_name} + (spotify_track_id, title, artist_names, album_name, album_artist_names, track_number, album_spotify_id, duration_ms, added_at_playlist, added_to_db, is_present_in_spotify, last_seen_in_spotify) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, track_data_tuple) + conn.commit() + logger.info(f"Track '{track_detail.get('name')}' added/updated in DB for playlist {playlist_spotify_id} in {PLAYLISTS_DB_PATH}.") + except sqlite3.Error as e: + logger.error(f"Error adding single track to playlist {playlist_spotify_id} in {PLAYLISTS_DB_PATH}: {e}", exc_info=True) + +# --- Artist Watch Database Functions --- + +def init_artists_db(): + """Initializes the artists database and creates the watched_artists table if it doesn't exist.""" + try: + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS watched_artists ( + spotify_id TEXT PRIMARY KEY, + name TEXT, + total_albums_on_spotify INTEGER, + last_checked INTEGER, + added_at INTEGER, + is_active INTEGER DEFAULT 1, + last_known_status TEXT, + last_task_id TEXT + ) + """) + conn.commit() + logger.info(f"Artists database initialized successfully at {ARTISTS_DB_PATH}") + except sqlite3.Error as e: + logger.error(f"Error initializing watched_artists table in {ARTISTS_DB_PATH}: {e}", exc_info=True) + raise + +def _create_artist_albums_table(artist_spotify_id: str): + """Creates a table for a specific artist to store its albums if it doesn't exist in artists.db.""" + table_name = f"artist_{artist_spotify_id.replace('-', '_')}_albums" + try: + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(f""" + CREATE TABLE IF NOT EXISTS {table_name} ( + album_spotify_id TEXT PRIMARY KEY, + name TEXT, + album_group TEXT, + album_type TEXT, + release_date TEXT, + total_tracks INTEGER, + added_to_db_at INTEGER, + is_download_initiated INTEGER DEFAULT 0, + task_id TEXT, + last_checked_for_tracks INTEGER + ) + """) + conn.commit() + logger.info(f"Albums table '{table_name}' for artist {artist_spotify_id} created or exists in {ARTISTS_DB_PATH}.") + except sqlite3.Error as e: + logger.error(f"Error creating artist albums table {table_name} in {ARTISTS_DB_PATH}: {e}", exc_info=True) + raise + +def add_artist_to_watch(artist_data: dict): + """Adds an artist to the watched_artists table and creates its albums table in artists.db.""" + artist_id = artist_data.get('id') + if not artist_id: + logger.error("Cannot add artist to watch: Missing 'id' in artist_data.") + return + + try: + _create_artist_albums_table(artist_id) + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT OR REPLACE INTO watched_artists + (spotify_id, name, total_albums_on_spotify, last_checked, added_at, is_active) + VALUES (?, ?, ?, ?, ?, 1) + """, ( + artist_id, + artist_data.get('name', 'N/A'), + artist_data.get('albums', {}).get('total', 0), + int(time.time()), + int(time.time()) + )) + conn.commit() + logger.info(f"Artist '{artist_data.get('name')}' ({artist_id}) added to watchlist in {ARTISTS_DB_PATH}.") + except sqlite3.Error as e: + logger.error(f"Error adding artist {artist_id} to watchlist in {ARTISTS_DB_PATH}: {e}", exc_info=True) + raise + except KeyError as e: + logger.error(f"Missing key in artist_data for artist {artist_id}: {e}. Data: {artist_data}", exc_info=True) + raise + +def remove_artist_from_watch(artist_spotify_id: str): + """Removes an artist from watched_artists and drops its albums table in artists.db.""" + table_name = f"artist_{artist_spotify_id.replace('-', '_')}_albums" + try: + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM watched_artists WHERE spotify_id = ?", (artist_spotify_id,)) + cursor.execute(f"DROP TABLE IF EXISTS {table_name}") + conn.commit() + logger.info(f"Artist {artist_spotify_id} removed from watchlist and its table '{table_name}' dropped from {ARTISTS_DB_PATH}.") + except sqlite3.Error as e: + logger.error(f"Error removing artist {artist_spotify_id} from watchlist in {ARTISTS_DB_PATH}: {e}", exc_info=True) + raise + +def get_watched_artists(): + """Retrieves all active artists from the watched_artists table in artists.db.""" + try: + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT * FROM watched_artists WHERE is_active = 1") + artists = [dict(row) for row in cursor.fetchall()] + return artists + except sqlite3.Error as e: + logger.error(f"Error retrieving watched artists from {ARTISTS_DB_PATH}: {e}", exc_info=True) + return [] + +def get_watched_artist(artist_spotify_id: str): + """Retrieves a specific artist from the watched_artists table in artists.db.""" + try: + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT * FROM watched_artists WHERE spotify_id = ?", (artist_spotify_id,)) + row = cursor.fetchone() + return dict(row) if row else None + except sqlite3.Error as e: + logger.error(f"Error retrieving artist {artist_spotify_id} from {ARTISTS_DB_PATH}: {e}", exc_info=True) + return None + +def update_artist_metadata_after_check(artist_spotify_id: str, total_albums_from_api: int): + """Updates the total_albums_on_spotify and last_checked for an artist in artists.db.""" + try: + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + UPDATE watched_artists + SET total_albums_on_spotify = ?, last_checked = ? + WHERE spotify_id = ? + """, (total_albums_from_api, int(time.time()), artist_spotify_id)) + conn.commit() + except sqlite3.Error as e: + logger.error(f"Error updating metadata for artist {artist_spotify_id} in {ARTISTS_DB_PATH}: {e}", exc_info=True) + +def get_artist_album_ids_from_db(artist_spotify_id: str): + """Retrieves all album Spotify IDs from a specific artist's albums table in artists.db.""" + table_name = f"artist_{artist_spotify_id.replace('-', '_')}_albums" + album_ids = set() + try: + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';") + if cursor.fetchone() is None: + logger.warning(f"Album table {table_name} for artist {artist_spotify_id} does not exist in {ARTISTS_DB_PATH}. Cannot fetch album IDs.") + return album_ids + cursor.execute(f"SELECT album_spotify_id FROM {table_name}") + rows = cursor.fetchall() + for row in rows: + album_ids.add(row['album_spotify_id']) + return album_ids + except sqlite3.Error as e: + logger.error(f"Error retrieving album IDs for artist {artist_spotify_id} from {ARTISTS_DB_PATH}: {e}", exc_info=True) + return album_ids + +def add_or_update_album_for_artist(artist_spotify_id: str, album_data: dict, task_id: str = None, is_download_complete: bool = False): + """Adds or updates an album in the specified artist's albums table in artists.db.""" + table_name = f"artist_{artist_spotify_id.replace('-', '_')}_albums" + album_id = album_data.get('id') + if not album_id: + logger.warning(f"Skipping album for artist {artist_spotify_id} due to missing album ID: {album_data}") + return + + download_status = 0 + if task_id and not is_download_complete: + download_status = 1 + elif is_download_complete: + download_status = 2 + + current_time = int(time.time()) + album_tuple = ( + album_id, + album_data.get('name', 'N/A'), + album_data.get('album_group', 'N/A'), + album_data.get('album_type', 'N/A'), + album_data.get('release_date'), + album_data.get('total_tracks'), + current_time, + download_status, + task_id + ) + try: + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + _create_artist_albums_table(artist_spotify_id) + + cursor.execute(f"SELECT added_to_db_at FROM {table_name} WHERE album_spotify_id = ?", (album_id,)) + existing_row = cursor.fetchone() + + if existing_row: + update_tuple = ( + album_data.get('name', 'N/A'), + album_data.get('album_group', 'N/A'), + album_data.get('album_type', 'N/A'), + album_data.get('release_date'), + album_data.get('total_tracks'), + download_status, + task_id, + album_id + ) + cursor.execute(f""" + UPDATE {table_name} SET + name = ?, album_group = ?, album_type = ?, release_date = ?, total_tracks = ?, + is_download_initiated = ?, task_id = ? + WHERE album_spotify_id = ? + """, update_tuple) + logger.info(f"Updated album '{album_data.get('name')}' in DB for artist {artist_spotify_id} in {ARTISTS_DB_PATH}.") + else: + cursor.execute(f""" + INSERT INTO {table_name} + (album_spotify_id, name, album_group, album_type, release_date, total_tracks, added_to_db_at, is_download_initiated, task_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, album_tuple) + logger.info(f"Added album '{album_data.get('name')}' to DB for artist {artist_spotify_id} in {ARTISTS_DB_PATH}.") + conn.commit() + except sqlite3.Error as e: + logger.error(f"Error adding/updating album {album_id} for artist {artist_spotify_id} in {ARTISTS_DB_PATH}: {e}", exc_info=True) + +def update_album_download_status_for_artist(artist_spotify_id: str, album_spotify_id: str, task_id: str, status: int): + """Updates the download status (is_download_initiated) and task_id for a specific album of an artist in artists.db.""" + table_name = f"artist_{artist_spotify_id.replace('-', '_')}_albums" + try: + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(f""" + UPDATE {table_name} + SET is_download_initiated = ?, task_id = ? + WHERE album_spotify_id = ? + """, (status, task_id, album_spotify_id)) + if cursor.rowcount == 0: + logger.warning(f"Attempted to update download status for non-existent album {album_spotify_id} for artist {artist_spotify_id} in {ARTISTS_DB_PATH}.") + else: + logger.info(f"Updated download status to {status} for album {album_spotify_id} (task: {task_id}) for artist {artist_spotify_id} in {ARTISTS_DB_PATH}.") + conn.commit() + except sqlite3.Error as e: + logger.error(f"Error updating album download status for album {album_spotify_id}, artist {artist_spotify_id} in {ARTISTS_DB_PATH}: {e}", exc_info=True) + +def add_specific_albums_to_artist_table(artist_spotify_id: str, album_details_list: list): + """ + Adds specific albums (with full details fetched separately) to the artist's album table. + This can be used when a user manually marks albums as "known" or "processed". + Albums added this way are marked with is_download_initiated = 3 (Manually Added/Known). + """ + if not album_details_list: + logger.info(f"No album details provided to add specifically for artist {artist_spotify_id}.") + return 0 + + processed_count = 0 + for album_data in album_details_list: + if not album_data or not album_data.get('id'): + logger.warning(f"Skipping album due to missing data or ID (manual add) for artist {artist_spotify_id}: {album_data}") + continue + + # Use existing function to add/update, ensuring it handles manual state + # Set task_id to None and is_download_initiated to a specific state for manually added known albums + # The add_or_update_album_for_artist expects `is_download_complete` not `is_download_initiated` directly. + # We can adapt `add_or_update_album_for_artist` or pass status directly if it's modified to handle it. + # For now, let's pass task_id=None and a flag that implies manual addition (e.g. is_download_complete=True, and then modify add_or_update_album_for_artist status logic) + # Or, more directly, update the `is_download_initiated` field as part of the album_tuple for INSERT and in UPDATE. + # Let's stick to calling `add_or_update_album_for_artist` and adjust its status handling if needed. + # Setting `is_download_complete=True` and `task_id=None` should set `is_download_initiated = 2` (completed) + # We might need a new status like 3 for "Manually Marked as Known" + # For simplicity, we'll use `add_or_update_album_for_artist` and the status will be 'download_complete'. + # If a more distinct status is needed, `add_or_update_album_for_artist` would need adjustment. + + # Simplification: we'll call add_or_update_album_for_artist which will mark it based on task_id presence or completion. + # For a truly "manual" state distinct from "downloaded", `add_or_update_album_for_artist` would need a new status value. + # Let's assume for now that adding it via this function means it's "known" and doesn't need downloading. + # The `add_or_update_album_for_artist` function sets is_download_initiated based on task_id and is_download_complete. + # If task_id is None and is_download_complete is True, it implies it's processed. + try: + add_or_update_album_for_artist(artist_spotify_id, album_data, task_id=None, is_download_complete=True) + processed_count += 1 + except Exception as e: + logger.error(f"Error manually adding album {album_data.get('id')} for artist {artist_spotify_id}: {e}", exc_info=True) + + logger.info(f"Manually added/updated {processed_count} albums in DB for artist {artist_spotify_id} in {ARTISTS_DB_PATH}.") + return processed_count + +def remove_specific_albums_from_artist_table(artist_spotify_id: str, album_spotify_ids: list): + """Removes specific albums from the artist's local album table.""" + table_name = f"artist_{artist_spotify_id.replace('-', '_')}_albums" + if not album_spotify_ids: + return 0 + + try: + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + placeholders = ','.join('?' for _ in album_spotify_ids) + # Check if table exists first + cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';") + if cursor.fetchone() is None: + logger.warning(f"Album table {table_name} for artist {artist_spotify_id} does not exist. Cannot remove albums.") + return 0 + + cursor.execute(f"DELETE FROM {table_name} WHERE album_spotify_id IN ({placeholders})", album_spotify_ids) + conn.commit() + deleted_count = cursor.rowcount + logger.info(f"Manually removed {deleted_count} albums from DB for artist {artist_spotify_id}.") + return deleted_count + except sqlite3.Error as e: + logger.error(f"Error manually removing albums for artist {artist_spotify_id} from table {table_name}: {e}", exc_info=True) + return 0 + +def is_track_in_playlist_db(playlist_spotify_id: str, track_spotify_id: str) -> bool: + """Checks if a specific track Spotify ID exists in the given playlist's tracks table.""" + table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" + try: + with _get_playlists_db_connection() as conn: + cursor = conn.cursor() + # First, check if the table exists to prevent errors on non-watched or new playlists + cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';") + if cursor.fetchone() is None: + return False # Table doesn't exist, so track cannot be in it + + cursor.execute(f"SELECT 1 FROM {table_name} WHERE spotify_track_id = ?", (track_spotify_id,)) + return cursor.fetchone() is not None + except sqlite3.Error as e: + logger.error(f"Error checking if track {track_spotify_id} is in playlist {playlist_spotify_id} DB: {e}", exc_info=True) + return False # Assume not present on error + +def is_album_in_artist_db(artist_spotify_id: str, album_spotify_id: str) -> bool: + """Checks if a specific album Spotify ID exists in the given artist's albums table.""" + table_name = f"artist_{artist_spotify_id.replace('-', '_')}_albums" + try: + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + # First, check if the table exists + cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';") + if cursor.fetchone() is None: + return False # Table doesn't exist + + cursor.execute(f"SELECT 1 FROM {table_name} WHERE album_spotify_id = ?", (album_spotify_id,)) + return cursor.fetchone() is not None + except sqlite3.Error as e: + logger.error(f"Error checking if album {album_spotify_id} is in artist {artist_spotify_id} DB: {e}", exc_info=True) + return False # Assume not present on error \ No newline at end of file diff --git a/routes/utils/watch/manager.py b/routes/utils/watch/manager.py new file mode 100644 index 0000000..88947d5 --- /dev/null +++ b/routes/utils/watch/manager.py @@ -0,0 +1,415 @@ +import time +import threading +import logging +import json +from pathlib import Path + +from routes.utils.watch.db import ( + get_watched_playlists, + get_watched_playlist, + get_playlist_track_ids_from_db, + add_tracks_to_playlist_db, + update_playlist_snapshot, + mark_tracks_as_not_present_in_spotify, + # Artist watch DB functions + init_artists_db, + get_watched_artists, + get_watched_artist, + get_artist_album_ids_from_db, + add_or_update_album_for_artist, # Renamed from add_album_to_artist_db + update_artist_metadata_after_check # Renamed from update_artist_metadata +) +from routes.utils.get_info import get_spotify_info # To fetch playlist, track, artist, and album details +from routes.utils.celery_queue_manager import download_queue_manager, get_config_params + +logger = logging.getLogger(__name__) +CONFIG_PATH = Path('./data/config/watch.json') +STOP_EVENT = threading.Event() + +DEFAULT_WATCH_CONFIG = { + "watchPollIntervalSeconds": 3600, + "max_tracks_per_run": 50, # For playlists + "watchedArtistAlbumGroup": ["album", "single"], # Default for artists + "delay_between_playlists_seconds": 2, + "delay_between_artists_seconds": 5 # Added for artists +} + +def get_watch_config(): + """Loads the watch configuration from watch.json.""" + try: + if CONFIG_PATH.exists(): + with open(CONFIG_PATH, 'r') as f: + config = json.load(f) + # Ensure all default keys are present + for key, value in DEFAULT_WATCH_CONFIG.items(): + config.setdefault(key, value) + return config + else: + # Create a default config if it doesn't exist + with open(CONFIG_PATH, 'w') as f: + json.dump(DEFAULT_WATCH_CONFIG, f, indent=2) + logger.info(f"Created default watch config at {CONFIG_PATH}") + return DEFAULT_WATCH_CONFIG + except Exception as e: + logger.error(f"Error loading watch config: {e}", exc_info=True) + return DEFAULT_WATCH_CONFIG # Fallback + +def construct_spotify_url(item_id, item_type="track"): + return f"https://open.spotify.com/{item_type}/{item_id}" + +def check_watched_playlists(specific_playlist_id: str = None): + """Checks watched playlists for new tracks and queues downloads. + If specific_playlist_id is provided, only that playlist is checked. + """ + logger.info(f"Playlist Watch Manager: Starting check. Specific playlist: {specific_playlist_id or 'All'}") + config = get_watch_config() + + if specific_playlist_id: + playlist_obj = get_watched_playlist(specific_playlist_id) + if not playlist_obj: + logger.error(f"Playlist Watch Manager: Playlist {specific_playlist_id} not found in watch database.") + return + watched_playlists_to_check = [playlist_obj] + else: + watched_playlists_to_check = get_watched_playlists() + + if not watched_playlists_to_check: + logger.info("Playlist Watch Manager: No playlists to check.") + return + + for playlist_in_db in watched_playlists_to_check: + playlist_spotify_id = playlist_in_db['spotify_id'] + playlist_name = playlist_in_db['name'] + logger.info(f"Playlist Watch Manager: Checking playlist '{playlist_name}' ({playlist_spotify_id})...") + + try: + # For playlists, we fetch all tracks in one go usually (Spotify API limit permitting) + current_playlist_data_from_api = get_spotify_info(playlist_spotify_id, "playlist") + if not current_playlist_data_from_api or 'tracks' not in current_playlist_data_from_api: + logger.error(f"Playlist Watch Manager: Failed to fetch data or tracks from Spotify for playlist {playlist_spotify_id}.") + continue + + api_snapshot_id = current_playlist_data_from_api.get('snapshot_id') + api_total_tracks = current_playlist_data_from_api.get('tracks', {}).get('total', 0) + + # Paginate through playlist tracks if necessary + all_api_track_items = [] + offset = 0 + limit = 50 # Spotify API limit for playlist items + + while True: + # Re-fetch with pagination if tracks.next is present, or on first call. + # get_spotify_info for playlist should ideally handle pagination internally if asked for all tracks. + # Assuming get_spotify_info for playlist returns all items or needs to be called iteratively. + # For simplicity, let's assume current_playlist_data_from_api has 'tracks' -> 'items' for the first page. + # And that get_spotify_info with 'playlist' type can take offset. + # Modifying get_spotify_info is outside current scope, so we'll assume it returns ALL items for a playlist. + # If it doesn't, this part would need adjustment for robust pagination. + # For now, we use the items from the initial fetch. + + paginated_playlist_data = get_spotify_info(playlist_spotify_id, "playlist", offset=offset, limit=limit) + if not paginated_playlist_data or 'tracks' not in paginated_playlist_data: + break + + page_items = paginated_playlist_data.get('tracks', {}).get('items', []) + if not page_items: + break + all_api_track_items.extend(page_items) + + if paginated_playlist_data.get('tracks', {}).get('next'): + offset += limit + else: + break + + current_api_track_ids = set() + api_track_id_to_item_map = {} + for item in all_api_track_items: # Use all_api_track_items + track = item.get('track') + if track and track.get('id') and not track.get('is_local'): + track_id = track['id'] + current_api_track_ids.add(track_id) + api_track_id_to_item_map[track_id] = item + + db_track_ids = get_playlist_track_ids_from_db(playlist_spotify_id) + + new_track_ids_for_download = current_api_track_ids - db_track_ids + queued_for_download_count = 0 + if new_track_ids_for_download: + logger.info(f"Playlist Watch Manager: Found {len(new_track_ids_for_download)} new tracks for playlist '{playlist_name}' to download.") + for track_id in new_track_ids_for_download: + api_item = api_track_id_to_item_map.get(track_id) + if not api_item or not api_item.get("track"): + logger.warning(f"Playlist Watch Manager: Missing track details in API map for new track_id {track_id} in playlist {playlist_spotify_id}. Cannot queue.") + continue + + track_to_queue = api_item["track"] + task_payload = { + "download_type": "track", + "url": construct_spotify_url(track_id, "track"), + "name": track_to_queue.get('name', 'Unknown Track'), + "artist": ", ".join([a['name'] for a in track_to_queue.get('artists', []) if a.get('name')]), + "orig_request": { + "source": "playlist_watch", + "playlist_id": playlist_spotify_id, + "playlist_name": playlist_name, + "track_spotify_id": track_id, + "track_item_for_db": api_item # Pass full API item for DB update on completion + } + # "track_details_for_db" was old name, using track_item_for_db consistent with celery_tasks + } + try: + task_id_or_none = download_queue_manager.add_task(task_payload, from_watch_job=True) + if task_id_or_none: # Task was newly queued + logger.info(f"Playlist Watch Manager: Queued download task {task_id_or_none} for new track {track_id} ('{track_to_queue.get('name')}') from playlist '{playlist_name}'.") + queued_for_download_count += 1 + # If task_id_or_none is None, it was a duplicate and not re-queued, Celery manager handles logging. + except Exception as e: + logger.error(f"Playlist Watch Manager: Failed to queue download for new track {track_id} from playlist '{playlist_name}': {e}", exc_info=True) + logger.info(f"Playlist Watch Manager: Attempted to queue {queued_for_download_count} new tracks for playlist '{playlist_name}'.") + else: + logger.info(f"Playlist Watch Manager: No new tracks to download for playlist '{playlist_name}'.") + + # Update DB for tracks that are still present in API (e.g. update 'last_seen_in_spotify') + # add_tracks_to_playlist_db handles INSERT OR REPLACE, updating existing entries. + # We should pass all current API tracks to ensure their `last_seen_in_spotify` and `is_present_in_spotify` are updated. + if all_api_track_items: # If there are any tracks in the API for this playlist + logger.info(f"Playlist Watch Manager: Refreshing {len(all_api_track_items)} tracks from API in local DB for playlist '{playlist_name}'.") + add_tracks_to_playlist_db(playlist_spotify_id, all_api_track_items) + + + removed_db_ids = db_track_ids - current_api_track_ids + if removed_db_ids: + logger.info(f"Playlist Watch Manager: {len(removed_db_ids)} tracks removed from Spotify playlist '{playlist_name}'. Marking in DB.") + mark_tracks_as_not_present_in_spotify(playlist_spotify_id, list(removed_db_ids)) + + update_playlist_snapshot(playlist_spotify_id, api_snapshot_id, api_total_tracks) # api_total_tracks from initial fetch + logger.info(f"Playlist Watch Manager: Finished checking playlist '{playlist_name}'. Snapshot ID updated. API Total Tracks: {api_total_tracks}.") + + except Exception as e: + logger.error(f"Playlist Watch Manager: Error processing playlist {playlist_spotify_id}: {e}", exc_info=True) + + time.sleep(max(1, config.get("delay_between_playlists_seconds", 2))) + + logger.info("Playlist Watch Manager: Finished checking all watched playlists.") + +def check_watched_artists(specific_artist_id: str = None): + """Checks watched artists for new albums and queues downloads.""" + logger.info(f"Artist Watch Manager: Starting check. Specific artist: {specific_artist_id or 'All'}") + config = get_watch_config() + watched_album_groups = [g.lower() for g in config.get("watchedArtistAlbumGroup", ["album", "single"])] + logger.info(f"Artist Watch Manager: Watching for album groups: {watched_album_groups}") + + if specific_artist_id: + artist_obj_in_db = get_watched_artist(specific_artist_id) + if not artist_obj_in_db: + logger.error(f"Artist Watch Manager: Artist {specific_artist_id} not found in watch database.") + return + artists_to_check = [artist_obj_in_db] + else: + artists_to_check = get_watched_artists() + + if not artists_to_check: + logger.info("Artist Watch Manager: No artists to check.") + return + + for artist_in_db in artists_to_check: + artist_spotify_id = artist_in_db['spotify_id'] + artist_name = artist_in_db['name'] + logger.info(f"Artist Watch Manager: Checking artist '{artist_name}' ({artist_spotify_id})...") + + try: + # Spotify API for artist albums is paginated. + # We need to fetch all albums. get_spotify_info with type 'artist-albums' should handle this. + # Let's assume get_spotify_info(artist_id, 'artist-albums') returns a list of all album objects. + # Or we implement pagination here. + + all_artist_albums_from_api = [] + offset = 0 + limit = 50 # Spotify API limit for artist albums + while True: + # The 'artist-albums' type for get_spotify_info needs to support pagination params. + # And return a list of album objects. + logger.debug(f"Artist Watch Manager: Fetching albums for {artist_spotify_id}. Limit: {limit}, Offset: {offset}") + artist_albums_page = get_spotify_info(artist_spotify_id, "artist", limit=limit, offset=offset) + + if not artist_albums_page or not isinstance(artist_albums_page.get('items'), list): + logger.warning(f"Artist Watch Manager: No album items found or invalid format for artist {artist_spotify_id} (name: '{artist_name}') at offset {offset}. Response: {artist_albums_page}") + break + + current_page_albums = artist_albums_page.get('items', []) + if not current_page_albums: + logger.info(f"Artist Watch Manager: No more albums on page for artist {artist_spotify_id} (name: '{artist_name}') at offset {offset}. Total fetched so far: {len(all_artist_albums_from_api)}.") + break + + logger.debug(f"Artist Watch Manager: Fetched {len(current_page_albums)} albums on current page for artist '{artist_name}'.") + all_artist_albums_from_api.extend(current_page_albums) + + # Correct pagination: Check if Spotify indicates a next page URL + # The `next` field in Spotify API responses is a URL to the next page or null. + if artist_albums_page.get('next'): + offset += limit # CORRECT: Increment offset by the limit used for the request + else: + logger.info(f"Artist Watch Manager: No 'next' page URL for artist '{artist_name}'. Pagination complete. Total albums fetched: {len(all_artist_albums_from_api)}.") + break + + # total_albums_from_api = len(all_artist_albums_from_api) + # Use the 'total' field from the API response for a more accurate count of all available albums (matching current API filter if any) + api_reported_total_albums = artist_albums_page.get('total', 0) if 'artist_albums_page' in locals() and artist_albums_page else len(all_artist_albums_from_api) + logger.info(f"Artist Watch Manager: Fetched {len(all_artist_albums_from_api)} albums in total from API for artist '{artist_name}'. API reports total: {api_reported_total_albums}.") + + db_album_ids = get_artist_album_ids_from_db(artist_spotify_id) + logger.info(f"Artist Watch Manager: Found {len(db_album_ids)} albums in DB for artist '{artist_name}'. These will be skipped if re-encountered unless logic changes.") + + queued_for_download_count = 0 + processed_album_ids_in_run = set() # To avoid processing duplicate album_ids if API returns them across pages (should not happen with correct pagination) + + for album_data in all_artist_albums_from_api: + album_id = album_data.get('id') + album_name = album_data.get('name', 'Unknown Album') + album_group = album_data.get('album_group', 'N/A').lower() + album_type = album_data.get('album_type', 'N/A').lower() + + if not album_id: + logger.warning(f"Artist Watch Manager: Skipping album without ID for artist '{artist_name}'. Album data: {album_data}") + continue + + if album_id in processed_album_ids_in_run: + logger.debug(f"Artist Watch Manager: Album '{album_name}' ({album_id}) already processed in this run. Skipping.") + continue + processed_album_ids_in_run.add(album_id) + + # Filter based on watchedArtistAlbumGroup + # The album_group field is generally preferred for this type of categorization as per Spotify docs. + is_matching_group = album_group in watched_album_groups + + logger.debug(f"Artist '{artist_name}', Album '{album_name}' ({album_id}): album_group='{album_group}', album_type='{album_type}'. Watched groups: {watched_album_groups}. Match: {is_matching_group}.") + + if not is_matching_group: + logger.debug(f"Artist Watch Manager: Skipping album '{album_name}' ({album_id}) by '{artist_name}' - group '{album_group}' not in watched list: {watched_album_groups}.") + continue + + logger.info(f"Artist Watch Manager: Album '{album_name}' ({album_id}) by '{artist_name}' (group: {album_group}) IS a matching group.") + + if album_id not in db_album_ids: + logger.info(f"Artist Watch Manager: Found NEW matching album '{album_name}' ({album_id}) by '{artist_name}'. Queuing for download.") + + album_artists_list = album_data.get('artists', []) + album_main_artist_name = album_artists_list[0].get('name', 'Unknown Artist') if album_artists_list else 'Unknown Artist' + + task_payload = { + "download_type": "album", # Or "track" if downloading individual tracks of album later + "url": construct_spotify_url(album_id, "album"), + "name": album_name, + "artist": album_main_artist_name, # Primary artist of the album + "orig_request": { + "source": "artist_watch", + "artist_spotify_id": artist_spotify_id, # Watched artist + "artist_name": artist_name, + "album_spotify_id": album_id, + "album_data_for_db": album_data # Pass full API album object for DB update on completion/queuing + } + } + try: + # Add to DB first with task_id, then queue. Or queue and add task_id to DB. + # Let's use add_or_update_album_for_artist to record it with a task_id before queuing. + # The celery_queue_manager.add_task might return None if it's a duplicate. + + # Record the album in DB as being processed for download + # Task_id will be added if successfully queued + + # We should call add_task first, and if it returns a task_id (not a duplicate), then update our DB. + task_id_or_none = download_queue_manager.add_task(task_payload, from_watch_job=True) + + if task_id_or_none: # Task was newly queued + add_or_update_album_for_artist(artist_spotify_id, album_data, task_id=task_id_or_none, is_download_complete=False) + logger.info(f"Artist Watch Manager: Queued download task {task_id_or_none} for new album '{album_name}' from artist '{artist_name}'.") + queued_for_download_count += 1 + # If task_id_or_none is None, it was a duplicate. We can still log/record album_data if needed, but without task_id or as already seen. + # add_or_update_album_for_artist(artist_spotify_id, album_data, task_id=None) # This would just log metadata if not a duplicate. + # The current add_task logic in celery_manager might create an error task for duplicates, + # so we might not need to do anything special here for duplicates apart from not incrementing count. + + except Exception as e: + logger.error(f"Artist Watch Manager: Failed to queue/record download for new album {album_id} ('{album_name}') from artist '{artist_name}': {e}", exc_info=True) + else: + logger.info(f"Artist Watch Manager: Album '{album_name}' ({album_id}) by '{artist_name}' already known in DB (ID found in db_album_ids). Skipping queue.") + # Optionally, update its entry (e.g. last_seen, or if details changed), but for now, we only queue new ones. + # add_or_update_album_for_artist(artist_spotify_id, album_data, task_id=None, is_download_complete=False) # would update added_to_db_at + + logger.info(f"Artist Watch Manager: For artist '{artist_name}', processed {len(all_artist_albums_from_api)} API albums, attempted to queue {queued_for_download_count} new albums.") + + update_artist_metadata_after_check(artist_spotify_id, api_reported_total_albums) + logger.info(f"Artist Watch Manager: Finished checking artist '{artist_name}'. DB metadata updated. API reported total albums (for API filter): {api_reported_total_albums}.") + + except Exception as e: + logger.error(f"Artist Watch Manager: Error processing artist {artist_spotify_id} ('{artist_name}'): {e}", exc_info=True) + + time.sleep(max(1, config.get("delay_between_artists_seconds", 5))) + + logger.info("Artist Watch Manager: Finished checking all watched artists.") + +def playlist_watch_scheduler(): + """Periodically calls check_watched_playlists and check_watched_artists.""" + logger.info("Watch Scheduler: Thread started.") + config = get_watch_config() # Load config once at start, or reload each loop? Reload each loop for dynamic changes. + + while not STOP_EVENT.is_set(): + current_config = get_watch_config() # Get latest config for this run + interval = current_config.get("watchPollIntervalSeconds", 3600) + + try: + logger.info("Watch Scheduler: Starting playlist check run.") + check_watched_playlists() + logger.info("Watch Scheduler: Playlist check run completed.") + except Exception as e: + logger.error(f"Watch Scheduler: Unhandled exception during check_watched_playlists: {e}", exc_info=True) + + # Add a small delay between playlist and artist checks if desired + # time.sleep(current_config.get("delay_between_check_types_seconds", 10)) + if STOP_EVENT.is_set(): break # Check stop event again before starting artist check + + try: + logger.info("Watch Scheduler: Starting artist check run.") + check_watched_artists() + logger.info("Watch Scheduler: Artist check run completed.") + except Exception as e: + logger.error(f"Watch Scheduler: Unhandled exception during check_watched_artists: {e}", exc_info=True) + + logger.info(f"Watch Scheduler: All checks complete. Next run in {interval} seconds.") + STOP_EVENT.wait(interval) + logger.info("Watch Scheduler: Thread stopped.") + +# --- Global thread for the scheduler --- +_watch_scheduler_thread = None # Renamed from _playlist_watch_thread + +def start_watch_manager(): # Renamed from start_playlist_watch_manager + global _watch_scheduler_thread + if _watch_scheduler_thread is None or not _watch_scheduler_thread.is_alive(): + STOP_EVENT.clear() + # Initialize DBs on start + from routes.utils.watch.db import init_playlists_db, init_artists_db # Updated import + init_playlists_db() # For playlists + init_artists_db() # For artists + + _watch_scheduler_thread = threading.Thread(target=playlist_watch_scheduler, daemon=True) + _watch_scheduler_thread.start() + logger.info("Watch Manager: Background scheduler started (includes playlists and artists).") + else: + logger.info("Watch Manager: Background scheduler already running.") + +def stop_watch_manager(): # Renamed from stop_playlist_watch_manager + global _watch_scheduler_thread + if _watch_scheduler_thread and _watch_scheduler_thread.is_alive(): + logger.info("Watch Manager: Stopping background scheduler...") + STOP_EVENT.set() + _watch_scheduler_thread.join(timeout=10) + if _watch_scheduler_thread.is_alive(): + logger.warning("Watch Manager: Scheduler thread did not stop in time.") + else: + logger.info("Watch Manager: Background scheduler stopped.") + _watch_scheduler_thread = None + else: + logger.info("Watch Manager: Background scheduler not running.") + +# If this module is imported, and you want to auto-start the manager, you could call start_watch_manager() here. +# However, it's usually better to explicitly start it from the main application/__init__.py. diff --git a/src/js/album.ts b/src/js/album.ts index 07865b4..1d62863 100644 --- a/src/js/album.ts +++ b/src/js/album.ts @@ -254,7 +254,7 @@ function renderAlbum(album: Album) {
${msToTime(track.duration_ms || 0)}
`; - groupSection.innerHTML = ` - ${groupHeaderHTML} -
- `; + groupSection.innerHTML = groupHeaderHTML; + const albumsListContainer = document.createElement('div'); + albumsListContainer.className = 'albums-list'; - const albumsContainer = groupSection.querySelector('.albums-list'); - if (albumsContainer) { - albums.forEach(album => { - if (!album) return; + albums.forEach(album => { + if (!album) return; + const albumElement = document.createElement('div'); + albumElement.className = 'album-card'; + + let albumCardHTML = ` + + Album cover + +
+
${album.name || 'Unknown Album'}
+
${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}
+
+ `; + + const actionsContainer = document.createElement('div'); + actionsContainer.className = 'album-actions-container'; + + if (!isExplicitFilterEnabled) { + const downloadBtnHTML = ` + + `; + actionsContainer.innerHTML += downloadBtnHTML; + } + + if (isArtistWatched) { + // Initial state is set based on album.is_locally_known + const isKnown = album.is_locally_known === true; + const initialStatus = isKnown ? "known" : "missing"; + const initialIcon = isKnown ? "/static/images/check.svg" : "/static/images/missing.svg"; + const initialTitle = isKnown ? "Click to mark as missing from DB" : "Click to mark as known in DB"; - 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); - }); - } + const toggleKnownBtnHTML = ` + + `; + actionsContainer.innerHTML += toggleKnownBtnHTML; + } + albumElement.innerHTML = albumCardHTML; + if (actionsContainer.hasChildNodes()) { + albumElement.appendChild(actionsContainer); + } + albumsListContainer.appendChild(albumElement); + }); + groupSection.appendChild(albumsListContainer); 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

@@ -288,109 +303,104 @@ function renderArtist(artistData: ArtistData, artistId: string) { Download All Featuring Albums
`; + featuringSection.innerHTML = featuringHeaderHTML; + const appearingAlbumsListContainer = document.createElement('div'); + appearingAlbumsListContainer.className = 'albums-list'; - featuringSection.innerHTML = ` - ${featuringHeaderHTML} -
- `; + appearingAlbums.forEach(album => { + if (!album) return; + const albumElement = document.createElement('div'); + albumElement.className = 'album-card'; + let albumCardHTML = ` + + Album cover + +
+
${album.name || 'Unknown Album'}
+
${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}
+
+ `; + + const actionsContainer = document.createElement('div'); + actionsContainer.className = 'album-actions-container'; - const albumsContainer = featuringSection.querySelector('.albums-list'); - if (albumsContainer) { - appearingAlbums.forEach(album => { - if (!album) return; + if (!isExplicitFilterEnabled) { + const downloadBtnHTML = ` + + `; + actionsContainer.innerHTML += downloadBtnHTML; + } + + if (isArtistWatched) { + // Initial state is set based on album.is_locally_known + const isKnown = album.is_locally_known === true; + const initialStatus = isKnown ? "known" : "missing"; + const initialIcon = isKnown ? "/static/images/check.svg" : "/static/images/missing.svg"; + const initialTitle = isKnown ? "Click to mark as missing from DB" : "Click to mark as known in DB"; - 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 + const toggleKnownBtnHTML = ` + + `; + actionsContainer.innerHTML += toggleKnownBtnHTML; + } + albumElement.innerHTML = albumCardHTML; + if (actionsContainer.hasChildNodes()) { + albumElement.appendChild(actionsContainer); + } + appearingAlbumsListContainer.appendChild(albumElement); + }); + featuringSection.appendChild(appearingAlbumsListContainer); 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); + attachAlbumActionListeners(artistId); + attachGroupDownloadListeners(artistId, artistName); } } -// Event listeners for group downloads using the artist download function -function attachGroupDownloadListeners(artistUrl: string, artistName: string) { +function attachGroupDownloadListeners(artistId: string, artistName: string) { document.querySelectorAll('.group-download-btn').forEach(btn => { - const button = btn as HTMLButtonElement; // Cast to HTMLButtonElement + const button = btn as HTMLButtonElement; button.addEventListener('click', async (e) => { - const target = e.target as HTMLButtonElement | null; // Cast target + const target = e.target as HTMLButtonElement | null; if (!target) return; - - const groupType = target.dataset.groupType || 'album'; // e.g. "album", "single", "compilation", "appears_on" + const groupType = target.dataset.groupType || 'album'; 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, + artistId, 'artist', { name: artistName || 'Unknown Artist', artist: artistName || 'Unknown Artist' }, - groupType // Only queue releases of this specific type. + groupType ); - - // 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 + } catch (error: any) { target.textContent = `Download All ${displayType}`; target.disabled = false; showError(`Failed to queue download for all ${groupType}: ${error?.message || 'Unknown error'}`); @@ -399,41 +409,116 @@ function attachGroupDownloadListeners(artistUrl: string, artistName: string) { }); } -// 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 +function attachAlbumActionListeners(artistIdForContext: string) { + document.querySelectorAll('.album-download-btn').forEach(btn => { + const button = btn as HTMLButtonElement; button.addEventListener('click', (e) => { e.stopPropagation(); - const currentTarget = e.currentTarget as HTMLButtonElement | null; // Cast currentTarget + const currentTarget = e.currentTarget as HTMLButtonElement | null; if (!currentTarget) return; - - const url = currentTarget.dataset.url || ''; + const itemId = currentTarget.dataset.id || ''; const name = currentTarget.dataset.name || 'Unknown'; - // Always use 'album' type for individual album downloads regardless of category const type = 'album'; - + if (!itemId) { + showError('Could not get album ID for download'); + return; + } 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 + downloadQueue.download(itemId, type, { name, type }) + .catch((err: any) => showError('Download failed: ' + (err?.message || 'Unknown error'))); }); }); + + document.querySelectorAll('.toggle-known-status-btn').forEach((btn) => { + btn.addEventListener('click', async (e: Event) => { + e.stopPropagation(); + const button = e.currentTarget as HTMLButtonElement; + const albumId = button.dataset.id || ''; + const artistId = button.dataset.artistId || artistIdForContext; + const currentStatus = button.dataset.status; + const img = button.querySelector('img'); + + if (!albumId || !artistId || !img) { + showError('Missing data for toggling album status'); + return; + } + + button.disabled = true; + try { + if (currentStatus === 'missing') { + await handleMarkAlbumAsKnown(artistId, albumId); + button.dataset.status = 'known'; + img.src = '/static/images/check.svg'; + button.title = 'Click to mark as missing from DB'; + } else { + await handleMarkAlbumAsMissing(artistId, albumId); + button.dataset.status = 'missing'; + img.src = '/static/images/missing.svg'; + button.title = 'Click to mark as known in DB'; + } + } catch (error) { + showError('Failed to update album status. Please try again.'); + } + button.disabled = false; + }); + }); +} + +async function handleMarkAlbumAsKnown(artistId: string, albumId: string) { + try { + const response = await fetch(`/api/artist/watch/${artistId}/albums`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify([albumId]), + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + const result = await response.json(); + showNotification(result.message || 'Album marked as known.'); + } catch (error: any) { + showError(`Failed to mark album as known: ${error.message}`); + throw error; // Re-throw for the caller to handle button state if needed + } +} + +async function handleMarkAlbumAsMissing(artistId: string, albumId: string) { + try { + const response = await fetch(`/api/artist/watch/${artistId}/albums`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify([albumId]), + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + const result = await response.json(); + showNotification(result.message || 'Album marked as missing.'); + } catch (error: any) { + showError(`Failed to mark album as missing: ${error.message}`); + throw error; // Re-throw + } } // 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 +async function startDownload(itemId: string, type: string, item: { name: string, artist?: string, type?: string }, albumType?: string) { + if (!itemId || !type) { + showError('Missing ID or type for download'); + return Promise.reject(new Error('Missing ID 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); + const result = await downloadQueue.download(itemId, type, item, albumType); // Make the queue visible after queueing downloadQueue.toggleVisibility(true); @@ -458,3 +543,171 @@ function showError(message: string) { function capitalize(str: string) { return str ? str.charAt(0).toUpperCase() + str.slice(1) : ''; } + +async function getArtistWatchStatus(artistId: string): Promise { + try { + const response = await fetch(`/api/artist/watch/${artistId}/status`); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); // Catch if res not json + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + const data: WatchStatusResponse = await response.json(); + return data.is_watched; + } catch (error) { + console.error('Error fetching artist watch status:', error); + showError('Could not fetch watch status.'); + return false; // Assume not watching on error + } +} + +async function watchArtist(artistId: string): Promise { + try { + const response = await fetch(`/api/artist/watch/${artistId}`, { + method: 'PUT', + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + // Optionally handle success message from response.json() + await response.json(); + } catch (error) { + console.error('Error watching artist:', error); + showError('Failed to watch artist.'); + throw error; // Re-throw to allow caller to handle UI update failure + } +} + +async function unwatchArtist(artistId: string): Promise { + try { + const response = await fetch(`/api/artist/watch/${artistId}`, { + method: 'DELETE', + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + // Optionally handle success message + await response.json(); + } catch (error) { + console.error('Error unwatching artist:', error); + showError('Failed to unwatch artist.'); + throw error; // Re-throw + } +} + +function updateWatchButton(artistId: string, isWatching: boolean) { + const watchArtistBtn = document.getElementById('watchArtistBtn') as HTMLButtonElement | null; + const syncArtistBtn = document.getElementById('syncArtistBtn') as HTMLButtonElement | null; + + if (watchArtistBtn) { + const img = watchArtistBtn.querySelector('img'); + if (isWatching) { + if (img) img.src = '/static/images/eye-crossed.svg'; + watchArtistBtn.innerHTML = `Unwatch Unwatch Artist`; + watchArtistBtn.classList.add('watching'); + watchArtistBtn.title = "Stop watching this artist"; + if (syncArtistBtn) syncArtistBtn.classList.remove('hidden'); + } else { + if (img) img.src = '/static/images/eye.svg'; + watchArtistBtn.innerHTML = `Watch Watch Artist`; + watchArtistBtn.classList.remove('watching'); + watchArtistBtn.title = "Watch this artist for new releases"; + if (syncArtistBtn) syncArtistBtn.classList.add('hidden'); + } + watchArtistBtn.dataset.watching = isWatching ? 'true' : 'false'; + } +} + +async function initializeWatchButton(artistId: string) { + const watchArtistBtn = document.getElementById('watchArtistBtn') as HTMLButtonElement | null; + const syncArtistBtn = document.getElementById('syncArtistBtn') as HTMLButtonElement | null; + + if (!watchArtistBtn) return; + + try { + watchArtistBtn.disabled = true; // Disable while fetching status + if (syncArtistBtn) syncArtistBtn.disabled = true; // Also disable sync button initially + + const isWatching = await getArtistWatchStatus(artistId); + updateWatchButton(artistId, isWatching); + watchArtistBtn.disabled = false; + if (syncArtistBtn) syncArtistBtn.disabled = !(watchArtistBtn.dataset.watching === 'true'); // Corrected logic + + watchArtistBtn.addEventListener('click', async () => { + const currentlyWatching = watchArtistBtn.dataset.watching === 'true'; + watchArtistBtn.disabled = true; + if (syncArtistBtn) syncArtistBtn.disabled = true; + try { + if (currentlyWatching) { + await unwatchArtist(artistId); + updateWatchButton(artistId, false); + } else { + await watchArtist(artistId); + updateWatchButton(artistId, true); + } + } catch (error) { + updateWatchButton(artistId, currentlyWatching); + } + watchArtistBtn.disabled = false; + if (syncArtistBtn) syncArtistBtn.disabled = !(watchArtistBtn.dataset.watching === 'true'); // Corrected logic + }); + + // Add event listener for the sync button + if (syncArtistBtn) { + syncArtistBtn.addEventListener('click', async () => { + syncArtistBtn.disabled = true; + const originalButtonContent = syncArtistBtn.innerHTML; // Store full HTML + const textNode = Array.from(syncArtistBtn.childNodes).find(node => node.nodeType === Node.TEXT_NODE); + const originalText = textNode ? textNode.nodeValue : 'Sync Watched Artist'; // Fallback text + + syncArtistBtn.innerHTML = `Sync Syncing...`; // Keep icon + try { + await triggerArtistSync(artistId); + showNotification('Artist sync triggered successfully.'); + } catch (error) { + // Error is shown by triggerArtistSync + } + syncArtistBtn.innerHTML = originalButtonContent; // Restore full original HTML + syncArtistBtn.disabled = false; + }); + } + + } catch (error) { + if (watchArtistBtn) watchArtistBtn.disabled = false; + if (syncArtistBtn) syncArtistBtn.disabled = true; // Keep sync disabled on error + updateWatchButton(artistId, false); + } +} + +// New function to trigger artist sync +async function triggerArtistSync(artistId: string): Promise { + try { + const response = await fetch(`/api/artist/watch/trigger_check/${artistId}`, { + method: 'POST', + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + await response.json(); // Contains success message + } catch (error) { + console.error('Error triggering artist sync:', error); + showError('Failed to trigger artist sync.'); + throw error; // Re-throw + } +} + +/** + * Displays a temporary notification message. + */ +function showNotification(message: string) { + // Basic notification - consider a more robust solution for production + const notificationEl = document.createElement('div'); + notificationEl.className = 'notification'; // Ensure this class is styled + notificationEl.textContent = message; + document.body.appendChild(notificationEl); + setTimeout(() => { + notificationEl.remove(); + }, 3000); +} diff --git a/src/js/config.ts b/src/js/config.ts index 86556b4..09aaf1f 100644 --- a/src/js/config.ts +++ b/src/js/config.ts @@ -124,6 +124,9 @@ async function loadConfig() { // Update explicit filter status updateExplicitFilterStatus(savedConfig.explicitFilter); + + // Load watch config + await loadWatchConfig(); } catch (error: any) { showConfigError('Error loading config: ' + error.message); } @@ -230,6 +233,12 @@ function setupEventListeners() { // Max concurrent downloads change listener (document.getElementById('maxConcurrentDownloads') as HTMLInputElement | null)?.addEventListener('change', saveConfig); + + // Watch options listeners + document.querySelectorAll('#watchedArtistAlbumGroupChecklist input[type="checkbox"]').forEach(checkbox => { + checkbox.addEventListener('change', saveWatchConfig); + }); + (document.getElementById('watchPollIntervalSeconds') as HTMLInputElement | null)?.addEventListener('change', saveWatchConfig); } function updateServiceSpecificOptions() { @@ -834,6 +843,9 @@ async function saveConfig() { // Update explicit filter status updateExplicitFilterStatus(savedConfig.explicitFilter); + + // Load watch config + await loadWatchConfig(); } catch (error: any) { showConfigError('Error loading config: ' + error.message); } @@ -921,3 +933,55 @@ function showCopyNotification(message: string) { }, 300); }, 2000); } + +async function loadWatchConfig() { + try { + const response = await fetch('/api/config/watch'); + if (!response.ok) throw new Error('Failed to load watch config'); + const watchConfig = await response.json(); + + const checklistContainer = document.getElementById('watchedArtistAlbumGroupChecklist'); + if (checklistContainer && watchConfig.watchedArtistAlbumGroup) { + const checkboxes = checklistContainer.querySelectorAll('input[type="checkbox"]') as NodeListOf; + checkboxes.forEach(checkbox => { + checkbox.checked = watchConfig.watchedArtistAlbumGroup.includes(checkbox.value); + }); + } + + const watchPollIntervalSecondsInput = document.getElementById('watchPollIntervalSeconds') as HTMLInputElement | null; + if (watchPollIntervalSecondsInput) watchPollIntervalSecondsInput.value = watchConfig.watchPollIntervalSeconds || '3600'; + + } catch (error: any) { + showConfigError('Error loading watch config: ' + error.message); + } +} + +async function saveWatchConfig() { + const checklistContainer = document.getElementById('watchedArtistAlbumGroupChecklist'); + const selectedGroups: string[] = []; + if (checklistContainer) { + const checkedBoxes = checklistContainer.querySelectorAll('input[type="checkbox"]:checked') as NodeListOf; + checkedBoxes.forEach(checkbox => selectedGroups.push(checkbox.value)); + } + + const watchConfig = { + watchedArtistAlbumGroup: selectedGroups, + watchPollIntervalSeconds: parseInt((document.getElementById('watchPollIntervalSeconds') as HTMLInputElement | null)?.value || '3600', 10) || 3600, + }; + + try { + const response = await fetch('/api/config/watch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(watchConfig) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to save watch config'); + } + showConfigSuccess('Watch settings saved successfully.'); + } catch (error: any) { + showConfigError('Error saving watch config: ' + error.message); + } +} diff --git a/src/js/main.ts b/src/js/main.ts index 14340d1..bc14a91 100644 --- a/src/js/main.ts +++ b/src/js/main.ts @@ -318,15 +318,10 @@ document.addEventListener('DOMContentLoaded', function() { if (!item) return; const currentSearchType = searchType?.value || 'track'; - let url; + let itemId = item.id || ''; // Use item.id directly - // Determine the URL based on item type - if (item.external_urls && item.external_urls.spotify) { - url = item.external_urls.spotify; - } else if (item.href) { - url = item.href; - } else { - showError('Could not determine download URL'); + if (!itemId) { // Check if ID was found + showError('Could not determine download ID'); return; } @@ -374,7 +369,7 @@ document.addEventListener('DOMContentLoaded', function() { } // Start the download - startDownload(url, currentSearchType, metadata, + startDownload(itemId, currentSearchType, metadata, (item as AlbumResultItem).album_type || ((item as TrackResultItem).album ? (item as TrackResultItem).album.album_type : null)) .then(() => { // For artists, show how many albums were queued @@ -398,15 +393,15 @@ document.addEventListener('DOMContentLoaded', function() { /** * Starts the download process via API */ - async function startDownload(url: string, type: string, item: DownloadQueueItem, albumType: string | null | undefined) { - if (!url || !type) { - showError('Missing URL or type for download'); + async function startDownload(itemId: string, type: string, item: DownloadQueueItem, albumType: string | null | undefined) { + if (!itemId || !type) { + showError('Missing ID or type for download'); return; } try { // Use the centralized downloadQueue.download method - await downloadQueue.download(url, type, item, albumType); + await downloadQueue.download(itemId, type, item, albumType); // Make the queue visible after queueing downloadQueue.toggleVisibility(true); diff --git a/src/js/playlist.ts b/src/js/playlist.ts index 2a80ca7..322a2d1 100644 --- a/src/js/playlist.ts +++ b/src/js/playlist.ts @@ -29,6 +29,7 @@ interface Track { duration_ms: number; explicit: boolean; external_urls?: { spotify?: string }; + is_locally_known?: boolean; // Added for local DB status } interface PlaylistItem { @@ -55,6 +56,11 @@ interface Playlist { external_urls?: { spotify?: string }; } +interface WatchedPlaylistStatus { + is_watched: boolean; + playlist_data?: Playlist; // Optional, present if watched +} + interface DownloadQueueItem { name: string; artist?: string; // Can be a simple string for the queue @@ -85,6 +91,9 @@ document.addEventListener('DOMContentLoaded', () => { showError('Failed to load playlist.'); }); + // Fetch initial watch status + fetchWatchStatus(playlistId); + const queueIcon = document.getElementById('queueIcon'); if (queueIcon) { queueIcon.addEventListener('click', () => { @@ -206,7 +215,7 @@ function renderPlaylist(playlist: Playlist) { downloadPlaylistBtn.textContent = 'Queued!'; }).catch((err: any) => { showError('Failed to queue playlist download: ' + (err?.message || 'Unknown error')); - downloadPlaylistBtn.disabled = false; + if (downloadPlaylistBtn) downloadPlaylistBtn.disabled = false; // Re-enable on error }); }); } @@ -227,7 +236,7 @@ function renderPlaylist(playlist: Playlist) { }) .catch((err: any) => { showError('Failed to queue album downloads: ' + (err?.message || 'Unknown error')); - if (downloadAlbumsBtn) downloadAlbumsBtn.disabled = false; + if (downloadAlbumsBtn) downloadAlbumsBtn.disabled = false; // Re-enable on error }); }); } @@ -239,6 +248,10 @@ function renderPlaylist(playlist: Playlist) { tracksList.innerHTML = ''; // Clear any existing content + // Determine if the playlist is being watched to show/hide management buttons + const watchPlaylistButton = document.getElementById('watchPlaylistBtn') as HTMLButtonElement; + const isPlaylistWatched = watchPlaylistButton && watchPlaylistButton.classList.contains('watching'); + if (playlist.tracks?.items) { playlist.tracks.items.forEach((item: PlaylistItem, index: number) => { if (!item || !item.track) return; // Skip null/undefined tracks @@ -263,14 +276,13 @@ function renderPlaylist(playlist: Playlist) { return; } - // Create links for track, artist, and album using their IDs. const trackLink = `/track/${track.id || ''}`; const artistLink = `/artist/${track.artists?.[0]?.id || ''}`; const albumLink = `/album/${track.album?.id || ''}`; const trackElement = document.createElement('div'); trackElement.className = 'track'; - trackElement.innerHTML = ` + let trackHTML = `
${index + 1}
@@ -284,14 +296,45 @@ function renderPlaylist(playlist: Playlist) { ${track.album?.name || 'Unknown Album'}
${msToTime(track.duration_ms || 0)}
- `; + + const actionsContainer = document.createElement('div'); + actionsContainer.className = 'track-actions-container'; + + if (!(isExplicitFilterEnabled && hasExplicitTrack)) { + const downloadBtnHTML = ` + + `; + actionsContainer.innerHTML += downloadBtnHTML; + } + + if (isPlaylistWatched) { + // Initial state is set based on track.is_locally_known + const isKnown = track.is_locally_known === true; // Ensure boolean check, default to false if undefined + const initialStatus = isKnown ? "known" : "missing"; + const initialIcon = isKnown ? "/static/images/check.svg" : "/static/images/missing.svg"; + const initialTitle = isKnown ? "Click to mark as missing from DB" : "Click to mark as known in DB"; + + const toggleKnownBtnHTML = ` + + `; + actionsContainer.innerHTML += toggleKnownBtnHTML; + } + + trackElement.innerHTML = trackHTML; + trackElement.appendChild(actionsContainer); tracksList.appendChild(trackElement); }); } @@ -303,7 +346,7 @@ function renderPlaylist(playlist: Playlist) { if (tracksContainerEl) tracksContainerEl.classList.remove('hidden'); // Attach download listeners to newly rendered download buttons - attachDownloadListeners(); + attachTrackActionListeners(); } /** @@ -329,26 +372,101 @@ function showError(message: string) { } /** - * Attaches event listeners to all individual download buttons. + * Attaches event listeners to all individual track action buttons (download, mark known, mark missing). */ -function attachDownloadListeners() { - document.querySelectorAll('.download-btn').forEach((btn) => { - // Skip the whole playlist and album download buttons. - if (btn.id === 'downloadPlaylistBtn' || btn.id === 'downloadAlbumsBtn') return; +function attachTrackActionListeners() { + document.querySelectorAll('.track-download-btn').forEach((btn) => { btn.addEventListener('click', (e: Event) => { e.stopPropagation(); 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. + const itemId = currentTarget.dataset.id || ''; + const type = currentTarget.dataset.type || 'track'; + const name = currentTarget.dataset.name || 'Unknown'; + if (!itemId) { + showError('Missing item ID for download on playlist page'); + return; + } currentTarget.remove(); - // For individual track downloads, we might not have album/artist name readily here. - // The queue.ts download method should be robust enough or we might need to fetch more data. - // For now, pass what we have. - startDownload(url, type, { name }, ''); // Pass name, artist/album are optional in DownloadQueueItem + startDownload(itemId, type, { name }, ''); }); }); + + document.querySelectorAll('.toggle-known-status-btn').forEach((btn) => { + btn.addEventListener('click', async (e: Event) => { + e.stopPropagation(); + const button = e.currentTarget as HTMLButtonElement; + const trackId = button.dataset.id || ''; + const playlistId = button.dataset.playlistId || ''; + const currentStatus = button.dataset.status; + const img = button.querySelector('img'); + + if (!trackId || !playlistId || !img) { + showError('Missing data for toggling track status'); + return; + } + + button.disabled = true; + try { + if (currentStatus === 'missing') { + await handleMarkTrackAsKnown(playlistId, trackId); + button.dataset.status = 'known'; + img.src = '/static/images/check.svg'; + button.title = 'Click to mark as missing from DB'; + } else { + await handleMarkTrackAsMissing(playlistId, trackId); + button.dataset.status = 'missing'; + img.src = '/static/images/missing.svg'; + button.title = 'Click to mark as known in DB'; + } + } catch (error) { + // Revert UI on error if needed, error is shown by handlers + showError('Failed to update track status. Please try again.'); + } + button.disabled = false; + }); + }); +} + +async function handleMarkTrackAsKnown(playlistId: string, trackId: string) { + try { + const response = await fetch(`/api/playlist/watch/${playlistId}/tracks`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify([trackId]), + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + const result = await response.json(); + showNotification(result.message || 'Track marked as known.'); + } catch (error: any) { + showError(`Failed to mark track as known: ${error.message}`); + throw error; // Re-throw for the caller to handle button state if needed + } +} + +async function handleMarkTrackAsMissing(playlistId: string, trackId: string) { + try { + const response = await fetch(`/api/playlist/watch/${playlistId}/tracks`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify([trackId]), + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + const result = await response.json(); + showNotification(result.message || 'Track marked as missing.'); + } catch (error: any) { + showError(`Failed to mark track as missing: ${error.message}`); + throw error; // Re-throw + } } /** @@ -359,14 +477,14 @@ async function downloadWholePlaylist(playlist: Playlist) { throw new Error('Invalid playlist data'); } - const url = playlist.external_urls?.spotify || ''; - if (!url) { - throw new Error('Missing playlist URL'); + const playlistId = playlist.id || ''; + if (!playlistId) { + throw new Error('Missing playlist ID'); } try { // Use the centralized downloadQueue.download method - await downloadQueue.download(url, 'playlist', { + await downloadQueue.download(playlistId, 'playlist', { name: playlist.name || 'Unknown Playlist', owner: playlist.owner?.display_name // Pass owner as a string // total_tracks can also be passed if QueueItem supports it directly @@ -426,7 +544,7 @@ async function downloadPlaylistAlbums(playlist: Playlist) { // Use the centralized downloadQueue.download method await downloadQueue.download( - albumUrl, + album.id, // Pass album ID directly 'album', { name: album.name || 'Unknown Album', @@ -460,15 +578,15 @@ async function downloadPlaylistAlbums(playlist: Playlist) { /** * Starts the download process using the centralized download method from the queue. */ -async function startDownload(url: string, type: string, item: DownloadQueueItem, albumType?: string) { - if (!url || !type) { - showError('Missing URL or type for download'); +async function startDownload(itemId: string, type: string, item: DownloadQueueItem, albumType?: string) { + if (!itemId || !type) { + showError('Missing ID or type for download'); return; } try { // Use the centralized downloadQueue.download method - await downloadQueue.download(url, type, item, albumType); + await downloadQueue.download(itemId, type, item, albumType); // Make the queue visible after queueing downloadQueue.toggleVisibility(true); @@ -484,3 +602,136 @@ async function startDownload(url: string, type: string, item: DownloadQueueItem, function extractName(url: string | null): string { return url || 'Unknown'; } + +/** + * Fetches the watch status of the current playlist and updates the UI. + */ +async function fetchWatchStatus(playlistId: string) { + if (!playlistId) return; + try { + const response = await fetch(`/api/playlist/watch/${playlistId}/status`); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to fetch watch status'); + } + const data: WatchedPlaylistStatus = await response.json(); + updateWatchButtons(data.is_watched, playlistId); + } catch (error) { + console.error('Error fetching watch status:', error); + // Don't show a blocking error, but maybe a small notification or log + // For now, assume not watched if status fetch fails, or keep buttons in default state + updateWatchButtons(false, playlistId); + } +} + +/** + * Updates the Watch/Unwatch and Sync buttons based on the playlist's watch status. + */ +function updateWatchButtons(isWatched: boolean, playlistId: string) { + const watchBtn = document.getElementById('watchPlaylistBtn') as HTMLButtonElement; + const syncBtn = document.getElementById('syncPlaylistBtn') as HTMLButtonElement; + + if (!watchBtn || !syncBtn) return; + + const watchBtnImg = watchBtn.querySelector('img'); + + if (isWatched) { + watchBtn.innerHTML = `Unwatch Unwatch Playlist`; + watchBtn.classList.add('watching'); + watchBtn.onclick = () => unwatchPlaylist(playlistId); + syncBtn.classList.remove('hidden'); + syncBtn.onclick = () => syncPlaylist(playlistId); + } else { + watchBtn.innerHTML = `Watch Watch Playlist`; + watchBtn.classList.remove('watching'); + watchBtn.onclick = () => watchPlaylist(playlistId); + syncBtn.classList.add('hidden'); + } + watchBtn.disabled = false; // Enable after status is known +} + +/** + * Adds the current playlist to the watchlist. + */ +async function watchPlaylist(playlistId: string) { + const watchBtn = document.getElementById('watchPlaylistBtn') as HTMLButtonElement; + if (watchBtn) watchBtn.disabled = true; + + try { + const response = await fetch(`/api/playlist/watch/${playlistId}`, { method: 'PUT' }); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to watch playlist'); + } + updateWatchButtons(true, playlistId); + showNotification(`Playlist added to watchlist. It will be synced shortly.`); + } catch (error: any) { + showError(`Error watching playlist: ${error.message}`); + if (watchBtn) watchBtn.disabled = false; + } +} + +/** + * Removes the current playlist from the watchlist. + */ +async function unwatchPlaylist(playlistId: string) { + const watchBtn = document.getElementById('watchPlaylistBtn') as HTMLButtonElement; + if (watchBtn) watchBtn.disabled = true; + + try { + const response = await fetch(`/api/playlist/watch/${playlistId}`, { method: 'DELETE' }); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to unwatch playlist'); + } + updateWatchButtons(false, playlistId); + showNotification('Playlist removed from watchlist.'); + } catch (error: any) { + showError(`Error unwatching playlist: ${error.message}`); + if (watchBtn) watchBtn.disabled = false; + } +} + +/** + * Triggers a manual sync for the watched playlist. + */ +async function syncPlaylist(playlistId: string) { + const syncBtn = document.getElementById('syncPlaylistBtn') as HTMLButtonElement; + let originalButtonContent = ''; // Define outside + + if (syncBtn) { + syncBtn.disabled = true; + originalButtonContent = syncBtn.innerHTML; // Store full HTML + syncBtn.innerHTML = `Sync Syncing...`; // Keep icon + } + + try { + const response = await fetch(`/api/playlist/watch/trigger_check/${playlistId}`, { method: 'POST' }); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to trigger sync'); + } + showNotification('Playlist sync triggered successfully.'); + } catch (error: any) { + showError(`Error triggering sync: ${error.message}`); + } finally { + if (syncBtn) { + syncBtn.disabled = false; + syncBtn.innerHTML = originalButtonContent; // Restore full original HTML + } + } +} + +/** + * Displays a temporary notification message. + */ +function showNotification(message: string) { + // Basic notification - consider a more robust solution for production + const notificationEl = document.createElement('div'); + notificationEl.className = 'notification'; + notificationEl.textContent = message; + document.body.appendChild(notificationEl); + setTimeout(() => { + notificationEl.remove(); + }, 3000); +} diff --git a/src/js/queue.ts b/src/js/queue.ts index 828124e..5fcb546 100644 --- a/src/js/queue.ts +++ b/src/js/queue.ts @@ -368,6 +368,11 @@ export class DownloadQueue { this.dispatchEvent('queueVisibilityChanged', { visible: !isVisible }); this.showError('Failed to save queue visibility'); } + + if (isVisible) { + // If the queue is now visible, ensure all visible items are being polled. + this.startMonitoringActiveEntries(); + } } showError(message: string) { @@ -913,6 +918,15 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) // We no longer start or stop monitoring based on visibility changes here // This allows the explicit monitoring control from the download methods + // Ensure all currently visible and active entries are being polled + // This is important for items that become visible after "Show More" or other UI changes + Object.values(this.queueEntries).forEach(entry => { + if (this.isEntryVisible(entry.uniqueId) && !entry.hasEnded && !this.pollingIntervals[entry.uniqueId]) { + console.log(`updateQueueOrder: Ensuring polling for visible/active entry ${entry.uniqueId} (${entry.prgFile})`); + this.setupPollingInterval(entry.uniqueId); + } + }); + // Update footer footer.innerHTML = ''; if (entries.length > this.visibleCount) { @@ -1469,21 +1483,34 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) * This method replaces the individual startTrackDownload, startAlbumDownload, etc. methods. * It will be called by all the other JS files. */ - 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'); + async download(itemId: string, type: string, item: QueueItem, albumType: string | null = null): Promise { // Add types and return type + if (!itemId) { + throw new Error('Missing ID for download'); } await this.loadConfig(); + + // Construct the API URL in the new format /api/{type}/download/{itemId} + let apiUrl = `/api/${type}/download/${itemId}`; - // Build the API URL with only the URL parameter as it's all that's needed - let apiUrl = `/api/${type}/download?url=${encodeURIComponent(url)}`; + // Prepare query parameters + const queryParams = new URLSearchParams(); + // Add item.name and item.artist only if they are not empty or undefined + if (item.name && item.name.trim() !== '') queryParams.append('name', item.name); + if (item.artist && item.artist.trim() !== '') queryParams.append('artist', item.artist); // For artist downloads, include album_type as it may still be needed if (type === 'artist' && albumType) { - apiUrl += `&album_type=${encodeURIComponent(albumType)}`; + queryParams.append('album_type', albumType); + } + + const queryString = queryParams.toString(); + if (queryString) { + apiUrl += `?${queryString}`; } + console.log(`Constructed API URL for download: ${apiUrl}`); // Log the constructed URL + try { // Show a loading indicator const queueIcon = document.getElementById('queueIcon'); // No direct classList manipulation diff --git a/src/js/track.ts b/src/js/track.ts index 044b3dc..2a5c9fa 100644 --- a/src/js/track.ts +++ b/src/js/track.ts @@ -150,9 +150,16 @@ function renderTrack(track: any) { downloadBtn.innerHTML = `Download`; return; } + const trackIdToDownload = track.id || ''; + if (!trackIdToDownload) { + showError('Missing track ID for download'); + downloadBtn.disabled = false; + downloadBtn.innerHTML = `Download`; + return; + } // Use the centralized downloadQueue.download method - downloadQueue.download(trackUrl, 'track', { name: track.name || 'Unknown Track', artist: track.artists?.[0]?.name }) + downloadQueue.download(trackIdToDownload, 'track', { name: track.name || 'Unknown Track', artist: track.artists?.[0]?.name }) .then(() => { downloadBtn.innerHTML = `Queued!`; // Make the queue visible to show the download @@ -196,15 +203,15 @@ function showError(message: string) { /** * Starts the download process by calling the centralized downloadQueue method */ -async function startDownload(url: string, type: string, item: any) { - if (!url || !type) { - showError('Missing URL or type for download'); +async function startDownload(itemId: string, type: string, item: any) { + if (!itemId || !type) { + showError('Missing ID or type for download'); return; } try { // Use the centralized downloadQueue.download method - await downloadQueue.download(url, type, item); + await downloadQueue.download(itemId, type, item); // Make the queue visible after queueing downloadQueue.toggleVisibility(true); diff --git a/static/css/artist/artist.css b/static/css/artist/artist.css index 97887e7..3c829d9 100644 --- a/static/css/artist/artist.css +++ b/static/css/artist/artist.css @@ -325,6 +325,53 @@ body { transform: scale(0.98); } +/* Watch Artist Button Styling */ +.watch-btn { + background-color: transparent; + color: #ffffff; + border: 1px solid #ffffff; + border-radius: 4px; + padding: 0.5rem 1rem; + font-size: 0.95rem; + cursor: pointer; + transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, transform 0.2s ease; + display: inline-flex; + align-items: center; + justify-content: center; + margin: 0.5rem; +} + +.watch-btn:hover { + background-color: #ffffff; + color: #121212; + border-color: #ffffff; +} + +.watch-btn.watching { + background-color: #1db954; /* Spotify green for "watching" state */ + color: #ffffff; + border-color: #1db954; +} + +.watch-btn.watching:hover { + background-color: #17a44b; /* Darker green on hover */ + border-color: #17a44b; + color: #ffffff; +} + +.watch-btn:active { + transform: scale(0.98); +} + +/* Styling for icons within watch and sync buttons */ +.watch-btn img, +.sync-btn img { + width: 16px; /* Adjust size as needed */ + height: 16px; /* Adjust size as needed */ + margin-right: 8px; /* Space between icon and text */ + filter: brightness(0) invert(1); /* Make icons white */ +} + /* Responsive Styles */ /* Medium Devices (Tablets) */ @@ -434,3 +481,52 @@ a:focus { color: #1db954; text-decoration: underline; } + +/* Toggle Known Status Button for Tracks/Albums */ +.toggle-known-status-btn { + width: 32px; + height: 32px; + padding: 0; + border-radius: 50%; + border: none; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background-color 0.2s ease, transform 0.2s ease; + margin-left: 0.5rem; /* Spacing from other buttons if any */ +} + +.toggle-known-status-btn img { + width: 18px; /* Adjust icon size as needed */ + height: 18px; + filter: brightness(0) invert(1); /* White icon */ +} + +.toggle-known-status-btn[data-status="known"] { + background-color: #28a745; /* Green for known/available */ +} + +.toggle-known-status-btn[data-status="known"]:hover { + background-color: #218838; /* Darker green on hover */ +} + +.toggle-known-status-btn[data-status="missing"] { + background-color: #dc3545; /* Red for missing */ +} + +.toggle-known-status-btn[data-status="missing"]:hover { + background-color: #c82333; /* Darker red on hover */ +} + +.toggle-known-status-btn:active { + transform: scale(0.95); +} + +.album-actions-container { + display: flex; + align-items: center; + /* If you want buttons at the bottom of the card or specific positioning, adjust here */ + /* For now, they will flow naturally. Adding padding if needed. */ + padding-top: 0.5rem; +} diff --git a/static/css/config/config.css b/static/css/config/config.css index 81b1599..1f9d01f 100644 --- a/static/css/config/config.css +++ b/static/css/config/config.css @@ -800,7 +800,7 @@ input:checked + .slider:before { width: 28px; height: 28px; } - +} /* Format help styles */ .format-help { @@ -935,4 +935,54 @@ input:checked + .slider:before { background-color: #e74c3c !important; /* Lighter red on hover */ transform: translateY(-2px) scale(1.05); } + +/* Watch Options Config Section */ +.watch-options-config { + background: #181818; + padding: 1.5rem; + border-radius: 12px; + margin-bottom: 2rem; + box-shadow: 0 4px 8px rgba(0,0,0,0.15); + transition: transform 0.3s ease; +} + +.watch-options-config:hover { + transform: translateY(-2px); +} + +/* New Checklist Styles */ +.checklist-container { + background: #2a2a2a; + border: 1px solid #404040; + border-radius: 8px; + padding: 0.8rem; + margin-top: 0.5rem; +} + +.checklist-item { + display: flex; + align-items: center; + margin-bottom: 0.5rem; + padding: 0.3rem 0; +} + +.checklist-item:last-child { + margin-bottom: 0; +} + +.checklist-item input[type="checkbox"] { + margin-right: 0.8rem; + width: 18px; /* Custom size */ + height: 18px; /* Custom size */ + cursor: pointer; + accent-color: #1db954; /* Modern way to color checkboxes */ +} + +.checklist-item label { + color: #ffffff; /* Ensure label text is white */ + font-size: 0.95rem; + cursor: pointer; + /* Reset some global label styles if they interfere */ + display: inline; + margin-bottom: 0; } \ No newline at end of file diff --git a/static/css/playlist/playlist.css b/static/css/playlist/playlist.css index c684386..3b04807 100644 --- a/static/css/playlist/playlist.css +++ b/static/css/playlist/playlist.css @@ -185,6 +185,14 @@ body { margin: 0.5rem; } +/* Style for icons within download buttons */ +.download-btn img { + margin-right: 0.5rem; /* Space between icon and text */ + width: 20px; /* Icon width */ + height: 20px; /* Icon height */ + vertical-align: middle; /* Align icon with text */ +} + .download-btn:hover { background-color: #17a44b; } @@ -459,3 +467,95 @@ a:focus { margin-bottom: 1rem; } } + +/* Notification Styling */ +.notification { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background-color: #333; + color: #fff; + padding: 10px 20px; + border-radius: 5px; + box-shadow: 0 2px 10px rgba(0,0,0,0.2); + z-index: 1005; /* Ensure it's above most other elements */ + opacity: 0; + transition: opacity 0.5s ease-in-out; + animation: fadeInOut 3s ease-in-out; +} + +@keyframes fadeInOut { + 0% { opacity: 0; } + 10% { opacity: 1; } + 90% { opacity: 1; } + 100% { opacity: 0; } +} + +/* Watch and Sync Button Specific Styles */ +.watch-btn { + background-color: #535353; /* A neutral dark gray */ +} + +.watch-btn:hover { + background-color: #6f6f6f; +} + +.sync-btn { + background-color: #28a745; /* A distinct green for sync */ +} + +.sync-btn:hover { + background-color: #218838; +} + +.sync-btn.hidden { + display: none; +} + +/* Toggle Known Status Button for Tracks/Albums */ +.toggle-known-status-btn { + width: 32px; + height: 32px; + padding: 0; + border-radius: 50%; + border: none; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background-color 0.2s ease, transform 0.2s ease; + margin-left: 0.5rem; /* Spacing from other buttons if any */ +} + +.toggle-known-status-btn img { + width: 18px; /* Adjust icon size as needed */ + height: 18px; + filter: brightness(0) invert(1); /* White icon */ +} + +.toggle-known-status-btn[data-status="known"] { + background-color: #28a745; /* Green for known/available */ +} + +.toggle-known-status-btn[data-status="known"]:hover { + background-color: #218838; /* Darker green on hover */ +} + +.toggle-known-status-btn[data-status="missing"] { + background-color: #dc3545; /* Red for missing */ +} + +.toggle-known-status-btn[data-status="missing"]:hover { + background-color: #c82333; /* Darker red on hover */ +} + +.toggle-known-status-btn:active { + transform: scale(0.95); +} + +.track-actions-container { + display: flex; + align-items: center; + margin-left: auto; /* Pushes action buttons to the right */ +} diff --git a/static/html/artist.html b/static/html/artist.html index 969edc8..c0d0514 100644 --- a/static/html/artist.html +++ b/static/html/artist.html @@ -28,6 +28,11 @@ Download Download All Discography + +
diff --git a/static/html/config.html b/static/html/config.html index 42d65db..46c9801 100644 --- a/static/html/config.html +++ b/static/html/config.html @@ -226,6 +226,41 @@ +
+

Watch Options

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ Select which album groups to monitor on watched artist pages. +
+
+
+ + +
+ How often to check watched items for updates (e.g., new playlist tracks, new artist albums). +
+
+
+
diff --git a/static/html/playlist.html b/static/html/playlist.html index d705ff7..7499aa8 100644 --- a/static/html/playlist.html +++ b/static/html/playlist.html @@ -33,6 +33,14 @@ Albums Download Playlist's Albums + +
diff --git a/static/images/check.svg b/static/images/check.svg new file mode 100644 index 0000000..01ea137 --- /dev/null +++ b/static/images/check.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/static/images/eye-crossed.svg b/static/images/eye-crossed.svg new file mode 100644 index 0000000..ae11bb6 --- /dev/null +++ b/static/images/eye-crossed.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/static/images/eye.svg b/static/images/eye.svg new file mode 100644 index 0000000..fe5537f --- /dev/null +++ b/static/images/eye.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/static/images/missing.svg b/static/images/missing.svg new file mode 100644 index 0000000..4eeb2b8 --- /dev/null +++ b/static/images/missing.svg @@ -0,0 +1,12 @@ + + + + ic_fluent_missing_metadata_24_filled + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/static/images/refresh.svg b/static/images/refresh.svg new file mode 100644 index 0000000..a66e48e --- /dev/null +++ b/static/images/refresh.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file From 03f132a9f34854e0911789f52c70947d54d7e994 Mon Sep 17 00:00:00 2001 From: "cool.gitter.not.me.again.duh" Date: Thu, 29 May 2025 12:00:57 -0600 Subject: [PATCH 08/18] 2.0.0 --- app.py | 7 +- requirements.txt | 63 ++-- routes/config.py | 3 + routes/utils/watch/manager.py | 7 + src/js/config.ts | 81 ++++- src/js/watch.ts | 644 ++++++++++++++++++++++++++++++++++ static/css/config/config.css | 38 ++ static/css/main/base.css | 39 ++ static/css/watch/watch.css | 350 ++++++++++++++++++ static/html/album.html | 4 + static/html/artist.html | 4 + static/html/config.html | 19 + static/html/main.html | 4 + static/html/playlist.html | 4 + static/html/track.html | 4 + static/html/watch.html | 68 ++++ static/images/binoculars.svg | 12 + static/images/refresh-cw.svg | 4 + 18 files changed, 1318 insertions(+), 37 deletions(-) create mode 100644 src/js/watch.ts create mode 100644 static/css/watch/watch.css create mode 100644 static/html/watch.html create mode 100644 static/images/binoculars.svg create mode 100644 static/images/refresh-cw.svg diff --git a/app.py b/app.py index 133ad18..3bf61aa 100755 --- a/app.py +++ b/app.py @@ -160,12 +160,17 @@ def create_app(): def serve_config(): return render_template('config.html') + # New route: Serve watch.html under /watchlist + @app.route('/watchlist') + def serve_watchlist(): + return render_template('watch.html') + # New route: Serve playlist.html under /playlist/ @app.route('/playlist/') def serve_playlist(id): # The id parameter is captured, but you can use it as needed. return render_template('playlist.html') - # New route: Serve playlist.html under /playlist/ + @app.route('/album/') def serve_album(id): # The id parameter is captured, but you can use it as needed. diff --git a/requirements.txt b/requirements.txt index 8b596f7..5cae1d0 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,47 +1,60 @@ +amqp==5.3.1 annotated-types==0.7.0 -anyio==4.8.0 +anyio==4.9.0 +billiard==4.2.1 blinker==1.9.0 -certifi==2024.12.14 -charset-normalizer==3.4.1 -click==8.1.8 -deezspot @ git+https://github.com/Xoconoch/deezspot-fork-again +celery==5.5.2 +certifi==2025.4.26 +charset-normalizer==3.4.2 +click==8.2.1 +click-didyoumean==0.3.1 +click-plugins==1.1.1 +click-repl==0.3.0 +deezspot @ git+https://github.com/Xoconoch/deezspot-fork-again@0b906e94e774cdeb8557a14a53236c0834fb1a2e defusedxml==0.7.1 -fastapi==0.115.7 -Flask==3.1.0 -Flask-Cors==5.0.0 -h11==0.14.0 +fastapi==0.115.12 +Flask==3.1.1 +Flask-Celery-Helper==1.1.0 +flask-cors==6.0.0 +h11==0.16.0 httptools==0.6.4 idna==3.10 ifaddr==0.2.0 itsdangerous==2.2.0 -Jinja2==3.1.5 +Jinja2==3.1.6 +kombu==5.5.3 librespot==0.0.9 MarkupSafe==3.0.2 mutagen==1.47.0 +prompt_toolkit==3.0.51 protobuf==3.20.1 -pycryptodome==3.21.0 +pycryptodome==3.23.0 pycryptodomex==3.17 -pydantic==2.10.6 -pydantic_core==2.27.2 +pydantic==2.11.5 +pydantic_core==2.33.2 PyOgg==0.6.14a1 -python-dotenv==1.0.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.1.0 PyYAML==6.0.2 -redis==5.2.1 +redis==6.2.0 requests==2.30.0 +six==1.17.0 sniffio==1.3.1 -spotipy -spotipy_anon -starlette==0.45.3 +spotipy==2.25.1 +spotipy_anon==1.4 +starlette==0.46.2 tqdm==4.67.1 -typing_extensions==4.12.2 -urllib3==2.3.0 -uvicorn==0.34.0 +typing-inspection==0.4.1 +typing_extensions==4.13.2 +tzdata==2025.2 +urllib3==2.4.0 +uvicorn==0.34.2 uvloop==0.21.0 +vine==5.1.0 waitress==3.0.2 -watchfiles==1.0.4 +watchfiles==1.0.5 +wcwidth==0.2.13 websocket-client==1.5.1 -websockets==14.2 +websockets==15.0.1 Werkzeug==3.1.3 zeroconf==0.62.0 -celery==5.3.6 -flask-celery-helper==1.1.0 \ No newline at end of file diff --git a/routes/config.py b/routes/config.py index 71a47f5..d363550 100644 --- a/routes/config.py +++ b/routes/config.py @@ -71,6 +71,7 @@ def get_watch_config(): CONFIG_PATH_WATCH.parent.mkdir(parents=True, exist_ok=True) # Default watch config defaults = { + 'enabled': False, 'watchedArtistAlbumGroup': ["album", "single"], 'watchPollIntervalSeconds': 3600 } @@ -82,6 +83,7 @@ def get_watch_config(): logging.error(f"Error reading watch config: {str(e)}") # Return defaults on error to prevent crashes return { + 'enabled': False, 'watchedArtistAlbumGroup': ["album", "single"], 'watchPollIntervalSeconds': 3600 } @@ -189,6 +191,7 @@ def handle_watch_config(): watch_config = get_watch_config() # Ensure defaults are applied if file was corrupted or missing fields defaults = { + 'enabled': False, 'watchedArtistAlbumGroup': ["album", "single"], 'watchPollIntervalSeconds': 3600 } diff --git a/routes/utils/watch/manager.py b/routes/utils/watch/manager.py index 88947d5..5ed25e9 100644 --- a/routes/utils/watch/manager.py +++ b/routes/utils/watch/manager.py @@ -27,6 +27,7 @@ CONFIG_PATH = Path('./data/config/watch.json') STOP_EVENT = threading.Event() DEFAULT_WATCH_CONFIG = { + "enabled": False, "watchPollIntervalSeconds": 3600, "max_tracks_per_run": 50, # For playlists "watchedArtistAlbumGroup": ["album", "single"], # Default for artists @@ -356,6 +357,12 @@ def playlist_watch_scheduler(): while not STOP_EVENT.is_set(): current_config = get_watch_config() # Get latest config for this run interval = current_config.get("watchPollIntervalSeconds", 3600) + watch_enabled = current_config.get("enabled", False) # Get enabled status + + if not watch_enabled: + logger.info("Watch Scheduler: Watch feature is disabled in config. Skipping checks.") + STOP_EVENT.wait(interval) # Still respect poll interval for checking config again + continue # Skip to next iteration try: logger.info("Watch Scheduler: Starting playlist check run.") diff --git a/src/js/config.ts b/src/js/config.ts index 09aaf1f..4e73a70 100644 --- a/src/js/config.ts +++ b/src/js/config.ts @@ -239,32 +239,52 @@ function setupEventListeners() { checkbox.addEventListener('change', saveWatchConfig); }); (document.getElementById('watchPollIntervalSeconds') as HTMLInputElement | null)?.addEventListener('change', saveWatchConfig); + (document.getElementById('watchEnabledToggle') as HTMLInputElement | null)?.addEventListener('change', () => { + const isEnabling = (document.getElementById('watchEnabledToggle') as HTMLInputElement)?.checked; + const alreadyShownFirstEnableNotice = localStorage.getItem('watchFeatureFirstEnableNoticeShown'); + + if (isEnabling && !alreadyShownFirstEnableNotice) { + const noticeDiv = document.getElementById('watchFeatureFirstEnableNotice'); + if (noticeDiv) noticeDiv.style.display = 'block'; + localStorage.setItem('watchFeatureFirstEnableNoticeShown', 'true'); + // Hide notice after a delay or on click if preferred + setTimeout(() => { + if (noticeDiv) noticeDiv.style.display = 'none'; + }, 15000); // Hide after 15 seconds + } else { + // If disabling, or if notice was already shown, ensure it's hidden + const noticeDiv = document.getElementById('watchFeatureFirstEnableNotice'); + if (noticeDiv) noticeDiv.style.display = 'none'; + } + saveWatchConfig(); + updateWatchWarningDisplay(); // Call this also when the watch enable toggle changes + }); + (document.getElementById('realTimeToggle') as HTMLInputElement | null)?.addEventListener('change', () => { + saveConfig(); + updateWatchWarningDisplay(); // Call this when realTimeToggle changes + }); } 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) + } + // Handle Deezer specific options 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'); @@ -738,10 +758,14 @@ async function handleCredentialSubmit(e: Event) { await updateAccountSelectors(); await saveConfig(); loadCredentials(service!); - setFormVisibility(false); // Hide form and show add button on successful submission // Show success message showConfigSuccess(isEditingSearch ? 'API credentials saved successfully' : 'Account saved successfully'); + + // Add a delay before hiding the form + setTimeout(() => { + setFormVisibility(false); // Hide form and show add button on successful submission + }, 2000); // 2 second delay } catch (error: any) { showConfigError(error.message); } @@ -949,7 +973,17 @@ async function loadWatchConfig() { } const watchPollIntervalSecondsInput = document.getElementById('watchPollIntervalSeconds') as HTMLInputElement | null; - if (watchPollIntervalSecondsInput) watchPollIntervalSecondsInput.value = watchConfig.watchPollIntervalSeconds || '3600'; + if (watchPollIntervalSecondsInput) { + watchPollIntervalSecondsInput.value = watchConfig.watchPollIntervalSeconds || '3600'; + } + + const watchEnabledToggle = document.getElementById('watchEnabledToggle') as HTMLInputElement | null; + if (watchEnabledToggle) { + watchEnabledToggle.checked = !!watchConfig.enabled; + } + + // Call this after the state of the toggles has been set based on watchConfig + updateWatchWarningDisplay(); } catch (error: any) { showConfigError('Error loading watch config: ' + error.message); @@ -965,6 +999,7 @@ async function saveWatchConfig() { } const watchConfig = { + enabled: (document.getElementById('watchEnabledToggle') as HTMLInputElement | null)?.checked, watchedArtistAlbumGroup: selectedGroups, watchPollIntervalSeconds: parseInt((document.getElementById('watchPollIntervalSeconds') as HTMLInputElement | null)?.value || '3600', 10) || 3600, }; @@ -985,3 +1020,27 @@ async function saveWatchConfig() { showConfigError('Error saving watch config: ' + error.message); } } + +// New function to manage the warning display +function updateWatchWarningDisplay() { + const watchEnabledToggle = document.getElementById('watchEnabledToggle') as HTMLInputElement | null; + const realTimeToggle = document.getElementById('realTimeToggle') as HTMLInputElement | null; + const warningDiv = document.getElementById('watchEnabledWarning') as HTMLElement | null; + + if (watchEnabledToggle && realTimeToggle && warningDiv) { + const isWatchEnabled = watchEnabledToggle.checked; + const isRealTimeEnabled = realTimeToggle.checked; + + if (isWatchEnabled && !isRealTimeEnabled) { + warningDiv.style.display = 'block'; + } else { + warningDiv.style.display = 'none'; + } + } + // Hide the first-enable notice if watch is disabled or if it was already dismissed by timeout/interaction + // The primary logic for showing first-enable notice is in the event listener for watchEnabledToggle + const firstEnableNoticeDiv = document.getElementById('watchFeatureFirstEnableNotice'); + if (firstEnableNoticeDiv && watchEnabledToggle && !watchEnabledToggle.checked) { + firstEnableNoticeDiv.style.display = 'none'; + } +} diff --git a/src/js/watch.ts b/src/js/watch.ts new file mode 100644 index 0000000..b93d136 --- /dev/null +++ b/src/js/watch.ts @@ -0,0 +1,644 @@ +import { downloadQueue } from './queue.js'; // Assuming queue.js is in the same directory + +// Interfaces for API data +interface Image { + url: string; + height?: number; + width?: number; +} + +// --- Items from the initial /watch/list API calls --- +interface ArtistFromWatchList { + spotify_id: string; // Changed from id to spotify_id + name: string; + images?: Image[]; + total_albums?: number; // Already provided by /api/artist/watch/list +} + +// New interface for artists after initial processing (spotify_id mapped to id) +interface ProcessedArtistFromWatchList extends ArtistFromWatchList { + id: string; // This is the mapped spotify_id +} + +interface WatchedPlaylistOwner { // Kept as is, used by PlaylistFromWatchList + display_name?: string; + id?: string; +} + +interface PlaylistFromWatchList { + spotify_id: string; // Changed from id to spotify_id + name: string; + owner?: WatchedPlaylistOwner; + images?: Image[]; // Ensure images can be part of this initial fetch + total_tracks?: number; +} + +// New interface for playlists after initial processing (spotify_id mapped to id) +interface ProcessedPlaylistFromWatchList extends PlaylistFromWatchList { + id: string; // This is the mapped spotify_id +} +// --- End of /watch/list items --- + + +// --- Responses from /api/{artist|playlist}/info endpoints --- +interface AlbumWithImages { // For items in ArtistInfoResponse.items + images?: Image[]; + // Other album properties like name, id etc., are not strictly needed for this specific change +} + +interface ArtistInfoResponse { + artist_id: string; // Matches key from artist.py + artist_name: string; // Matches key from artist.py + artist_image_url?: string; // Matches key from artist.py + total: number; // This is total_albums, matches key from artist.py + artist_external_url?: string; // Matches key from artist.py + items?: AlbumWithImages[]; // Add album items to get the first album's image +} + +// PlaylistInfoResponse is effectively the Playlist interface from playlist.ts +// For clarity, defining it here based on what's needed for the card. +interface PlaylistInfoResponse { + id: string; + name: string; + description: string | null; + owner: { display_name?: string; id?: string; }; // Matches Playlist.owner + images: Image[]; // Matches Playlist.images + tracks: { total: number; /* items: PlaylistItem[] - not needed for card */ }; // Matches Playlist.tracks + followers?: { total: number; }; // Matches Playlist.followers + external_urls?: { spotify?: string }; // Matches Playlist.external_urls +} +// --- End of /info endpoint responses --- + + +// --- Final combined data structure for rendering cards --- +interface FinalArtistCardItem { + itemType: 'artist'; + id: string; // Spotify ID + name: string; // Best available name (from /info or fallback) + imageUrl?: string; // Best available image URL (from /info or fallback) + total_albums: number;// From /info or fallback + external_urls?: { spotify?: string }; // From /info +} + +interface FinalPlaylistCardItem { + itemType: 'playlist'; + id: string; // Spotify ID + name: string; // Best available name (from /info or fallback) + imageUrl?: string; // Best available image URL (from /info or fallback) + owner_name?: string; // From /info or fallback + total_tracks: number;// From /info or fallback + followers_count?: number; // From /info + description?: string | null; // From /info, for potential use (e.g., tooltip) + external_urls?: { spotify?: string }; // From /info +} + +type FinalCardItem = FinalArtistCardItem | FinalPlaylistCardItem; +// --- End of final card data structure --- + +// The type for items initially fetched from /watch/list, before detailed processing +// Updated to use ProcessedArtistFromWatchList for artists and ProcessedPlaylistFromWatchList for playlists +type InitialWatchedItem = + (ProcessedArtistFromWatchList & { itemType: 'artist' }) | + (ProcessedPlaylistFromWatchList & { itemType: 'playlist' }); + +// Interface for a settled promise (fulfilled) +interface CustomPromiseFulfilledResult { + status: 'fulfilled'; + value: T; +} + +// Interface for a settled promise (rejected) +interface CustomPromiseRejectedResult { + status: 'rejected'; + reason: any; +} + +type CustomSettledPromiseResult = CustomPromiseFulfilledResult | CustomPromiseRejectedResult; + +// Original WatchedItem type, which will be replaced by FinalCardItem for rendering +interface WatchedArtistOriginal { + id: string; + name: string; + images?: Image[]; + total_albums?: number; +} + +interface WatchedPlaylistOriginal { + id: string; + name: string; + owner?: WatchedPlaylistOwner; + images?: Image[]; + total_tracks?: number; +} + +type WatchedItem = (WatchedArtistOriginal & { itemType: 'artist' }) | (WatchedPlaylistOriginal & { itemType: 'playlist' }); + +document.addEventListener('DOMContentLoaded', function() { + const watchedItemsContainer = document.getElementById('watchedItemsContainer'); + const loadingIndicator = document.getElementById('loadingWatchedItems'); + const emptyStateIndicator = document.getElementById('emptyWatchedItems'); + const queueIcon = document.getElementById('queueIcon'); + const checkAllWatchedBtn = document.getElementById('checkAllWatchedBtn') as HTMLButtonElement | null; + + if (queueIcon) { + queueIcon.addEventListener('click', () => { + downloadQueue.toggleVisibility(); + }); + } + + if (checkAllWatchedBtn) { + checkAllWatchedBtn.addEventListener('click', async () => { + checkAllWatchedBtn.disabled = true; + const originalText = checkAllWatchedBtn.innerHTML; + checkAllWatchedBtn.innerHTML = 'Refreshing... Checking...'; + + try { + const artistCheckPromise = fetch('/api/artist/watch/trigger_check', { method: 'POST' }); + const playlistCheckPromise = fetch('/api/playlist/watch/trigger_check', { method: 'POST' }); + + // Use Promise.allSettled-like behavior to handle both responses + const results = await Promise.all([ + artistCheckPromise.then(async res => ({ + ok: res.ok, + data: await res.json().catch(() => ({ error: 'Invalid JSON response' })), + type: 'artist' + })).catch(e => ({ ok: false, data: { error: e.message || 'Request failed' }, type: 'artist' })), + playlistCheckPromise.then(async res => ({ + ok: res.ok, + data: await res.json().catch(() => ({ error: 'Invalid JSON response' })), + type: 'playlist' + })).catch(e => ({ ok: false, data: { error: e.message || 'Request failed' }, type: 'playlist' })) + ]); + + const artistResult = results.find(r => r.type === 'artist'); + const playlistResult = results.find(r => r.type === 'playlist'); + + let successMessages: string[] = []; + let errorMessages: string[] = []; + + if (artistResult) { + if (artistResult.ok) { + successMessages.push(artistResult.data.message || 'Artist check triggered.'); + } else { + errorMessages.push(`Artist check failed: ${artistResult.data.error || 'Unknown error'}`); + } + } + + if (playlistResult) { + if (playlistResult.ok) { + successMessages.push(playlistResult.data.message || 'Playlist check triggered.'); + } else { + errorMessages.push(`Playlist check failed: ${playlistResult.data.error || 'Unknown error'}`); + } + } + + if (errorMessages.length > 0) { + showNotification(errorMessages.join(' '), true); + if (successMessages.length > 0) { // If some succeeded and some failed + // Delay the success message slightly so it doesn't overlap or get missed + setTimeout(() => showNotification(successMessages.join(' ')), 1000); + } + } else if (successMessages.length > 0) { + showNotification(successMessages.join(' ')); + } else { + showNotification('Could not determine check status for artists or playlists.', true); + } + + } catch (error: any) { // Catch for unexpected issues with Promise.all or setup + console.error('Error in checkAllWatchedBtn handler:', error); + showNotification(`An unexpected error occurred: ${error.message}`, true); + } finally { + checkAllWatchedBtn.disabled = false; + checkAllWatchedBtn.innerHTML = originalText; + } + }); + } + + // Initial load + loadWatchedItems(); +}); + +const MAX_NOTIFICATIONS = 3; + +async function loadWatchedItems() { + const watchedItemsContainer = document.getElementById('watchedItemsContainer'); + const loadingIndicator = document.getElementById('loadingWatchedItems'); + const emptyStateIndicator = document.getElementById('emptyWatchedItems'); + + showLoading(true); + showEmptyState(false); + if (watchedItemsContainer) watchedItemsContainer.innerHTML = ''; + + try { + const [artistsResponse, playlistsResponse] = await Promise.all([ + fetch('/api/artist/watch/list'), + fetch('/api/playlist/watch/list') + ]); + + if (!artistsResponse.ok || !playlistsResponse.ok) { + throw new Error('Failed to load initial watched items list'); + } + + const artists: ArtistFromWatchList[] = await artistsResponse.json(); + const playlists: PlaylistFromWatchList[] = await playlistsResponse.json(); + + const initialItems: InitialWatchedItem[] = [ + ...artists.map(artist => ({ + ...artist, + id: artist.spotify_id, // Map spotify_id to id for artists + itemType: 'artist' as const + })), + ...playlists.map(playlist => ({ + ...playlist, + id: playlist.spotify_id, // Map spotify_id to id for playlists + itemType: 'playlist' as const + })) + ]; + + if (initialItems.length === 0) { + showLoading(false); + showEmptyState(true); + return; + } + + // Fetch detailed info for each item + const detailedItemPromises = initialItems.map(async (initialItem) => { + try { + if (initialItem.itemType === 'artist') { + const infoResponse = await fetch(`/api/artist/info?id=${initialItem.id}`); + if (!infoResponse.ok) { + console.warn(`Failed to fetch artist info for ${initialItem.name} (ID: ${initialItem.id}): ${infoResponse.status}`); + // Fallback to initial data if info fetch fails + return { + itemType: 'artist', + id: initialItem.id, + name: initialItem.name, + imageUrl: (initialItem as ArtistFromWatchList).images?.[0]?.url, // Cast to access images + total_albums: (initialItem as ArtistFromWatchList).total_albums || 0, // Cast to access total_albums + } as FinalArtistCardItem; + } + const info: ArtistInfoResponse = await infoResponse.json(); + return { + itemType: 'artist', + id: initialItem.id, // Use the ID from the watch list, as /info might have 'artist_id' + name: info.artist_name || initialItem.name, // Prefer info, fallback to initial + imageUrl: info.items?.[0]?.images?.[0]?.url || info.artist_image_url || (initialItem as ProcessedArtistFromWatchList).images?.[0]?.url, // Prioritize first album image from items + total_albums: info.total, // 'total' from ArtistInfoResponse is total_albums + external_urls: { spotify: info.artist_external_url } + } as FinalArtistCardItem; + } else { // Playlist + const infoResponse = await fetch(`/api/playlist/info?id=${initialItem.id}`); + if (!infoResponse.ok) { + console.warn(`Failed to fetch playlist info for ${initialItem.name} (ID: ${initialItem.id}): ${infoResponse.status}`); + // Fallback to initial data if info fetch fails + return { + itemType: 'playlist', + id: initialItem.id, + name: initialItem.name, + imageUrl: (initialItem as ProcessedPlaylistFromWatchList).images?.[0]?.url, // Cast to access images + owner_name: (initialItem as ProcessedPlaylistFromWatchList).owner?.display_name, // Cast to access owner + total_tracks: (initialItem as ProcessedPlaylistFromWatchList).total_tracks || 0, // Cast to access total_tracks + } as FinalPlaylistCardItem; + } + const info: PlaylistInfoResponse = await infoResponse.json(); + return { + itemType: 'playlist', + id: initialItem.id, // Use ID from watch list + name: info.name || initialItem.name, // Prefer info, fallback to initial + imageUrl: info.images?.[0]?.url || (initialItem as ProcessedPlaylistFromWatchList).images?.[0]?.url, // Prefer info, fallback to initial (ProcessedPlaylistFromWatchList) + owner_name: info.owner?.display_name || (initialItem as ProcessedPlaylistFromWatchList).owner?.display_name, // Prefer info, fallback to initial (ProcessedPlaylistFromWatchList) + total_tracks: info.tracks.total, // 'total' from PlaylistInfoResponse.tracks + followers_count: info.followers?.total, + description: info.description, + external_urls: info.external_urls + } as FinalPlaylistCardItem; + } + } catch (e: any) { + console.error(`Error processing item ${initialItem.name} (ID: ${initialItem.id}):`, e); + // Return a fallback structure if processing fails catastrophically + return { + itemType: initialItem.itemType, + id: initialItem.id, + name: initialItem.name + " (Error loading details)", + imageUrl: initialItem.images?.[0]?.url, + // Add minimal common fields for artists and playlists for fallback + ...(initialItem.itemType === 'artist' ? { total_albums: (initialItem as ProcessedArtistFromWatchList).total_albums || 0 } : {}), + ...(initialItem.itemType === 'playlist' ? { total_tracks: (initialItem as ProcessedPlaylistFromWatchList).total_tracks || 0 } : {}), + } as FinalCardItem; // Cast to avoid TS errors, knowing one of the spreads will match + } + }); + + // Simulating Promise.allSettled behavior for compatibility + const settledResults: CustomSettledPromiseResult[] = await Promise.all( + detailedItemPromises.map(p => + p.then(value => ({ status: 'fulfilled', value } as CustomPromiseFulfilledResult)) + .catch(reason => ({ status: 'rejected', reason } as CustomPromiseRejectedResult)) + ) + ); + + const finalItems: FinalCardItem[] = settledResults + .filter((result): result is CustomPromiseFulfilledResult => result.status === 'fulfilled') + .map(result => result.value) + .filter(item => item !== null) as FinalCardItem[]; // Ensure no nulls from catastrophic failures + + showLoading(false); + + if (finalItems.length === 0) { + showEmptyState(true); + // Potentially show a different message if initialItems existed but all failed to load details + if (initialItems.length > 0 && watchedItemsContainer) { + watchedItemsContainer.innerHTML = `

Could not load details for any watched items. Please check the console for errors.

`; + } + return; + } + + if (watchedItemsContainer) { + // Clear previous content + watchedItemsContainer.innerHTML = ''; + + if (finalItems.length > 8) { + const playlistItems = finalItems.filter(item => item.itemType === 'playlist') as FinalPlaylistCardItem[]; + const artistItems = finalItems.filter(item => item.itemType === 'artist') as FinalArtistCardItem[]; + + // Create and append Playlist section + if (playlistItems.length > 0) { + const playlistSection = document.createElement('div'); + playlistSection.className = 'watched-items-group'; + const playlistHeader = document.createElement('h2'); + playlistHeader.className = 'watched-group-header'; + playlistHeader.textContent = 'Watched Playlists'; + playlistSection.appendChild(playlistHeader); + const playlistGrid = document.createElement('div'); + playlistGrid.className = 'results-grid'; // Use existing grid style + playlistItems.forEach(item => { + const cardElement = createWatchedItemCard(item); + playlistGrid.appendChild(cardElement); + }); + playlistSection.appendChild(playlistGrid); + watchedItemsContainer.appendChild(playlistSection); + } else { + const noPlaylistsMessage = document.createElement('p'); + noPlaylistsMessage.textContent = 'No watched playlists.'; + noPlaylistsMessage.className = 'empty-group-message'; + // Optionally add a header for consistency even if empty + const playlistHeader = document.createElement('h2'); + playlistHeader.className = 'watched-group-header'; + playlistHeader.textContent = 'Watched Playlists'; + watchedItemsContainer.appendChild(playlistHeader); + watchedItemsContainer.appendChild(noPlaylistsMessage); + } + + // Create and append Artist section + if (artistItems.length > 0) { + const artistSection = document.createElement('div'); + artistSection.className = 'watched-items-group'; + const artistHeader = document.createElement('h2'); + artistHeader.className = 'watched-group-header'; + artistHeader.textContent = 'Watched Artists'; + artistSection.appendChild(artistHeader); + const artistGrid = document.createElement('div'); + artistGrid.className = 'results-grid'; // Use existing grid style + artistItems.forEach(item => { + const cardElement = createWatchedItemCard(item); + artistGrid.appendChild(cardElement); + }); + artistSection.appendChild(artistGrid); + watchedItemsContainer.appendChild(artistSection); + } else { + const noArtistsMessage = document.createElement('p'); + noArtistsMessage.textContent = 'No watched artists.'; + noArtistsMessage.className = 'empty-group-message'; + // Optionally add a header for consistency even if empty + const artistHeader = document.createElement('h2'); + artistHeader.className = 'watched-group-header'; + artistHeader.textContent = 'Watched Artists'; + watchedItemsContainer.appendChild(artistHeader); + watchedItemsContainer.appendChild(noArtistsMessage); + } + + } else { // 8 or fewer items, render them directly + finalItems.forEach(item => { + const cardElement = createWatchedItemCard(item); + watchedItemsContainer.appendChild(cardElement); + }); + } + } + + } catch (error: any) { + console.error('Error loading watched items:', error); + showLoading(false); + if (watchedItemsContainer) { + watchedItemsContainer.innerHTML = `

Error loading watched items: ${error.message}

`; + } + } +} + +function createWatchedItemCard(item: FinalCardItem): HTMLDivElement { + const cardElement = document.createElement('div'); + cardElement.className = 'watched-item-card'; + cardElement.dataset.itemId = item.id; + cardElement.dataset.itemType = item.itemType; + + // Check Now button HTML is no longer generated separately here for absolute positioning + + let imageUrl = '/static/images/placeholder.jpg'; + if (item.imageUrl) { + imageUrl = item.imageUrl; + } + + let detailsHtml = ''; + let typeBadgeClass = ''; + let typeName = ''; + + if (item.itemType === 'artist') { + typeName = 'Artist'; + typeBadgeClass = 'artist'; + const artist = item as FinalArtistCardItem; + detailsHtml = artist.total_albums !== undefined ? `${artist.total_albums} albums` : ''; + } else if (item.itemType === 'playlist') { + typeName = 'Playlist'; + typeBadgeClass = 'playlist'; + const playlist = item as FinalPlaylistCardItem; + detailsHtml = playlist.owner_name ? `By: ${playlist.owner_name}` : ''; + detailsHtml += playlist.total_tracks !== undefined ? ` • ${playlist.total_tracks} tracks` : ''; + if (playlist.followers_count !== undefined) { + detailsHtml += ` • ${playlist.followers_count} followers`; + } + } + + cardElement.innerHTML = ` +
+ ${item.name} +
+
${item.name}
+
${detailsHtml}
+ ${typeName} +
+ + +
+ `; + + // Add click event to navigate to the item's detail page + cardElement.addEventListener('click', (e: MouseEvent) => { + const target = e.target as HTMLElement; + // Don't navigate if any button within the card was clicked + if (target.closest('button')) { + return; + } + window.location.href = `/${item.itemType}/${item.id}`; + }); + + // Add event listener for the "Check Now" button + const checkNowBtn = cardElement.querySelector('.check-item-now-btn') as HTMLButtonElement | null; + if (checkNowBtn) { + checkNowBtn.addEventListener('click', (e: MouseEvent) => { + e.stopPropagation(); + const itemId = checkNowBtn.dataset.id; + const itemType = checkNowBtn.dataset.type as 'artist' | 'playlist'; + if (itemId && itemType) { + triggerItemCheck(itemId, itemType, checkNowBtn); + } + }); + } + + // Add event listener for the "Unwatch" button + const unwatchBtn = cardElement.querySelector('.unwatch-item-btn') as HTMLButtonElement | null; + if (unwatchBtn) { + unwatchBtn.addEventListener('click', (e: MouseEvent) => { + e.stopPropagation(); + const itemId = unwatchBtn.dataset.id; + const itemType = unwatchBtn.dataset.type as 'artist' | 'playlist'; + if (itemId && itemType) { + unwatchItem(itemId, itemType, unwatchBtn, cardElement); + } + }); + } + + return cardElement; +} + +function showLoading(show: boolean) { + const loadingIndicator = document.getElementById('loadingWatchedItems'); + if (loadingIndicator) loadingIndicator.classList.toggle('hidden', !show); +} + +function showEmptyState(show: boolean) { + const emptyStateIndicator = document.getElementById('emptyWatchedItems'); + if (emptyStateIndicator) emptyStateIndicator.classList.toggle('hidden', !show); +} + +async function unwatchItem(itemId: string, itemType: 'artist' | 'playlist', buttonElement: HTMLButtonElement, cardElement: HTMLElement) { + const originalButtonContent = buttonElement.innerHTML; + buttonElement.disabled = true; + buttonElement.innerHTML = 'Unwatching...'; // Assuming a small loader icon + + const endpoint = `/api/${itemType}/watch/${itemId}`; + + try { + const response = await fetch(endpoint, { method: 'DELETE' }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `Server error: ${response.status}`); + } + const result = await response.json(); + showNotification(result.message || `${itemType.charAt(0).toUpperCase() + itemType.slice(1)} unwatched successfully.`); + + cardElement.style.transition = 'opacity 0.5s ease, transform 0.5s ease'; + cardElement.style.opacity = '0'; + cardElement.style.transform = 'scale(0.9)'; + setTimeout(() => { + cardElement.remove(); + const watchedItemsContainer = document.getElementById('watchedItemsContainer'); + const playlistGroups = document.querySelectorAll('.watched-items-group .results-grid'); + let totalItemsLeft = 0; + + if (playlistGroups.length > 0) { // Grouped view + playlistGroups.forEach(group => { + totalItemsLeft += group.childElementCount; + }); + // If a group becomes empty, we might want to remove the group header or show an empty message for that group. + // This can be added here if desired. + } else if (watchedItemsContainer) { // Non-grouped view + totalItemsLeft = watchedItemsContainer.childElementCount; + } + + if (totalItemsLeft === 0) { + // If all items are gone (either from groups or directly), reload to show empty state. + // This also correctly handles the case where the initial list had <= 8 items. + loadWatchedItems(); + } + + }, 500); + + } catch (error: any) { + console.error(`Error unwatching ${itemType}:`, error); + showNotification(`Failed to unwatch: ${error.message}`, true); + buttonElement.disabled = false; + buttonElement.innerHTML = originalButtonContent; + } +} + +async function triggerItemCheck(itemId: string, itemType: 'artist' | 'playlist', buttonElement: HTMLButtonElement) { + const originalButtonContent = buttonElement.innerHTML; // Will just be the img + buttonElement.disabled = true; + // Keep the icon, but we can add a class for spinning or use the same icon. + // For simplicity, just using the same icon. Text "Checking..." is removed. + buttonElement.innerHTML = 'Checking...'; + + const endpoint = `/api/${itemType}/watch/trigger_check/${itemId}`; + + try { + const response = await fetch(endpoint, { method: 'POST' }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); // Handle non-JSON error responses + throw new Error(errorData.error || `Server error: ${response.status}`); + } + const result = await response.json(); + showNotification(result.message || `Successfully triggered check for ${itemType}.`); + } catch (error: any) { + console.error(`Error triggering ${itemType} check:`, error); + showNotification(`Failed to trigger check: ${error.message}`, true); + } finally { + buttonElement.disabled = false; + buttonElement.innerHTML = originalButtonContent; + } +} + +// Helper function to show notifications (can be moved to a shared utility file if used elsewhere) +function showNotification(message: string, isError: boolean = false) { + const notificationArea = document.getElementById('notificationArea') || createNotificationArea(); + + // Limit the number of visible notifications + while (notificationArea.childElementCount >= MAX_NOTIFICATIONS) { + const oldestNotification = notificationArea.firstChild; // In column-reverse, firstChild is visually the bottom one + if (oldestNotification) { + oldestNotification.remove(); + } else { + break; // Should not happen if childElementCount > 0 + } + } + + const notification = document.createElement('div'); + notification.className = `notification-toast ${isError ? 'error' : 'success'}`; + notification.textContent = message; + + notificationArea.appendChild(notification); + + // Auto-remove after 5 seconds + setTimeout(() => { + notification.classList.add('hide'); + setTimeout(() => notification.remove(), 500); // Remove from DOM after fade out + }, 5000); +} + +function createNotificationArea(): HTMLElement { + const area = document.createElement('div'); + area.id = 'notificationArea'; + document.body.appendChild(area); + return area; +} \ No newline at end of file diff --git a/static/css/config/config.css b/static/css/config/config.css index 1f9d01f..3d11079 100644 --- a/static/css/config/config.css +++ b/static/css/config/config.css @@ -985,4 +985,42 @@ input:checked + .slider:before { /* Reset some global label styles if they interfere */ display: inline; margin-bottom: 0; +} + +/* Urgent Warning Message Style */ +.urgent-warning-message { + background-color: rgba(255, 165, 0, 0.1); /* Orange/Amber background */ + border: 1px solid #FFA500; /* Orange/Amber border */ + color: #FFA500; /* Orange/Amber text */ + padding: 1rem; + border-radius: 8px; + display: flex; /* Use flex to align icon and text */ + align-items: center; /* Vertically align icon and text */ + margin-top: 1rem; + margin-bottom: 1rem; +} + +.urgent-warning-message .warning-icon { + margin-right: 0.75rem; /* Space between icon and text */ + min-width: 24px; /* Ensure icon doesn't shrink too much */ + color: #FFA500; /* Match icon color to text/border */ +} + +/* Existing info-message style - ensure it doesn't conflict or adjust if needed */ +.info-message { + background-color: rgba(0, 123, 255, 0.1); + border: 1px solid #007bff; + color: #007bff; + padding: 1rem; + border-radius: 8px; + margin-top: 1rem; + margin-bottom: 1rem; +} + +/* Version text styling */ +.version-text { + font-size: 0.9rem; + color: #888; /* Light grey color */ + margin-left: auto; /* Push it to the right */ + padding-top: 0.5rem; /* Align with title better */ } \ No newline at end of file diff --git a/static/css/main/base.css b/static/css/main/base.css index a933b44..06037c3 100644 --- a/static/css/main/base.css +++ b/static/css/main/base.css @@ -27,6 +27,9 @@ --color-primary-hover: #17a44b; --color-error: #c0392b; --color-success: #2ecc71; + /* Adding accent green if not present, or ensuring it is */ + --color-accent-green: #22c55e; /* Example: A Tailwind-like green */ + --color-accent-green-dark: #16a34a; /* Darker shade for hover */ /* Spacing */ --space-xs: 0.25rem; @@ -499,4 +502,40 @@ a:hover, a:focus { font-style: italic; font-size: 0.9rem; margin-top: 0.5rem; +} + +.watchlist-icon { + position: fixed; + right: 20px; + bottom: 90px; /* Positioned above the queue icon */ + z-index: 1000; +} + +/* Responsive adjustments for floating icons */ +@media (max-width: 768px) { + .floating-icon { + width: 48px; + height: 48px; + right: 15px; + } + .settings-icon { + bottom: 15px; /* Adjust for smaller screens */ + } + .queue-icon { + bottom: 15px; /* Adjust for smaller screens */ + } + .watchlist-icon { + bottom: 75px; /* Adjust for smaller screens, above queue icon */ + } + .home-btn.floating-icon { /* Specific for home button if it's also floating */ + left: 15px; + bottom: 15px; + } +} + +/* Ensure images inside btn-icon are sized correctly */ +.btn-icon img { + width: 20px; + height: 20px; + filter: brightness(0) invert(1); } \ No newline at end of file diff --git a/static/css/watch/watch.css b/static/css/watch/watch.css new file mode 100644 index 0000000..5c4215b --- /dev/null +++ b/static/css/watch/watch.css @@ -0,0 +1,350 @@ +/* static/css/watch/watch.css */ + +/* General styles for the watch page, similar to main.css */ +body { + font-family: var(--font-family-sans-serif); + background-color: var(--background-color); + color: white; + margin: 0; + padding: 0; +} + +.app-container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +.watch-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; + padding-bottom: 15px; + border-bottom: 1px solid var(--border-color-soft); +} + +.watch-header h1 { + color: white; + font-size: 2em; + margin: 0; +} + +.check-all-btn { + padding: 10px 15px; + font-size: 0.9em; + display: flex; + align-items: center; + gap: 8px; /* Space between icon and text */ + background-color: var(--color-accent-green); /* Green background */ + color: white; /* Ensure text is white for contrast */ + border: none; /* Remove default border */ +} + +.check-all-btn:hover { + background-color: var(--color-accent-green-dark); /* Darker green on hover */ +} + +.check-all-btn img { + width: 18px; /* Slightly larger for header button */ + height: 18px; + filter: brightness(0) invert(1); /* Ensure header icon is white */ +} + +.back-to-search-btn { + padding: 10px 20px; + font-size: 0.9em; +} + +/* Styling for the grid of watched items, similar to results-grid */ +.results-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); /* Responsive grid */ + gap: 20px; + padding: 0; +} + +/* Individual watched item card styling, inspired by result-card from main.css */ +.watched-item-card { + background-color: var(--color-surface); + border-radius: var(--border-radius-medium); + padding: 15px; + box-shadow: var(--shadow-soft); + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + transition: transform 0.2s ease, box-shadow 0.2s ease; + cursor: pointer; + position: relative; +} + +.watched-item-card:hover { + transform: translateY(-5px); + box-shadow: var(--shadow-medium); + border-top: 1px solid var(--border-color-soft); +} + +.item-art-wrapper { + width: 100%; + padding-bottom: 100%; /* 1:1 Aspect Ratio */ + position: relative; + margin-bottom: 15px; + border-radius: var(--border-radius-soft); + overflow: hidden; +} + +.item-art { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; /* Cover the area, cropping if necessary */ +} + +.item-name { + font-size: 1.1em; + font-weight: bold; + color: white; + margin-bottom: 5px; + display: -webkit-box; + -webkit-line-clamp: 2; /* Limit to 2 lines */ + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + min-height: 2.4em; /* Reserve space for two lines */ +} + +.item-details { + font-size: 0.9em; + color: white; + margin-bottom: 10px; + line-height: 1.4; + width: 100%; /* Ensure it takes full width for centering/alignment */ +} + +.item-details span { + display: block; /* Each detail on a new line */ + margin-bottom: 3px; +} + +.item-type-badge { + display: inline-block; + padding: 3px 8px; + font-size: 0.75em; + font-weight: bold; + border-radius: var(--border-radius-small); + margin-bottom: 10px; + text-transform: uppercase; +} + +.item-type-badge.artist { + background-color: var(--color-accent-blue-bg); + color: var(--color-accent-blue-text); +} + +.item-type-badge.playlist { + background-color: var(--color-accent-green-bg); + color: var(--color-accent-green-text); +} + +/* Action buttons (e.g., Go to item, Unwatch) */ +.item-actions { + margin-top: auto; + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 10px; + border-top: 1px solid var(--border-color-soft); +} + +.item-actions .btn-icon { + padding: 0; + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0; + border: none; +} + +.item-actions .check-item-now-btn, +.item-actions .unwatch-item-btn { + /* Shared properties are in .item-actions .btn-icon */ +} + +.item-actions .check-item-now-btn { + background-color: var(--color-accent-green); +} + +.item-actions .check-item-now-btn:hover { + background-color: var(--color-accent-green-dark); +} + +.item-actions .check-item-now-btn img, +.item-actions .unwatch-item-btn img { + width: 16px; + height: 16px; + filter: brightness(0) invert(1); +} + +.item-actions .unwatch-item-btn { + background-color: var(--color-error); + color: white; +} + +.item-actions .unwatch-item-btn:hover { + background-color: #a52a2a; +} + +/* Loading and Empty State - reuse from main.css if possible or define here */ +.loading, +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 40px 20px; + color: var(--text-color-muted); + width: 100%; +} + +.loading.hidden, +.empty-state.hidden { + display: none; +} + +.loading-indicator { + font-size: 1.2em; + margin-bottom: 10px; + color: white; +} + +.empty-state-content { + max-width: 400px; +} + +.empty-state-icon { + width: 80px; + height: 80px; + margin-bottom: 20px; + opacity: 0.7; + filter: brightness(0) invert(1); /* Added to make icon white */ +} + +.empty-state h2 { + font-size: 1.5em; + color: white; + margin-bottom: 10px; +} + +.empty-state p { + font-size: 1em; + line-height: 1.5; + color: white; +} + +/* Ensure floating icons from base.css are not obscured or mispositioned */ +/* No specific overrides needed if base.css handles them well */ + +/* Responsive adjustments if needed */ +@media (max-width: 768px) { + .results-grid { + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + } + .watch-header h1 { + font-size: 1.5em; + } + .watched-group-header { + font-size: 1.5rem; + } +} + +@media (max-width: 480px) { + .results-grid { + grid-template-columns: 1fr; /* Single column on very small screens */ + } + .watched-item-card { + padding: 10px; + } + .item-name { + font-size: 1em; + } + .item-details { + font-size: 0.8em; + } +} + +.watched-items-group { + margin-bottom: 2rem; /* Space between groups */ +} + +.watched-group-header { + font-size: 1.8rem; + color: var(--color-text-primary); + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--color-border); +} + +.empty-group-message { + color: var(--color-text-secondary); + padding: 1rem; + text-align: center; + font-style: italic; +} + +/* Ensure the main watchedItemsContainer still behaves like a grid if there are few items */ +#watchedItemsContainer:not(:has(.watched-items-group)) { + display: grid; + /* Assuming results-grid styles are already defined elsewhere, + or copy relevant grid styles here if needed */ +} + +/* Notification Toast Styles */ +#notificationArea { + position: fixed; + bottom: 20px; + left: 50%; /* Center horizontally */ + transform: translateX(-50%); /* Adjust for exact centering */ + z-index: 2000; + display: flex; + flex-direction: column-reverse; + gap: 10px; + width: auto; /* Allow width to be determined by content */ + max-width: 90%; /* Prevent it from being too wide on large screens */ +} + +.notification-toast { + padding: 12px 20px; + border-radius: var(--border-radius-medium); + color: white; /* Default text color to white */ + font-size: 0.9em; + box-shadow: var(--shadow-strong); + opacity: 1; + transition: opacity 0.5s ease, transform 0.5s ease; + transform: translateX(0); /* Keep this for the hide animation */ + text-align: center; /* Center text within the toast */ +} + +.notification-toast.success { + background-color: var(--color-success); /* Use existing success color */ + /* color: var(--color-accent-green-text); REMOVE - use white */ + /* border: 1px solid var(--color-accent-green-text); REMOVE */ +} + +.notification-toast.error { + background-color: var(--color-error); /* Use existing error color */ + /* color: var(--color-accent-red-text); REMOVE - use white */ + /* border: 1px solid var(--color-accent-red-text); REMOVE */ +} + +.notification-toast.hide { + opacity: 0; + transform: translateY(100%); /* Slide down for exit, or could keep translateX if preferred */ +} \ No newline at end of file diff --git a/static/html/album.html b/static/html/album.html index 0747fb5..a02c747 100644 --- a/static/html/album.html +++ b/static/html/album.html @@ -50,6 +50,10 @@ Home + + Watchlist + + + + Watchlist + + + + Watchlist + + + + Watchlist + + + + +
+ +
+ + + + + + + + + Home + + + + Watchlist + + + + + + + + + \ No newline at end of file diff --git a/static/images/binoculars.svg b/static/images/binoculars.svg new file mode 100644 index 0000000..553498d --- /dev/null +++ b/static/images/binoculars.svg @@ -0,0 +1,12 @@ + + + + binoculars-filled + + + + + + + + \ No newline at end of file diff --git a/static/images/refresh-cw.svg b/static/images/refresh-cw.svg new file mode 100644 index 0000000..efd180f --- /dev/null +++ b/static/images/refresh-cw.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file From ba33e10afc018a13c92db3b6dbe1f47018a7dd33 Mon Sep 17 00:00:00 2001 From: "cool.gitter.not.me.again.duh" Date: Thu, 29 May 2025 14:45:29 -0600 Subject: [PATCH 09/18] I am still unworthy of SSE... --- routes/utils/celery_tasks.py | 25 +++++ src/js/queue.ts | 195 ++++++++++++++++++++++++++++------- 2 files changed, 180 insertions(+), 40 deletions(-) diff --git a/routes/utils/celery_tasks.py b/routes/utils/celery_tasks.py index d83185e..9e2d77e 100644 --- a/routes/utils/celery_tasks.py +++ b/routes/utils/celery_tasks.py @@ -440,6 +440,17 @@ class ProgressTrackingTask(Task): # Store the processed status update store_task_status(task_id, stored_data) + # Immediately delete task info from Redis after marking as complete + if stored_data.get("status") == ProgressState.COMPLETE: + logger.info(f"Task {task_id} completed. Deleting task data from Redis.") + try: + redis_client.delete(f"task:{task_id}:info") + redis_client.delete(f"task:{task_id}:status") + redis_client.delete(f"task:{task_id}:status:next_id") # Also delete the counter + logger.info(f"Successfully deleted Redis data for completed task {task_id}.") + except Exception as e: + logger.error(f"Error deleting Redis data for completed task {task_id}: {e}", exc_info=True) + def _handle_initializing(self, task_id, data, task_info): """Handle initializing status from deezspot""" # Extract relevant fields @@ -785,6 +796,20 @@ class ProgressTrackingTask(Task): data["status"] = ProgressState.COMPLETE data["message"] = "Download complete" + # Store the processed status update + store_task_status(task_id, data) + + # Immediately delete task info from Redis after marking as complete + if data.get("status") == ProgressState.COMPLETE: + logger.info(f"Task {task_id} ({task_info.get('name', 'Unknown')}) completed. Deleting task data from Redis.") + try: + redis_client.delete(f"task:{task_id}:info") + redis_client.delete(f"task:{task_id}:status") + redis_client.delete(f"task:{task_id}:status:next_id") # Also delete the counter + logger.info(f"Successfully deleted Redis data for completed task {task_id}.") + except Exception as e: + logger.error(f"Error deleting Redis data for completed task {task_id}: {e}", exc_info=True) + # Celery signal handlers @task_prerun.connect def task_prerun_handler(task_id=None, task=None, *args, **kwargs): diff --git a/src/js/queue.ts b/src/js/queue.ts index 5fcb546..ab9fe2d 100644 --- a/src/js/queue.ts +++ b/src/js/queue.ts @@ -162,6 +162,7 @@ export class DownloadQueue { // Load the saved visible count (or default to 10) visibleCount: number; + globalSyncIntervalId: number | null = null; // For the new global sync constructor() { const storedVisibleCount = localStorage.getItem("downloadQueueVisibleCount"); @@ -202,7 +203,10 @@ export class DownloadQueue { // Wait for initDOM to complete before setting up event listeners and loading existing PRG files. this.initDOM().then(() => { this.initEventListeners(); - this.loadExistingPrgFiles(); + this.loadExistingPrgFiles().then(() => { // Ensure loadExistingPrgFiles completes + // Start global task list synchronization after initial load + this.startGlobalTaskSync(); + }); }); } @@ -387,6 +391,18 @@ export class DownloadQueue { * Adds a new download entry. */ addDownload(item: QueueItem, type: string, prgFile: string, requestUrl: string | null = null, startMonitoring: boolean = false): string { + // Check if an entry with this prgFile already exists + const existingQueueId = this.findQueueIdByPrgFile(prgFile); + if (existingQueueId) { + console.log(`addDownload: Entry for prgFile ${prgFile} already exists with queueId ${existingQueueId}. Ensuring monitoring.`); + const existingEntry = this.queueEntries[existingQueueId]; + if (existingEntry && !existingEntry.hasEnded && startMonitoring && !this.pollingIntervals[existingQueueId]) { + // If it exists, is not ended, needs monitoring, and isn't currently polled, start its individual polling. + this.startDownloadStatusMonitoring(existingQueueId); + } + return existingQueueId; // Return existing ID + } + const queueId = this.generateQueueId(); const entry = this.createQueueEntry(item, type, prgFile, queueId, requestUrl); this.queueEntries[queueId] = entry; @@ -972,7 +988,16 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) return index >= 0 && index < this.visibleCount; } - async cleanupEntry(queueId: string) { + findQueueIdByPrgFile(prgFile: string): string | undefined { + for (const queueId in this.queueEntries) { + if (this.queueEntries[queueId].prgFile === prgFile) { + return queueId; + } + } + return undefined; + } + + async cleanupEntry(queueId: string /* Parameter deleteFromServer removed */) { const entry = this.queueEntries[queueId]; if (entry) { // Close any polling interval @@ -998,17 +1023,8 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); } - // Delete the entry from the server - try { - const response = await fetch(`/api/prgs/delete/${entry.prgFile}`, { method: 'DELETE' }); - if (response.ok) { - console.log(`Successfully deleted task ${entry.prgFile} from server`); - } else { - console.warn(`Failed to delete task ${entry.prgFile}: ${response.status} ${response.statusText}`); - } - } catch (error) { - console.error(`Error deleting task ${entry.prgFile}:`, error); - } + // The block for deleting from server has been removed. + // console.log(`Entry ${queueId} (${entry.prgFile}) cleaned up from UI and local cache.`); // Update the queue display this.updateQueueOrder(); @@ -1303,14 +1319,22 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) // Stop polling this.clearPollingInterval(queueId); - - // Use 10 seconds cleanup delay for all states including errors - const cleanupDelay = 10000; - - // Clean up after the appropriate delay - setTimeout(() => { - this.cleanupEntry(queueId); - }, cleanupDelay); + + const statusData = typeof progress === 'object' ? progress : entry.lastStatus; + + if (statusData && (statusData.status === 'complete' || statusData.status === 'done')) { + // For completed tasks, show for 2 seconds then remove from UI only + setTimeout(() => { + this.cleanupEntry(queueId); // Pass only queueId + }, 2000); + } else { + // For other terminal states (error, cancelled), use existing cleanup logic (default 10s) + // The server-side delete for these will be handled by backend mechanisms or specific cancel actions + const cleanupDelay = 10000; + setTimeout(() => { + this.cleanupEntry(queueId); // Pass only queueId + }, cleanupDelay); + } } handleInactivity(entry: QueueEntry, queueId: string, logElement: HTMLElement | null) { // Add types @@ -2004,27 +2028,32 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) if (data.last_line.status === 'cancelled' || data.last_line.status === 'cancel') { console.log('Cleaning up cancelled download immediately'); this.clearPollingInterval(queueId); - this.cleanupEntry(queueId); + this.cleanupEntry(queueId); // Pass only queueId return; // No need to process further } - // Only set up cleanup if this is not an error that we're in the process of retrying - // If status is 'error' but the status message contains 'Retrying', don't clean up - const isRetrying = entry.isRetrying || - (data.last_line.status === 'error' && - entry.element.querySelector('.log')?.textContent?.includes('Retry')); - - if (!isRetrying) { + // For completed tasks, start 2s UI timer + if (data.last_line.status === 'complete' || data.last_line.status === 'done') { + this.clearPollingInterval(queueId); setTimeout(() => { - // Double-check the entry still exists and has not been retried before cleaning up - const currentEntry = this.queueEntries[queueId]; // Get current entry - if (currentEntry && // Check if currentEntry exists - !currentEntry.isRetrying && - currentEntry.hasEnded) { - this.clearPollingInterval(queueId); - this.cleanupEntry(queueId); - } - }, 5000); + this.cleanupEntry(queueId); // Pass only queueId + }, 2000); + // Do not return here, allow UI to update to complete state first + } else { + // For other terminal states like 'error' + // Only set up cleanup if this is not an error that we're in the process of retrying + const isRetrying = entry.isRetrying || + (data.last_line.status === 'error' && + entry.element.querySelector('.log')?.textContent?.includes('Retry')); + + if (!isRetrying) { + // Errors will use the handleDownloadCompletion logic which has its own timeout + // this.handleDownloadCompletion(entry, queueId, data.last_line); + // No, we want to ensure polling stops here for errors too if not retrying + this.clearPollingInterval(queueId); + // Existing logic for error display and auto-cleanup (15s) is below + // and cleanupEntry for errors will be called from there or from handleDownloadCompletion + } } } @@ -2218,7 +2247,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) const closeErrorBtn = errorLogElement.querySelector('.close-error-btn') as HTMLButtonElement | null; if (closeErrorBtn) { closeErrorBtn.addEventListener('click', () => { - this.cleanupEntry(queueId); + this.cleanupEntry(queueId); // Pass only queueId }); } @@ -2257,7 +2286,21 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) // Handle terminal states for non-error cases if (['complete', 'cancel', 'cancelled', 'done', 'skipped'].includes(status)) { entry.hasEnded = true; - this.handleDownloadCompletion(entry, queueId, statusData); + // this.handleDownloadCompletion(entry, queueId, statusData); // Already called from fetchDownloadStatus for terminal states + // We need to ensure the 2-second rule for 'complete'/'done' is applied here too, if not already handled + if (status === 'complete' || status === 'done') { + if (!this.pollingIntervals[queueId]) { // Check if polling was already cleared (meaning timeout started) + this.clearPollingInterval(queueId); + setTimeout(() => { + this.cleanupEntry(queueId); // Pass only queueId + }, 2000); + } + } else if (status === 'cancel' || status === 'cancelled' || status === 'skipped') { + // For cancelled or skipped, can cleanup sooner or use existing server delete logic + this.clearPollingInterval(queueId); + this.cleanupEntry(queueId); // Pass only queueId + } + // Errors are handled by their specific block below } // Cache the status for potential page reloads @@ -2731,6 +2774,78 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) for (const queueId in this.pollingIntervals) { this.clearPollingInterval(queueId); } + if (this.globalSyncIntervalId !== null) { + clearInterval(this.globalSyncIntervalId as number); + this.globalSyncIntervalId = null; + console.log('Stopped global task sync polling.'); + } + } + + async syncWithBackendTaskList() { + try { + const response = await fetch('/api/prgs/list'); + if (!response.ok) { + console.error('Failed to fetch backend task list:', response.status); + return; + } + const backendTaskIds: string[] = await response.json(); + const backendTaskIdSet = new Set(backendTaskIds); + + // console.log('Backend task IDs:', backendTaskIds); + // console.log('Frontend task IDs (prgFiles):', Object.values(this.queueEntries).map(e => e.prgFile)); + + // 1. Add new tasks from backend that are not in frontend + for (const taskId of backendTaskIds) { + if (!this.findQueueIdByPrgFile(taskId)) { + console.log(`Sync: Task ${taskId} found in backend but not frontend. Fetching details.`); + try { + const taskDetailsResponse = await fetch(`/api/prgs/${taskId}`); + if (taskDetailsResponse.ok) { + const taskDetails: StatusData = await taskDetailsResponse.json(); + // Construct a minimal item for addDownload. The actual details will be filled by status updates. + const item: QueueItem = { + name: taskDetails.last_line?.name || taskDetails.last_line?.song || taskDetails.last_line?.title || taskDetails.original_request?.name || taskId, + artist: taskDetails.last_line?.artist || taskDetails.original_request?.artist || '', + type: taskDetails.last_line?.type || taskDetails.original_request?.type || 'unknown' + }; + const requestUrl = taskDetails.original_url || taskDetails.original_request?.url || null; + this.addDownload(item, item.type || 'unknown', taskId, requestUrl, true); // true to start monitoring + } else { + console.warn(`Sync: Failed to fetch details for new task ${taskId} from backend.`); + } + } catch (fetchError) { + console.error(`Sync: Error fetching details for task ${taskId}:`, fetchError); + } + } + } + + // 2. Remove stale tasks from frontend that are not in backend active list + const frontendPrgFiles = Object.values(this.queueEntries).map(entry => entry.prgFile); + for (const prgFile of frontendPrgFiles) { + const queueId = this.findQueueIdByPrgFile(prgFile); + if (queueId && !backendTaskIdSet.has(prgFile)) { + const entry = this.queueEntries[queueId]; + // Only remove if it's not already considered ended by frontend (e.g., completed and timer running) + if (entry && !entry.hasEnded) { + console.log(`Sync: Task ${prgFile} (queueId: ${queueId}) found in frontend but not in backend active list. Removing.`); + this.cleanupEntry(queueId); + } + } + } + } catch (error) { + console.error('Error during global task sync:', error); + } + } + + startGlobalTaskSync() { + if (this.globalSyncIntervalId !== null) { + clearInterval(this.globalSyncIntervalId as number); + } + this.syncWithBackendTaskList(); // Initial sync + this.globalSyncIntervalId = setInterval(() => { + this.syncWithBackendTaskList(); + }, 5000) as unknown as number; // Poll every 5 seconds + console.log('Started global task sync polling every 5 seconds.'); } } From 1a39af37304a016b805ced626c07d6460bafbbc9 Mon Sep 17 00:00:00 2001 From: "cool.gitter.not.me.again.duh" Date: Thu, 29 May 2025 18:10:37 -0600 Subject: [PATCH 10/18] I think we good --- app.py | 6 + requirements.txt | 1 + routes/__init__.py | 2 +- routes/album.py | 33 +- routes/artist.py | 6 +- routes/history.py | 42 +++ routes/playlist.py | 36 ++- routes/prgs.py | 84 ++++- routes/track.py | 33 +- routes/utils/celery_manager.py | 74 ++++- routes/utils/celery_tasks.py | 218 +++++++++---- routes/utils/get_info.py | 12 +- routes/utils/history_manager.py | 235 ++++++++++++++ routes/utils/watch/manager.py | 2 +- src/js/history.ts | 160 ++++++++++ src/js/queue.ts | 540 +++++++++++++------------------- static/css/config/config.css | 30 -- static/css/history/history.css | 121 +++++++ static/css/main/base.css | 79 ++--- static/css/main/icons.css | 41 +++ static/html/album.html | 3 + static/html/artist.html | 3 + static/html/config.html | 3 + static/html/history.html | 83 +++++ static/html/main.html | 3 + static/html/playlist.html | 3 + static/html/track.html | 3 + static/html/watch.html | 3 + static/images/history.svg | 4 + static/images/info.svg | 7 + 30 files changed, 1374 insertions(+), 496 deletions(-) create mode 100644 routes/history.py create mode 100644 routes/utils/history_manager.py create mode 100644 src/js/history.ts create mode 100644 static/css/history/history.css create mode 100644 static/html/history.html create mode 100644 static/images/history.svg create mode 100644 static/images/info.svg diff --git a/app.py b/app.py index 3bf61aa..5b3ccaf 100755 --- a/app.py +++ b/app.py @@ -8,6 +8,7 @@ from routes.playlist import playlist_bp from routes.prgs import prgs_bp from routes.config import config_bp from routes.artist import artist_bp +from routes.history import history_bp import logging import logging.handlers import time @@ -149,6 +150,7 @@ def create_app(): app.register_blueprint(playlist_bp, url_prefix='/api/playlist') app.register_blueprint(artist_bp, url_prefix='/api/artist') app.register_blueprint(prgs_bp, url_prefix='/api/prgs') + app.register_blueprint(history_bp, url_prefix='/api/history') # Serve frontend @app.route('/') @@ -186,6 +188,10 @@ def create_app(): # The id parameter is captured, but you can use it as needed. return render_template('artist.html') + @app.route('/history') + def serve_history_page(): + return render_template('history.html') + @app.route('/static/') def serve_static(path): return send_from_directory('static', path) diff --git a/requirements.txt b/requirements.txt index 5cae1d0..88789c3 100755 --- a/requirements.txt +++ b/requirements.txt @@ -42,6 +42,7 @@ six==1.17.0 sniffio==1.3.1 spotipy==2.25.1 spotipy_anon==1.4 +sse-starlette==2.3.5 starlette==0.46.2 tqdm==4.67.1 typing-inspection==0.4.1 diff --git a/routes/__init__.py b/routes/__init__.py index 9bde965..d4013a1 100755 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -3,7 +3,7 @@ import atexit # Configure basic logging for the application if not already configured # This is a good place for it if routes are a central part of your app structure. -logging.basicConfig(level=logging.DEBUG, +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) diff --git a/routes/album.py b/routes/album.py index e2cf496..8a3df33 100755 --- a/routes/album.py +++ b/routes/album.py @@ -6,18 +6,39 @@ import uuid import time from routes.utils.celery_queue_manager import download_queue_manager from routes.utils.celery_tasks import store_task_info, store_task_status, ProgressState +from routes.utils.get_info import get_spotify_info album_bp = Blueprint('album', __name__) @album_bp.route('/download/', methods=['GET']) def handle_download(album_id): # Retrieve essential parameters from the request. - name = request.args.get('name') - artist = request.args.get('artist') + # name = request.args.get('name') + # artist = request.args.get('artist') # Construct the URL from album_id url = f"https://open.spotify.com/album/{album_id}" + # Fetch metadata from Spotify + try: + album_info = get_spotify_info(album_id, "album") + if not album_info or not album_info.get('name') or not album_info.get('artists'): + return Response( + json.dumps({"error": f"Could not retrieve metadata for album ID: {album_id}"}), + status=404, + mimetype='application/json' + ) + + name_from_spotify = album_info.get('name') + artist_from_spotify = album_info['artists'][0].get('name') if album_info['artists'] else "Unknown Artist" + + except Exception as e: + return Response( + json.dumps({"error": f"Failed to fetch metadata for album {album_id}: {str(e)}"}), + status=500, + mimetype='application/json' + ) + # Validate required parameters if not url: return Response( @@ -35,8 +56,8 @@ def handle_download(album_id): task_id = download_queue_manager.add_task({ "download_type": "album", "url": url, - "name": name, - "artist": artist, + "name": name_from_spotify, + "artist": artist_from_spotify, "orig_request": orig_params }) except Exception as e: @@ -47,8 +68,8 @@ def handle_download(album_id): store_task_info(error_task_id, { "download_type": "album", "url": url, - "name": name, - "artist": artist, + "name": name_from_spotify, + "artist": artist_from_spotify, "original_request": orig_params, "created_at": time.time(), "is_submission_error_task": True diff --git a/routes/artist.py b/routes/artist.py index f811250..d6f0b5d 100644 --- a/routes/artist.py +++ b/routes/artist.py @@ -124,7 +124,7 @@ def get_artist_info(): try: from routes.utils.get_info import get_spotify_info - artist_info = get_spotify_info(spotify_id, "artist") + artist_info = get_spotify_info(spotify_id, "artist_discography") # If artist_info is successfully fetched (it contains album items), # check if the artist is watched and augment album items with is_locally_known status @@ -166,11 +166,11 @@ def add_artist_to_watchlist(artist_spotify_id): return jsonify({"message": f"Artist {artist_spotify_id} is already being watched."}), 200 # This call returns an album list-like structure based on logs - artist_album_list_data = get_spotify_info(artist_spotify_id, "artist") + artist_album_list_data = get_spotify_info(artist_spotify_id, "artist_discography") # Check if we got any data and if it has items if not artist_album_list_data or not isinstance(artist_album_list_data.get('items'), list): - logger.error(f"Could not fetch album list details for artist {artist_spotify_id} from Spotify using get_spotify_info('artist'). Data: {artist_album_list_data}") + logger.error(f"Could not fetch album list details for artist {artist_spotify_id} from Spotify using get_spotify_info('artist_discography'). Data: {artist_album_list_data}") return jsonify({"error": f"Could not fetch sufficient details for artist {artist_spotify_id} to initiate watch."}), 404 # Attempt to extract artist name and verify ID diff --git a/routes/history.py b/routes/history.py new file mode 100644 index 0000000..69ec7f8 --- /dev/null +++ b/routes/history.py @@ -0,0 +1,42 @@ +from flask import Blueprint, jsonify, request +from routes.utils.history_manager import get_history_entries +import logging + +logger = logging.getLogger(__name__) +history_bp = Blueprint('history', __name__, url_prefix='/api/history') + +@history_bp.route('', methods=['GET']) +def get_download_history(): + """API endpoint to retrieve download history with pagination, sorting, and filtering.""" + try: + limit = request.args.get('limit', 25, type=int) + offset = request.args.get('offset', 0, type=int) + sort_by = request.args.get('sort_by', 'timestamp_completed') + sort_order = request.args.get('sort_order', 'DESC') + + # Basic filtering example: filter by status_final or download_type + filters = {} + status_filter = request.args.get('status_final') + if status_filter: + filters['status_final'] = status_filter + + type_filter = request.args.get('download_type') + if type_filter: + filters['download_type'] = type_filter + + # Add more filters as needed, e.g., by item_name (would need LIKE for partial match) + # search_term = request.args.get('search') + # if search_term: + # filters['item_name'] = f'%{search_term}%' # This would require LIKE in get_history_entries + + entries, total_count = get_history_entries(limit, offset, sort_by, sort_order, filters) + + return jsonify({ + 'entries': entries, + 'total_count': total_count, + 'limit': limit, + 'offset': offset + }) + except Exception as e: + logger.error(f"Error in /api/history endpoint: {e}", exc_info=True) + return jsonify({"error": "Failed to retrieve download history"}), 500 \ No newline at end of file diff --git a/routes/playlist.py b/routes/playlist.py index 9cb6f09..2f53fb1 100755 --- a/routes/playlist.py +++ b/routes/playlist.py @@ -28,13 +28,35 @@ playlist_bp = Blueprint('playlist', __name__, url_prefix='/api/playlist') @playlist_bp.route('/download/', methods=['GET']) def handle_download(playlist_id): # Retrieve essential parameters from the request. - name = request.args.get('name') - artist = request.args.get('artist') + # name = request.args.get('name') # Removed + # artist = request.args.get('artist') # Removed orig_params = request.args.to_dict() # Construct the URL from playlist_id url = f"https://open.spotify.com/playlist/{playlist_id}" - orig_params["original_url"] = url # Update original_url to the constructed one + orig_params["original_url"] = request.url # Update original_url to the constructed one + + # Fetch metadata from Spotify + try: + playlist_info = get_spotify_info(playlist_id, "playlist") + if not playlist_info or not playlist_info.get('name') or not playlist_info.get('owner'): + return Response( + json.dumps({"error": f"Could not retrieve metadata for playlist ID: {playlist_id}"}), + status=404, + mimetype='application/json' + ) + + name_from_spotify = playlist_info.get('name') + # Use owner's display_name as the 'artist' for playlists + owner_info = playlist_info.get('owner', {}) + artist_from_spotify = owner_info.get('display_name', "Unknown Owner") + + except Exception as e: + return Response( + json.dumps({"error": f"Failed to fetch metadata for playlist {playlist_id}: {str(e)}"}), + status=500, + mimetype='application/json' + ) # Validate required parameters if not url: # This check might be redundant now but kept for safety @@ -48,8 +70,8 @@ def handle_download(playlist_id): task_id = download_queue_manager.add_task({ "download_type": "playlist", "url": url, - "name": name, - "artist": artist, + "name": name_from_spotify, # Use fetched name + "artist": artist_from_spotify, # Use fetched owner name as artist "orig_request": orig_params }) # Removed DuplicateDownloadError handling, add_task now manages this by creating an error task. @@ -59,8 +81,8 @@ def handle_download(playlist_id): store_task_info(error_task_id, { "download_type": "playlist", "url": url, - "name": name, - "artist": artist, + "name": name_from_spotify, # Use fetched name + "artist": artist_from_spotify, # Use fetched owner name as artist "original_request": orig_params, "created_at": time.time(), "is_submission_error_task": True diff --git a/routes/prgs.py b/routes/prgs.py index 6190622..330e0b0 100755 --- a/routes/prgs.py +++ b/routes/prgs.py @@ -1,4 +1,4 @@ -from flask import Blueprint, abort, jsonify, Response, stream_with_context +from flask import Blueprint, abort, jsonify, Response, stream_with_context, request import os import json import logging @@ -38,11 +38,38 @@ def get_prg_file(task_id): task_info = get_task_info(task_id) if not task_info: abort(404, "Task not found") - original_request = task_info.get("original_request", {}) + + # Dynamically construct original_url + dynamic_original_url = "" + download_type = task_info.get("download_type") + # The 'url' field in task_info stores the Spotify/Deezer URL of the item + # e.g., https://open.spotify.com/album/albumId or https://www.deezer.com/track/trackId + item_url = task_info.get("url") + + if download_type and item_url: + try: + # Extract the ID from the item_url (last part of the path) + item_id = item_url.split('/')[-1] + if item_id: # Ensure item_id is not empty + base_url = request.host_url.rstrip('/') + dynamic_original_url = f"{base_url}/api/{download_type}/download/{item_id}" + else: + logger.warning(f"Could not extract item ID from URL: {item_url} for task {task_id}. Falling back for original_url.") + original_request_obj = task_info.get("original_request", {}) + dynamic_original_url = original_request_obj.get("original_url", "") + except Exception as e: + logger.error(f"Error constructing dynamic original_url for task {task_id}: {e}", exc_info=True) + original_request_obj = task_info.get("original_request", {}) + dynamic_original_url = original_request_obj.get("original_url", "") # Fallback on any error + else: + logger.warning(f"Missing download_type ('{download_type}') or item_url ('{item_url}') in task_info for task {task_id}. Falling back for original_url.") + original_request_obj = task_info.get("original_request", {}) + dynamic_original_url = original_request_obj.get("original_url", "") + last_status = get_last_task_status(task_id) status_count = len(get_task_status(task_id)) response = { - "original_url": original_request.get("original_url", ""), + "original_url": dynamic_original_url, "last_line": last_status, "timestamp": time.time(), "task_id": task_id, @@ -75,12 +102,53 @@ def delete_prg_file(task_id): def list_prg_files(): """ Retrieve a list of all tasks in the system. - Combines results from both the old PRG file system and the new task ID based system. + Returns a detailed list of task objects including status and metadata. """ - # List only new system tasks - tasks = get_all_tasks() - task_ids = [task["task_id"] for task in tasks] - return jsonify(task_ids) + try: + tasks = get_all_tasks() # This already gets summary data + detailed_tasks = [] + for task_summary in tasks: + task_id = task_summary.get("task_id") + if not task_id: + continue + + task_info = get_task_info(task_id) + last_status = get_last_task_status(task_id) + + if task_info and last_status: + detailed_tasks.append({ + "task_id": task_id, + "type": task_info.get("type", task_summary.get("type", "unknown")), + "name": task_info.get("name", task_summary.get("name", "Unknown")), + "artist": task_info.get("artist", task_summary.get("artist", "")), + "download_type": task_info.get("download_type", task_summary.get("download_type", "unknown")), + "status": last_status.get("status", "unknown"), # Keep summary status for quick access + "last_status_obj": last_status, # Full last status object + "original_request": task_info.get("original_request", {}), + "created_at": task_info.get("created_at", 0), + "timestamp": last_status.get("timestamp", task_info.get("created_at", 0)) + }) + elif task_info: # If last_status is somehow missing, still provide some info + detailed_tasks.append({ + "task_id": task_id, + "type": task_info.get("type", "unknown"), + "name": task_info.get("name", "Unknown"), + "artist": task_info.get("artist", ""), + "download_type": task_info.get("download_type", "unknown"), + "status": "unknown", + "last_status_obj": None, + "original_request": task_info.get("original_request", {}), + "created_at": task_info.get("created_at", 0), + "timestamp": task_info.get("created_at", 0) + }) + + # Sort tasks by creation time (newest first, or by timestamp if creation time is missing) + detailed_tasks.sort(key=lambda x: x.get('timestamp', x.get('created_at', 0)), reverse=True) + + return jsonify(detailed_tasks) + except Exception as e: + logger.error(f"Error in /api/prgs/list: {e}", exc_info=True) + return jsonify({"error": "Failed to retrieve task list"}), 500 @prgs_bp.route('/retry/', methods=['POST']) diff --git a/routes/track.py b/routes/track.py index b48be7b..f1902b9 100755 --- a/routes/track.py +++ b/routes/track.py @@ -7,19 +7,40 @@ import time # For timestamps from routes.utils.celery_queue_manager import download_queue_manager from routes.utils.celery_tasks import store_task_info, store_task_status, ProgressState # For error task creation from urllib.parse import urlparse # for URL validation +from routes.utils.get_info import get_spotify_info # Added import track_bp = Blueprint('track', __name__) @track_bp.route('/download/', methods=['GET']) def handle_download(track_id): # Retrieve essential parameters from the request. - name = request.args.get('name') - artist = request.args.get('artist') + # name = request.args.get('name') # Removed + # artist = request.args.get('artist') # Removed orig_params = request.args.to_dict() # Construct the URL from track_id url = f"https://open.spotify.com/track/{track_id}" orig_params["original_url"] = url # Update original_url to the constructed one + + # Fetch metadata from Spotify + try: + track_info = get_spotify_info(track_id, "track") + if not track_info or not track_info.get('name') or not track_info.get('artists'): + return Response( + json.dumps({"error": f"Could not retrieve metadata for track ID: {track_id}"}), + status=404, + mimetype='application/json' + ) + + name_from_spotify = track_info.get('name') + artist_from_spotify = track_info['artists'][0].get('name') if track_info['artists'] else "Unknown Artist" + + except Exception as e: + return Response( + json.dumps({"error": f"Failed to fetch metadata for track {track_id}: {str(e)}"}), + status=500, + mimetype='application/json' + ) # Validate required parameters if not url: @@ -42,8 +63,8 @@ def handle_download(track_id): task_id = download_queue_manager.add_task({ "download_type": "track", "url": url, - "name": name, - "artist": artist, + "name": name_from_spotify, # Use fetched name + "artist": artist_from_spotify, # Use fetched artist "orig_request": orig_params }) # Removed DuplicateDownloadError handling, add_task now manages this by creating an error task. @@ -53,8 +74,8 @@ def handle_download(track_id): store_task_info(error_task_id, { "download_type": "track", "url": url, - "name": name, - "artist": artist, + "name": name_from_spotify, # Use fetched name + "artist": artist_from_spotify, # Use fetched artist "original_request": orig_params, "created_at": time.time(), "is_submission_error_task": True diff --git a/routes/utils/celery_manager.py b/routes/utils/celery_manager.py index 59d5bba..8e3cb7f 100644 --- a/routes/utils/celery_manager.py +++ b/routes/utils/celery_manager.py @@ -18,9 +18,12 @@ from .celery_tasks import ( get_last_task_status, store_task_status, get_all_tasks as get_all_celery_tasks_info, - cleanup_stale_errors + cleanup_stale_errors, + delayed_delete_task_data ) from .celery_config import get_config_params +# Import history manager +from .history_manager import init_history_db # Configure logging logger = logging.getLogger(__name__) @@ -91,11 +94,73 @@ class CeleryManager: } store_task_status(task_id, error_payload) stale_tasks_count += 1 + + # Schedule deletion for this interrupted task + logger.info(f"Task {task_id} was interrupted. Data scheduled for deletion in 30s.") + delayed_delete_task_data.apply_async( + args=[task_id, "Task interrupted by application restart and auto-cleaned."], + countdown=30 + ) if stale_tasks_count > 0: logger.info(f"Marked {stale_tasks_count} stale tasks as 'error'.") else: - logger.info("No stale tasks found that needed cleanup.") + logger.info("No stale tasks found that needed cleanup (active states).") + + # NEW: Check for tasks that are already terminal but might have missed their cleanup + logger.info("Checking for terminal tasks (COMPLETE, CANCELLED, terminal ERROR) that might have missed cleanup...") + cleaned_during_this_pass = 0 + # `tasks` variable is from `get_all_celery_tasks_info()` called at the beginning of the method + for task_summary in tasks: + task_id = task_summary.get("task_id") + if not task_id: + continue + + last_status_data = get_last_task_status(task_id) + if last_status_data: + current_status_str = last_status_data.get("status") + task_info_details = get_task_info(task_id) # Get full info for download_type etc. + + cleanup_reason = "" + schedule_cleanup = False + + if current_status_str == ProgressState.COMPLETE: + # If a task is COMPLETE (any download_type) and still here, its original scheduled deletion was missed. + logger.warning(f"Task {task_id} ('{task_summary.get('name', 'Unknown')}', type: {task_info_details.get('download_type')}) is COMPLETE and still in Redis. Re-scheduling cleanup.") + cleanup_reason = f"Task ({task_info_details.get('download_type')}) was COMPLETE; re-scheduling auto-cleanup." + schedule_cleanup = True + elif current_status_str == ProgressState.CANCELLED: + logger.warning(f"Task {task_id} ('{task_summary.get('name', 'Unknown')}') is CANCELLED and still in Redis. Re-scheduling cleanup.") + cleanup_reason = "Task was CANCELLED; re-scheduling auto-cleanup." + schedule_cleanup = True + elif current_status_str == ProgressState.ERROR: + can_retry_flag = last_status_data.get("can_retry", False) + # is_submission_error_task and is_duplicate_error_task are flags on task_info, not typically on last_status + is_submission_error = task_info_details.get("is_submission_error_task", False) + is_duplicate_error = task_info_details.get("is_duplicate_error_task", False) + # Check if it's an error state that should have been cleaned up + if not can_retry_flag or is_submission_error or is_duplicate_error or last_status_data.get("status") == ProgressState.ERROR_RETRIED: + # ERROR_RETRIED means the original task is done and should be cleaned. + logger.warning(f"Task {task_id} ('{task_summary.get('name', 'Unknown')}') is in a terminal ERROR state ('{last_status_data.get('error')}') and still in Redis. Re-scheduling cleanup.") + cleanup_reason = f"Task was in terminal ERROR state ('{last_status_data.get('error', 'Unknown error')}'); re-scheduling auto-cleanup." + schedule_cleanup = True + elif current_status_str == ProgressState.ERROR_RETRIED: + # This state itself implies the task is terminal and its data can be cleaned. + logger.warning(f"Task {task_id} ('{task_summary.get('name', 'Unknown')}') is ERROR_RETRIED and still in Redis. Re-scheduling cleanup.") + cleanup_reason = "Task was ERROR_RETRIED; re-scheduling auto-cleanup." + schedule_cleanup = True + + if schedule_cleanup: + delayed_delete_task_data.apply_async( + args=[task_id, cleanup_reason], + countdown=30 # Schedule with 30s delay + ) + cleaned_during_this_pass +=1 + + if cleaned_during_this_pass > 0: + logger.info(f"Re-scheduled cleanup for {cleaned_during_this_pass} terminal tasks that were still in Redis.") + else: + logger.info("No additional terminal tasks found in Redis needing cleanup re-scheduling.") except Exception as e: logger.error(f"Error during stale task cleanup: {e}", exc_info=True) @@ -107,6 +172,9 @@ class CeleryManager: self.running = True + # Initialize history database + init_history_db() + # Clean up stale tasks BEFORE starting/restarting workers self._cleanup_stale_tasks() @@ -221,7 +289,7 @@ class CeleryManager: 'worker', '--loglevel=info', f'--concurrency={new_worker_count}', - '-Q', 'downloads', + '-Q', 'downloads,default', '--logfile=-', # Output logs to stdout '--without-heartbeat', # Reduce log noise '--without-gossip', # Reduce log noise diff --git a/routes/utils/celery_tasks.py b/routes/utils/celery_tasks.py index 9e2d77e..650878f 100644 --- a/routes/utils/celery_tasks.py +++ b/routes/utils/celery_tasks.py @@ -18,6 +18,9 @@ from routes.utils.celery_config import REDIS_URL, REDIS_BACKEND, REDIS_PASSWORD, # Import for playlist watch DB update from routes.utils.watch.db import add_single_track_to_playlist_db +# Import history manager function +from .history_manager import add_entry_to_history + # Initialize Celery app celery_app = Celery('download_tasks', broker=REDIS_URL, @@ -146,6 +149,50 @@ def get_task_info(task_id): logger.error(f"Error getting task info: {e}") return {} +# --- History Logging Helper --- +def _log_task_to_history(task_id, final_status_str, error_msg=None): + """Helper function to gather task data and log it to the history database.""" + try: + task_info = get_task_info(task_id) + last_status_obj = get_last_task_status(task_id) + + if not task_info: + logger.warning(f"History: No task_info found for task_id {task_id}. Cannot log to history.") + return + + # Extract Spotify ID from item URL if possible + spotify_id = None + item_url = task_info.get('url', '') + if item_url: + try: + spotify_id = item_url.split('/')[-1] + # Further validation if it looks like a Spotify ID (e.g., 22 chars, alphanumeric) + if not (spotify_id and len(spotify_id) == 22 and spotify_id.isalnum()): + spotify_id = None # Reset if not a valid-looking ID + except Exception: + spotify_id = None # Ignore errors in parsing + + history_entry = { + 'task_id': task_id, + 'download_type': task_info.get('download_type'), + 'item_name': task_info.get('name'), + 'item_artist': task_info.get('artist'), + 'item_album': task_info.get('album', task_info.get('name') if task_info.get('download_type') == 'album' else None), + 'item_url': item_url, + 'spotify_id': spotify_id, + 'status_final': final_status_str, + 'error_message': error_msg if error_msg else (last_status_obj.get('error') if last_status_obj else None), + 'timestamp_added': task_info.get('created_at', time.time()), + 'timestamp_completed': last_status_obj.get('timestamp', time.time()) if last_status_obj else time.time(), + 'original_request_json': json.dumps(task_info.get('original_request', {})), + 'last_status_obj_json': json.dumps(last_status_obj if last_status_obj else {}) + } + add_entry_to_history(history_entry) + except Exception as e: + logger.error(f"History: Error preparing or logging history for task {task_id}: {e}", exc_info=True) + +# --- End History Logging Helper --- + def cancel_task(task_id): """Cancel a task by its ID""" try: @@ -159,7 +206,16 @@ def cancel_task(task_id): # Try to revoke the Celery task if it hasn't started yet celery_app.control.revoke(task_id, terminate=True, signal='SIGTERM') - logger.info(f"Task {task_id} cancelled by user") + # Log cancellation to history + _log_task_to_history(task_id, 'CANCELLED', "Task cancelled by user") + + # Schedule deletion of task data after 30 seconds + delayed_delete_task_data.apply_async( + args=[task_id, "Task cancelled by user and auto-cleaned."], + countdown=30 + ) + logger.info(f"Task {task_id} cancelled by user. Data scheduled for deletion in 30s.") + return {"status": "cancelled", "task_id": task_id} except Exception as e: logger.error(f"Error cancelling task {task_id}: {e}") @@ -440,17 +496,6 @@ class ProgressTrackingTask(Task): # Store the processed status update store_task_status(task_id, stored_data) - # Immediately delete task info from Redis after marking as complete - if stored_data.get("status") == ProgressState.COMPLETE: - logger.info(f"Task {task_id} completed. Deleting task data from Redis.") - try: - redis_client.delete(f"task:{task_id}:info") - redis_client.delete(f"task:{task_id}:status") - redis_client.delete(f"task:{task_id}:status:next_id") # Also delete the counter - logger.info(f"Successfully deleted Redis data for completed task {task_id}.") - except Exception as e: - logger.error(f"Error deleting Redis data for completed task {task_id}: {e}", exc_info=True) - def _handle_initializing(self, task_id, data, task_info): """Handle initializing status from deezspot""" # Extract relevant fields @@ -789,6 +834,11 @@ class ProgressTrackingTask(Task): # Log summary logger.info(f"Task {task_id} summary: {completed_tracks} completed, {skipped_tracks} skipped, {error_count} errors") + # Schedule deletion for completed multi-track downloads + delayed_delete_task_data.apply_async( + args=[task_id, "Task completed successfully and auto-cleaned."], + countdown=30 # Delay in seconds + ) else: # Generic done for other types @@ -796,20 +846,6 @@ class ProgressTrackingTask(Task): data["status"] = ProgressState.COMPLETE data["message"] = "Download complete" - # Store the processed status update - store_task_status(task_id, data) - - # Immediately delete task info from Redis after marking as complete - if data.get("status") == ProgressState.COMPLETE: - logger.info(f"Task {task_id} ({task_info.get('name', 'Unknown')}) completed. Deleting task data from Redis.") - try: - redis_client.delete(f"task:{task_id}:info") - redis_client.delete(f"task:{task_id}:status") - redis_client.delete(f"task:{task_id}:status:next_id") # Also delete the counter - logger.info(f"Successfully deleted Redis data for completed task {task_id}.") - except Exception as e: - logger.error(f"Error deleting Redis data for completed task {task_id}: {e}", exc_info=True) - # Celery signal handlers @task_prerun.connect def task_prerun_handler(task_id=None, task=None, *args, **kwargs): @@ -834,25 +870,40 @@ def task_prerun_handler(task_id=None, task=None, *args, **kwargs): def task_postrun_handler(task_id=None, task=None, retval=None, state=None, *args, **kwargs): """Signal handler when a task finishes""" try: - # Skip if task is already marked as complete or error in Redis - last_status = get_last_task_status(task_id) - if last_status and last_status.get("status") in [ProgressState.COMPLETE, ProgressState.ERROR]: - return - - # Get task info + # Skip if task is already marked as complete or error in Redis for history logging purposes + last_status_for_history = get_last_task_status(task_id) + if last_status_for_history and last_status_for_history.get("status") in [ProgressState.COMPLETE, ProgressState.ERROR, ProgressState.CANCELLED, "ERROR_RETRIED", "ERROR_AUTO_CLEANED"]: + # Check if it was a REVOKED (cancelled) task, if so, ensure it's logged. + if state == states.REVOKED and last_status_for_history.get("status") != ProgressState.CANCELLED: + logger.info(f"Task {task_id} was REVOKED (likely cancelled), logging to history.") + _log_task_to_history(task_id, 'CANCELLED', "Task was revoked/cancelled.") + # else: + # logger.debug(f"History: Task {task_id} already in terminal state {last_status_for_history.get('status')} in Redis. History logging likely handled.") + # return # Do not return here, let the normal status update proceed for Redis if necessary + task_info = get_task_info(task_id) - + current_redis_status = last_status_for_history.get("status") if last_status_for_history else None + # Update task status based on Celery task state if state == states.SUCCESS: - store_task_status(task_id, { - "status": ProgressState.COMPLETE, - "timestamp": time.time(), - "type": task_info.get("type", "unknown"), - "name": task_info.get("name", "Unknown"), - "artist": task_info.get("artist", ""), - "message": "Download completed successfully." - }) + if current_redis_status != ProgressState.COMPLETE: + store_task_status(task_id, { + "status": ProgressState.COMPLETE, + "timestamp": time.time(), + "type": task_info.get("type", "unknown"), + "name": task_info.get("name", "Unknown"), + "artist": task_info.get("artist", ""), + "message": "Download completed successfully." + }) logger.info(f"Task {task_id} completed successfully: {task_info.get('name', 'Unknown')}") + _log_task_to_history(task_id, 'COMPLETED') + + # If the task was a single track, schedule its data for deletion after a delay + if task_info.get("download_type") == "track": + delayed_delete_task_data.apply_async( + args=[task_id, "Task completed successfully and auto-cleaned."], + countdown=30 # Delay in seconds + ) # If from playlist_watch and successful, add track to DB original_request = task_info.get("original_request", {}) @@ -896,24 +947,34 @@ def task_failure_handler(task_id=None, exception=None, traceback=None, *args, ** # Check if we can retry can_retry = retry_count < max_retries - # Update task status to error - error_message_str = str(exception) - store_task_status(task_id, { - "status": ProgressState.ERROR, - "timestamp": time.time(), - "type": task_info.get("type", "unknown"), - "name": task_info.get("name", "Unknown"), - "artist": task_info.get("artist", ""), - "error": error_message_str, - "traceback": str(traceback), - "can_retry": can_retry, - "retry_count": retry_count, - "max_retries": max_retries - }) + # Update task status to error in Redis if not already an error + if last_status and last_status.get("status") != ProgressState.ERROR: + store_task_status(task_id, { + "status": ProgressState.ERROR, + "timestamp": time.time(), + "type": task_info.get("type", "unknown"), + "name": task_info.get("name", "Unknown"), + "artist": task_info.get("artist", ""), + "error": str(exception), + "traceback": str(traceback), + "can_retry": can_retry, + "retry_count": retry_count, + "max_retries": max_retries + }) - logger.error(f"Task {task_id} failed: {error_message_str}") + logger.error(f"Task {task_id} failed: {str(exception)}") + _log_task_to_history(task_id, 'ERROR', str(exception)) + if can_retry: logger.info(f"Task {task_id} can be retried ({retry_count}/{max_retries})") + else: + # If task cannot be retried, schedule its data for deletion + logger.info(f"Task {task_id} failed and cannot be retried. Data scheduled for deletion in 30s.") + delayed_delete_task_data.apply_async( + args=[task_id, f"Task failed ({str(exception)}) and max retries reached. Auto-cleaned."], + countdown=30 + ) + except Exception as e: logger.error(f"Error in task_failure_handler: {e}") @@ -1147,15 +1208,40 @@ def delete_task_data_and_log(task_id, reason="Task data deleted"): try: task_info = get_task_info(task_id) # Get info before deleting last_status = get_last_task_status(task_id) + current_status_val = last_status.get("status") if last_status else None - # Update status to cancelled if it's not already in a terminal state that implies deletion is okay - if not last_status or last_status.get("status") not in [ProgressState.CANCELLED, ProgressState.ERROR_RETRIED, ProgressState.ERROR_AUTO_CLEANED]: + # Determine the final status for Redis before deletion + # The reason passed to this function indicates why it's being deleted. + final_redis_status = ProgressState.ERROR_AUTO_CLEANED # Default for most cleanup scenarios + error_message_for_status = reason + + if reason == "Task completed successfully and auto-cleaned.": + final_redis_status = ProgressState.COMPLETE # It was already complete + error_message_for_status = "Task completed and auto-cleaned." + elif reason == "Task cancelled by user and auto-cleaned.": + final_redis_status = ProgressState.CANCELLED # It was already cancelled + error_message_for_status = "Task cancelled and auto-cleaned." + elif "Task failed" in reason and "max retries reached" in reason: + final_redis_status = ProgressState.ERROR # It was already an error (non-retryable) + error_message_for_status = reason + elif reason == "Task interrupted by application restart and auto-cleaned.": + final_redis_status = ProgressState.ERROR # It was marked as ERROR (interrupted) + error_message_for_status = reason + # Add more specific conditions if needed based on other reasons `delayed_delete_task_data` might be called with. + + # Update Redis status one last time if it's not already reflecting the final intended state for this cleanup. + # This is mainly for cases where cleanup is initiated for tasks not yet in a fully terminal state by other handlers. + if current_status_val not in [ProgressState.COMPLETE, ProgressState.CANCELLED, ProgressState.ERROR_RETRIED, ProgressState.ERROR_AUTO_CLEANED, final_redis_status]: store_task_status(task_id, { - "status": ProgressState.ERROR_AUTO_CLEANED, # Use specific status - "error": reason, + "status": final_redis_status, + "error": error_message_for_status, # Use the reason as the error/message for this status "timestamp": time.time() }) - + # History logging for COMPLETION, CANCELLATION, or definitive ERROR should have occurred when those states were first reached. + # If this cleanup is for a task that *wasn't* in such a state (e.g. stale, still processing), log it now. + if final_redis_status == ProgressState.ERROR_AUTO_CLEANED: + _log_task_to_history(task_id, 'ERROR', error_message_for_status) # Or a more specific status if desired + # Delete Redis keys associated with the task redis_client.delete(f"task:{task_id}:info") redis_client.delete(f"task:{task_id}:status") @@ -1204,4 +1290,12 @@ def cleanup_stale_errors(): return {"status": "complete", "cleaned_count": cleaned_count} except Exception as e: logger.error(f"Error during cleanup_stale_errors: {e}", exc_info=True) - return {"status": "error", "error": str(e)} \ No newline at end of file + return {"status": "error", "error": str(e)} + +@celery_app.task(name="delayed_delete_task_data", queue="default") # Use default queue for utility tasks +def delayed_delete_task_data(task_id, reason): + """ + Celery task to delete task data after a delay. + """ + logger.info(f"Executing delayed deletion for task {task_id}. Reason: {reason}") + delete_task_data_and_log(task_id, reason) \ No newline at end of file diff --git a/routes/utils/get_info.py b/routes/utils/get_info.py index 9b8b49c..da1133d 100644 --- a/routes/utils/get_info.py +++ b/routes/utils/get_info.py @@ -52,15 +52,17 @@ def get_spotify_info(spotify_id, spotify_type, limit=None, offset=None): return Spo.get_album(spotify_id) elif spotify_type == "playlist": return Spo.get_playlist(spotify_id) - elif spotify_type == "artist": + elif spotify_type == "artist_discography": if limit is not None and offset is not None: - return Spo.get_artist(spotify_id, limit=limit, offset=offset) + return Spo.get_artist_discography(spotify_id, limit=limit, offset=offset) elif limit is not None: - return Spo.get_artist(spotify_id, limit=limit) + return Spo.get_artist_discography(spotify_id, limit=limit) elif offset is not None: - return Spo.get_artist(spotify_id, offset=offset) + return Spo.get_artist_discography(spotify_id, offset=offset) else: - return Spo.get_artist(spotify_id) + return Spo.get_artist_discography(spotify_id) + elif spotify_type == "artist": + return Spo.get_artist(spotify_id) elif spotify_type == "episode": return Spo.get_episode(spotify_id) else: diff --git a/routes/utils/history_manager.py b/routes/utils/history_manager.py new file mode 100644 index 0000000..d3c55d7 --- /dev/null +++ b/routes/utils/history_manager.py @@ -0,0 +1,235 @@ +import sqlite3 +import json +import time +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + +HISTORY_DIR = Path('./data/history') +HISTORY_DB_FILE = HISTORY_DIR / 'download_history.db' + +def init_history_db(): + """Initializes the download history database and creates the table if it doesn't exist.""" + try: + HISTORY_DIR.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(HISTORY_DB_FILE) + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS download_history ( + task_id TEXT PRIMARY KEY, + download_type TEXT, + item_name TEXT, + item_artist TEXT, + item_album TEXT, + item_url TEXT, + spotify_id TEXT, + status_final TEXT, -- 'COMPLETED', 'ERROR', 'CANCELLED' + error_message TEXT, + timestamp_added REAL, + timestamp_completed REAL, + original_request_json TEXT, + last_status_obj_json TEXT + ) + """) + conn.commit() + logger.info(f"Download history database initialized at {HISTORY_DB_FILE}") + except sqlite3.Error as e: + logger.error(f"Error initializing download history database: {e}", exc_info=True) + finally: + if conn: + conn.close() + +def add_entry_to_history(history_data: dict): + """Adds or replaces an entry in the download_history table. + + Args: + history_data (dict): A dictionary containing the data for the history entry. + Expected keys match the table columns. + """ + required_keys = [ + 'task_id', 'download_type', 'item_name', 'item_artist', 'item_album', + 'item_url', 'spotify_id', 'status_final', 'error_message', + 'timestamp_added', 'timestamp_completed', 'original_request_json', + 'last_status_obj_json' + ] + # Ensure all keys are present, filling with None if not + for key in required_keys: + history_data.setdefault(key, None) + + conn = None + try: + conn = sqlite3.connect(HISTORY_DB_FILE) + cursor = conn.cursor() + cursor.execute(""" + INSERT OR REPLACE INTO download_history ( + task_id, download_type, item_name, item_artist, item_album, + item_url, spotify_id, status_final, error_message, + timestamp_added, timestamp_completed, original_request_json, + last_status_obj_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + history_data['task_id'], history_data['download_type'], history_data['item_name'], + history_data['item_artist'], history_data['item_album'], history_data['item_url'], + history_data['spotify_id'], history_data['status_final'], history_data['error_message'], + history_data['timestamp_added'], history_data['timestamp_completed'], + history_data['original_request_json'], history_data['last_status_obj_json'] + )) + conn.commit() + logger.info(f"Added/Updated history for task_id: {history_data['task_id']}, status: {history_data['status_final']}") + except sqlite3.Error as e: + logger.error(f"Error adding entry to download history for task_id {history_data.get('task_id')}: {e}", exc_info=True) + except Exception as e: + logger.error(f"Unexpected error adding to history for task_id {history_data.get('task_id')}: {e}", exc_info=True) + finally: + if conn: + conn.close() + +def get_history_entries(limit=25, offset=0, sort_by='timestamp_completed', sort_order='DESC', filters=None): + """Retrieves entries from the download_history table with pagination, sorting, and filtering. + + Args: + limit (int): Maximum number of entries to return. + offset (int): Number of entries to skip (for pagination). + sort_by (str): Column name to sort by. + sort_order (str): 'ASC' or 'DESC'. + filters (dict, optional): A dictionary of column_name: value to filter by. + Currently supports exact matches. + + Returns: + tuple: (list of history entries as dicts, total_count of matching entries) + """ + conn = None + try: + conn = sqlite3.connect(HISTORY_DB_FILE) + conn.row_factory = sqlite3.Row # Access columns by name + cursor = conn.cursor() + + base_query = "FROM download_history" + count_query = "SELECT COUNT(*) " + base_query + select_query = "SELECT * " + base_query + + where_clauses = [] + params = [] + + if filters: + for column, value in filters.items(): + # Basic security: ensure column is a valid one (alphanumeric + underscore) + if column.replace('_', '').isalnum(): + where_clauses.append(f"{column} = ?") + params.append(value) + + if where_clauses: + where_sql = " WHERE " + " AND ".join(where_clauses) + count_query += where_sql + select_query += where_sql + + # Get total count for pagination + cursor.execute(count_query, params) + total_count = cursor.fetchone()[0] + + # Validate sort_by and sort_order to prevent SQL injection + valid_sort_columns = [ + 'task_id', 'download_type', 'item_name', 'item_artist', 'item_album', + 'item_url', 'status_final', 'timestamp_added', 'timestamp_completed' + ] + if sort_by not in valid_sort_columns: + sort_by = 'timestamp_completed' # Default sort + + sort_order_upper = sort_order.upper() + if sort_order_upper not in ['ASC', 'DESC']: + sort_order_upper = 'DESC' + + select_query += f" ORDER BY {sort_by} {sort_order_upper} LIMIT ? OFFSET ?" + params.extend([limit, offset]) + + cursor.execute(select_query, params) + rows = cursor.fetchall() + + # Convert rows to list of dicts + entries = [dict(row) for row in rows] + return entries, total_count + + except sqlite3.Error as e: + logger.error(f"Error retrieving history entries: {e}", exc_info=True) + return [], 0 + finally: + if conn: + conn.close() + +if __name__ == '__main__': + # For testing purposes + logging.basicConfig(level=logging.INFO) + init_history_db() + + sample_data_complete = { + 'task_id': 'test_task_123', + 'download_type': 'track', + 'item_name': 'Test Song', + 'item_artist': 'Test Artist', + 'item_album': 'Test Album', + 'item_url': 'http://spotify.com/track/123', + 'spotify_id': '123', + 'status_final': 'COMPLETED', + 'error_message': None, + 'timestamp_added': time.time() - 3600, + 'timestamp_completed': time.time(), + 'original_request_json': json.dumps({'param1': 'value1'}), + 'last_status_obj_json': json.dumps({'status': 'complete', 'message': 'Finished!'}) + } + add_entry_to_history(sample_data_complete) + + sample_data_error = { + 'task_id': 'test_task_456', + 'download_type': 'album', + 'item_name': 'Another Album', + 'item_artist': 'Another Artist', + 'item_album': 'Another Album', # For albums, item_name and item_album are often the same + 'item_url': 'http://spotify.com/album/456', + 'spotify_id': '456', + 'status_final': 'ERROR', + 'error_message': 'Download failed due to network issue.', + 'timestamp_added': time.time() - 7200, + 'timestamp_completed': time.time() - 60, + 'original_request_json': json.dumps({'param2': 'value2'}), + 'last_status_obj_json': json.dumps({'status': 'error', 'error': 'Network issue'}) + } + add_entry_to_history(sample_data_error) + + # Test updating an entry + updated_data_complete = { + 'task_id': 'test_task_123', + 'download_type': 'track', + 'item_name': 'Test Song (Updated)', + 'item_artist': 'Test Artist', + 'item_album': 'Test Album II', + 'item_url': 'http://spotify.com/track/123', + 'spotify_id': '123', + 'status_final': 'COMPLETED', + 'error_message': None, + 'timestamp_added': time.time() - 3600, + 'timestamp_completed': time.time() + 100, # Updated completion time + 'original_request_json': json.dumps({'param1': 'value1', 'new_param': 'added'}), + 'last_status_obj_json': json.dumps({'status': 'complete', 'message': 'Finished! With update.'}) + } + add_entry_to_history(updated_data_complete) + + print(f"Test entries added/updated in {HISTORY_DB_FILE}") + + print("\nFetching all history entries (default sort):") + entries, total = get_history_entries(limit=5) + print(f"Total entries: {total}") + for entry in entries: + print(entry) + + print("\nFetching history entries (sorted by item_name ASC, limit 2, offset 1):") + entries_sorted, total_sorted = get_history_entries(limit=2, offset=1, sort_by='item_name', sort_order='ASC') + print(f"Total entries (should be same as above): {total_sorted}") + for entry in entries_sorted: + print(entry) + + print("\nFetching history entries with filter (status_final = COMPLETED):") + entries_filtered, total_filtered = get_history_entries(filters={'status_final': 'COMPLETED'}) + print(f"Total COMPLETED entries: {total_filtered}") + for entry in entries_filtered: + print(entry) \ No newline at end of file diff --git a/routes/utils/watch/manager.py b/routes/utils/watch/manager.py index 5ed25e9..560de77 100644 --- a/routes/utils/watch/manager.py +++ b/routes/utils/watch/manager.py @@ -231,7 +231,7 @@ def check_watched_artists(specific_artist_id: str = None): # The 'artist-albums' type for get_spotify_info needs to support pagination params. # And return a list of album objects. logger.debug(f"Artist Watch Manager: Fetching albums for {artist_spotify_id}. Limit: {limit}, Offset: {offset}") - artist_albums_page = get_spotify_info(artist_spotify_id, "artist", limit=limit, offset=offset) + artist_albums_page = get_spotify_info(artist_spotify_id, "artist_discography", limit=limit, offset=offset) if not artist_albums_page or not isinstance(artist_albums_page.get('items'), list): logger.warning(f"Artist Watch Manager: No album items found or invalid format for artist {artist_spotify_id} (name: '{artist_name}') at offset {offset}. Response: {artist_albums_page}") diff --git a/src/js/history.ts b/src/js/history.ts new file mode 100644 index 0000000..ed13c34 --- /dev/null +++ b/src/js/history.ts @@ -0,0 +1,160 @@ +document.addEventListener('DOMContentLoaded', () => { + const historyTableBody = document.getElementById('history-table-body') as HTMLTableSectionElement | null; + const prevButton = document.getElementById('prev-page') as HTMLButtonElement | null; + const nextButton = document.getElementById('next-page') as HTMLButtonElement | null; + const pageInfo = document.getElementById('page-info') as HTMLSpanElement | null; + const limitSelect = document.getElementById('limit-select') as HTMLSelectElement | null; + const statusFilter = document.getElementById('status-filter') as HTMLSelectElement | null; + const typeFilter = document.getElementById('type-filter') as HTMLSelectElement | null; + + let currentPage = 1; + let limit = 25; + let totalEntries = 0; + let currentSortBy = 'timestamp_completed'; + let currentSortOrder = 'DESC'; + + async function fetchHistory(page = 1) { + if (!historyTableBody || !prevButton || !nextButton || !pageInfo || !limitSelect || !statusFilter || !typeFilter) { + console.error('One or more critical UI elements are missing for history page.'); + return; + } + + const offset = (page - 1) * limit; + let apiUrl = `/api/history?limit=${limit}&offset=${offset}&sort_by=${currentSortBy}&sort_order=${currentSortOrder}`; + + const statusVal = statusFilter.value; + if (statusVal) { + apiUrl += `&status_final=${statusVal}`; + } + const typeVal = typeFilter.value; + if (typeVal) { + apiUrl += `&download_type=${typeVal}`; + } + + try { + const response = await fetch(apiUrl); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + renderHistory(data.entries); + totalEntries = data.total_count; + currentPage = Math.floor(offset / limit) + 1; + updatePagination(); + } catch (error) { + console.error('Error fetching history:', error); + if (historyTableBody) { + historyTableBody.innerHTML = 'Error loading history.'; + } + } + } + + function renderHistory(entries: any[]) { + if (!historyTableBody) return; + + historyTableBody.innerHTML = ''; // Clear existing rows + if (!entries || entries.length === 0) { + historyTableBody.innerHTML = 'No history entries found.'; + return; + } + + entries.forEach(entry => { + const row = historyTableBody.insertRow(); + row.insertCell().textContent = entry.item_name || 'N/A'; + row.insertCell().textContent = entry.item_artist || 'N/A'; + row.insertCell().textContent = entry.download_type ? entry.download_type.charAt(0).toUpperCase() + entry.download_type.slice(1) : 'N/A'; + + const statusCell = row.insertCell(); + statusCell.textContent = entry.status_final || 'N/A'; + statusCell.className = `status-${entry.status_final}`; + + row.insertCell().textContent = entry.timestamp_added ? new Date(entry.timestamp_added * 1000).toLocaleString() : 'N/A'; + row.insertCell().textContent = entry.timestamp_completed ? new Date(entry.timestamp_completed * 1000).toLocaleString() : 'N/A'; + + const detailsCell = row.insertCell(); + const detailsButton = document.createElement('button'); + detailsButton.innerHTML = `Details`; + detailsButton.className = 'details-btn btn-icon'; + detailsButton.title = 'Show Details'; + detailsButton.onclick = () => showDetailsModal(entry); + detailsCell.appendChild(detailsButton); + + if (entry.status_final === 'ERROR' && entry.error_message) { + const errorSpan = document.createElement('span'); + errorSpan.textContent = ' (Show Error)'; + errorSpan.className = 'error-message-toggle'; + errorSpan.style.marginLeft = '5px'; + errorSpan.onclick = (e) => { + e.stopPropagation(); // Prevent click on row if any + let errorDetailsDiv = row.querySelector('.error-details') as HTMLElement | null; + if (!errorDetailsDiv) { + errorDetailsDiv = document.createElement('div'); + errorDetailsDiv.className = 'error-details'; + const newCell = row.insertCell(); // This will append to the end of the row + newCell.colSpan = 7; // Span across all columns + newCell.appendChild(errorDetailsDiv); + // Visually, this new cell will be after the 'Details' button cell. + // To make it appear as part of the status cell or below the row, more complex DOM manipulation or CSS would be needed. + } + errorDetailsDiv.textContent = entry.error_message; + // Toggle display by directly manipulating the style of the details div + errorDetailsDiv.style.display = errorDetailsDiv.style.display === 'none' ? 'block' : 'none'; + }; + statusCell.appendChild(errorSpan); + } + }); + } + + function updatePagination() { + if (!pageInfo || !prevButton || !nextButton) return; + + const totalPages = Math.ceil(totalEntries / limit) || 1; + pageInfo.textContent = `Page ${currentPage} of ${totalPages}`; + prevButton.disabled = currentPage === 1; + nextButton.disabled = currentPage === totalPages; + } + + function showDetailsModal(entry: any) { + const details = `Task ID: ${entry.task_id}\n` + + `Type: ${entry.download_type}\n` + + `Name: ${entry.item_name}\n` + + `Artist: ${entry.item_artist}\n` + + `Album: ${entry.item_album || 'N/A'}\n` + + `URL: ${entry.item_url}\n` + + `Spotify ID: ${entry.spotify_id || 'N/A'}\n` + + `Status: ${entry.status_final}\n` + + `Error: ${entry.error_message || 'None'}\n` + + `Added: ${new Date(entry.timestamp_added * 1000).toLocaleString()}\n` + + `Completed/Ended: ${new Date(entry.timestamp_completed * 1000).toLocaleString()}\n\n` + + `Original Request: ${JSON.stringify(JSON.parse(entry.original_request_json || '{}'), null, 2)}\n\n` + + `Last Status Object: ${JSON.stringify(JSON.parse(entry.last_status_obj_json || '{}'), null, 2)}`; + alert(details); + } + + document.querySelectorAll('th[data-sort]').forEach(headerCell => { + headerCell.addEventListener('click', () => { + const sortField = (headerCell as HTMLElement).dataset.sort; + if (!sortField) return; + + if (currentSortBy === sortField) { + currentSortOrder = currentSortOrder === 'ASC' ? 'DESC' : 'ASC'; + } else { + currentSortBy = sortField; + currentSortOrder = 'DESC'; + } + fetchHistory(1); + }); + }); + + prevButton?.addEventListener('click', () => fetchHistory(currentPage - 1)); + nextButton?.addEventListener('click', () => fetchHistory(currentPage + 1)); + limitSelect?.addEventListener('change', (e) => { + limit = parseInt((e.target as HTMLSelectElement).value, 10); + fetchHistory(1); + }); + statusFilter?.addEventListener('change', () => fetchHistory(1)); + typeFilter?.addEventListener('change', () => fetchHistory(1)); + + // Initial fetch + fetchHistory(); +}); \ No newline at end of file diff --git a/src/js/queue.ts b/src/js/queue.ts index ab9fe2d..3f0f594 100644 --- a/src/js/queue.ts +++ b/src/js/queue.ts @@ -162,7 +162,6 @@ export class DownloadQueue { // Load the saved visible count (or default to 10) visibleCount: number; - globalSyncIntervalId: number | null = null; // For the new global sync constructor() { const storedVisibleCount = localStorage.getItem("downloadQueueVisibleCount"); @@ -203,10 +202,9 @@ export class DownloadQueue { // Wait for initDOM to complete before setting up event listeners and loading existing PRG files. this.initDOM().then(() => { this.initEventListeners(); - this.loadExistingPrgFiles().then(() => { // Ensure loadExistingPrgFiles completes - // Start global task list synchronization after initial load - this.startGlobalTaskSync(); - }); + this.loadExistingPrgFiles(); + // Start periodic sync + setInterval(() => this.periodicSyncWithServer(), 10000); // Sync every 10 seconds }); } @@ -391,18 +389,6 @@ export class DownloadQueue { * Adds a new download entry. */ addDownload(item: QueueItem, type: string, prgFile: string, requestUrl: string | null = null, startMonitoring: boolean = false): string { - // Check if an entry with this prgFile already exists - const existingQueueId = this.findQueueIdByPrgFile(prgFile); - if (existingQueueId) { - console.log(`addDownload: Entry for prgFile ${prgFile} already exists with queueId ${existingQueueId}. Ensuring monitoring.`); - const existingEntry = this.queueEntries[existingQueueId]; - if (existingEntry && !existingEntry.hasEnded && startMonitoring && !this.pollingIntervals[existingQueueId]) { - // If it exists, is not ended, needs monitoring, and isn't currently polled, start its individual polling. - this.startDownloadStatusMonitoring(existingQueueId); - } - return existingQueueId; // Return existing ID - } - const queueId = this.generateQueueId(); const entry = this.createQueueEntry(item, type, prgFile, queueId, requestUrl); this.queueEntries[queueId] = entry; @@ -988,16 +974,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) return index >= 0 && index < this.visibleCount; } - findQueueIdByPrgFile(prgFile: string): string | undefined { - for (const queueId in this.queueEntries) { - if (this.queueEntries[queueId].prgFile === prgFile) { - return queueId; - } - } - return undefined; - } - - async cleanupEntry(queueId: string /* Parameter deleteFromServer removed */) { + async cleanupEntry(queueId: string) { const entry = this.queueEntries[queueId]; if (entry) { // Close any polling interval @@ -1023,9 +1000,6 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); } - // The block for deleting from server has been removed. - // console.log(`Entry ${queueId} (${entry.prgFile}) cleaned up from UI and local cache.`); - // Update the queue display this.updateQueueOrder(); } @@ -1319,22 +1293,16 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) // Stop polling this.clearPollingInterval(queueId); - - const statusData = typeof progress === 'object' ? progress : entry.lastStatus; - - if (statusData && (statusData.status === 'complete' || statusData.status === 'done')) { - // For completed tasks, show for 2 seconds then remove from UI only - setTimeout(() => { - this.cleanupEntry(queueId); // Pass only queueId - }, 2000); - } else { - // For other terminal states (error, cancelled), use existing cleanup logic (default 10s) - // The server-side delete for these will be handled by backend mechanisms or specific cancel actions - const cleanupDelay = 10000; - setTimeout(() => { - this.cleanupEntry(queueId); // Pass only queueId - }, cleanupDelay); - } + + // Use 3 seconds cleanup delay for completed, 10 seconds for other terminal states like errors + const cleanupDelay = (progress && typeof progress !== 'number' && (progress.status === 'complete' || progress.status === 'done')) ? 3000 : + (progress && typeof progress !== 'number' && (progress.status === 'cancelled' || progress.status === 'cancel' || progress.status === 'skipped')) ? 20000 : + 10000; // Default for other errors if not caught by the more specific error handler delay + + // Clean up after the appropriate delay + setTimeout(() => { + this.cleanupEntry(queueId); + }, cleanupDelay); } handleInactivity(entry: QueueEntry, queueId: string, logElement: HTMLElement | null) { // Add types @@ -1519,9 +1487,9 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) // Prepare query parameters const queryParams = new URLSearchParams(); - // Add item.name and item.artist only if they are not empty or undefined - if (item.name && item.name.trim() !== '') queryParams.append('name', item.name); - if (item.artist && item.artist.trim() !== '') queryParams.append('artist', item.artist); + // item.name and item.artist are no longer sent as query parameters + // if (item.name && item.name.trim() !== '') queryParams.append('name', item.name); + // if (item.artist && item.artist.trim() !== '') queryParams.append('artist', item.artist); // For artist downloads, include album_type as it may still be needed if (type === 'artist' && albumType) { @@ -1663,202 +1631,107 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) // Clear existing queue entries first to avoid duplicates when refreshing for (const queueId in this.queueEntries) { const entry = this.queueEntries[queueId]; - // Close any active connections this.clearPollingInterval(queueId); - - // Don't remove the entry from DOM - we'll rebuild it entirely delete this.queueEntries[queueId]; } + // Fetch detailed task list from the new endpoint const response = await fetch('/api/prgs/list'); - const prgFiles: string[] = await response.json(); // Add type - - // Sort filenames by the numeric portion (assumes format "type_number.prg"). - prgFiles.sort((a, b) => { - const numA = parseInt(a.split('_')[1]); - const numB = parseInt(b.split('_')[1]); - return numA - numB; - }); + if (!response.ok) { + console.error("Failed to load existing tasks:", response.status, await response.text()); + return; + } + const existingTasks: any[] = await response.json(); // We expect an array of detailed task objects - // Iterate through each PRG file and add it as a dummy queue entry. - for (const prgFile of prgFiles) { - try { - const prgResponse = await fetch(`/api/prgs/${prgFile}`); - if (!prgResponse.ok) continue; - const prgData: StatusData = await prgResponse.json(); // Add type - - // Skip prg files that are marked as cancelled, completed, or interrupted - if (prgData.last_line && - (prgData.last_line.status === "cancel" || - prgData.last_line.status === "cancelled" || - prgData.last_line.status === "interrupted" || - prgData.last_line.status === "complete")) { - // Delete old completed or cancelled PRG files - try { - await fetch(`/api/prgs/delete/${prgFile}`, { method: 'DELETE' }); - console.log(`Cleaned up old PRG file: ${prgFile}`); - } catch (error) { - console.error(`Failed to delete completed/cancelled PRG file ${prgFile}:`, error); - } - continue; + const terminalStates = ['complete', 'done', 'cancelled', 'ERROR_AUTO_CLEANED', 'ERROR_RETRIED', 'cancel', 'interrupted', 'error']; + + for (const taskData of existingTasks) { + const prgFile = taskData.task_id; // Use task_id as prgFile identifier + const lastStatus = taskData.last_status_obj; + const originalRequest = taskData.original_request || {}; + + // Skip adding to UI if the task is already in a terminal state + if (lastStatus && terminalStates.includes(lastStatus.status)) { + console.log(`Skipping UI addition for terminal task ${prgFile}, status: ${lastStatus.status}`); + // Also ensure it's cleaned from local cache if it was there + if (this.queueCache[prgFile]) { + delete this.queueCache[prgFile]; } - - // Check cached status - if we marked it cancelled locally, delete it and skip - const cachedStatus: StatusData | undefined = this.queueCache[prgFile]; // Add type - if (cachedStatus && - (cachedStatus.status === 'cancelled' || - cachedStatus.status === 'cancel' || - cachedStatus.status === 'interrupted' || - cachedStatus.status === 'complete')) { - try { - await fetch(`/api/prgs/delete/${prgFile}`, { method: 'DELETE' }); - console.log(`Cleaned up cached cancelled PRG file: ${prgFile}`); - } catch (error) { - console.error(`Failed to delete cached cancelled PRG file ${prgFile}:`, error); - } - continue; - } - - // Use the enhanced original request info from the first line - const originalRequest = prgData.original_request || {}; - 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: 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) { - const parent = lastLineData.parent; - - if (parent.type === 'album') { - itemType = 'album'; - dummyItem = { - name: parent.title || 'Unknown Album', - artist: parent.artist || 'Unknown Artist', - type: 'album', - url: parent.url || '', - // Keep track of the current track info for progress display - current_track: lastLineData.current_track, - 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 - }; - } else if (parent.type === 'playlist') { - itemType = 'playlist'; - dummyItem = { - name: parent.name || 'Unknown Playlist', - owner: parent.owner || 'Unknown Creator', - type: 'playlist', - url: parent.url || '', - // Keep track of the current track info for progress display - current_track: lastLineData.current_track, - 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 - }; - } - } else { - // Use the explicit display fields if available, or fall back to other fields + continue; + } + + let itemType = taskData.type || originalRequest.type || 'unknown'; + let dummyItem: QueueItem = { + name: taskData.name || originalRequest.name || prgFile, + artist: taskData.artist || originalRequest.artist || '', + type: itemType, + url: originalRequest.url || lastStatus?.url || '', + endpoint: originalRequest.endpoint || '', + download_type: taskData.download_type || originalRequest.download_type || '', + total_tracks: lastStatus?.total_tracks || originalRequest.total_tracks, + current_track: lastStatus?.current_track, + }; + + // If this is a track with a parent from the last_status, adjust item and type + if (lastStatus && lastStatus.type === 'track' && lastStatus.parent) { + const parent = lastStatus.parent; + if (parent.type === 'album') { + itemType = 'album'; dummyItem = { - name: prgData.display_title || originalRequest.display_title || lastLineData.name || lastLineData.song || lastLineData.title || originalRequest.name || prgFile, - artist: prgData.display_artist || originalRequest.display_artist || lastLineData.artist || originalRequest.artist || '', - type: itemType, - url: originalRequest.url || lastLineData.url || '', - endpoint: originalRequest.endpoint || '', - download_type: originalRequest.download_type || '', - // Include any available track info - song: lastLineData.song, - title: lastLineData.title, - total_tracks: typeof lastLineData.total_tracks === 'string' ? parseInt(lastLineData.total_tracks, 10) : lastLineData.total_tracks, - current_track: lastLineData.current_track + name: parent.title || 'Unknown Album', + artist: parent.artist || 'Unknown Artist', + type: 'album', + url: parent.url || '', + total_tracks: parent.total_tracks || lastStatus.total_tracks, + parent: parent }; - }; - - // Check if this is a retry file and get the retry count - let retryCount = 0; - if (prgFile.includes('_retry')) { + } else if (parent.type === 'playlist') { + itemType = 'playlist'; + dummyItem = { + name: parent.name || 'Unknown Playlist', + owner: parent.owner || 'Unknown Creator', + type: 'playlist', + url: parent.url || '', + total_tracks: parent.total_tracks || lastStatus.total_tracks, + parent: parent + }; + } + } + + let retryCount = 0; + if (lastStatus && lastStatus.retry_count) { + retryCount = lastStatus.retry_count; + } else if (prgFile.includes('_retry')) { const retryMatch = prgFile.match(/_retry(\d+)/); if (retryMatch && retryMatch[1]) { retryCount = parseInt(retryMatch[1], 10); - } else if (prgData.last_line && prgData.last_line.retry_count) { - retryCount = prgData.last_line.retry_count; } - } else if (prgData.last_line && prgData.last_line.retry_count) { - retryCount = prgData.last_line.retry_count; - } - - // Build a potential requestUrl from the original information - let requestUrl: string | null = null; // Add type - if (dummyItem.endpoint && dummyItem.url) { - const params = new CustomURLSearchParams(); - params.append('url', dummyItem.url); - - if (dummyItem.name) params.append('name', dummyItem.name); - if (dummyItem.artist) params.append('artist', dummyItem.artist); - - // Add any other parameters from the original request - for (const [key, value] of Object.entries(originalRequest)) { - if (!['url', 'name', 'artist', 'type', 'endpoint', 'download_type', - 'display_title', 'display_type', 'display_artist', 'service'].includes(key)) { - params.append(key, value as string); // Cast value to string - } - } - - requestUrl = `${dummyItem.endpoint}?${params.toString()}`; - } - - // Add to download queue - const queueId = this.generateQueueId(); - const entry = this.createQueueEntry(dummyItem, itemType, prgFile, queueId, requestUrl); - entry.retryCount = retryCount; - - // Set the entry's last status from the PRG file - if (prgData.last_line) { - entry.lastStatus = prgData.last_line; - - // If this is a track that's part of an album/playlist - if (prgData.last_line.parent) { - entry.parentInfo = prgData.last_line.parent; - } - - // Make sure to save the status to the cache for persistence - this.queueCache[prgFile] = prgData.last_line; - - // Apply proper status classes - this.applyStatusClasses(entry, prgData.last_line); - - // Update log display with current info - 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 || '')) { // 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 || '')) { - // Show parent info for non-terminal states - if (entry.parentInfo.type === 'album') { - logElement.textContent = `From album: "${entry.parentInfo.title}"`; - } else if (entry.parentInfo.type === 'playlist') { - logElement.textContent = `From playlist: "${entry.parentInfo.name}" by ${entry.parentInfo.owner}`; - } - } - } - } - - this.queueEntries[queueId] = entry; - } catch (error) { - console.error("Error fetching details for", prgFile, error); } + + const requestUrl = originalRequest.url ? `/api/${itemType}/download/${originalRequest.url.split('/').pop()}?name=${encodeURIComponent(dummyItem.name || '')}&artist=${encodeURIComponent(dummyItem.artist || '')}` : null; + + const queueId = this.generateQueueId(); + const entry = this.createQueueEntry(dummyItem, itemType, prgFile, queueId, requestUrl); + entry.retryCount = retryCount; + + if (lastStatus) { + entry.lastStatus = lastStatus; + if (lastStatus.parent) { + entry.parentInfo = lastStatus.parent; + } + this.queueCache[prgFile] = lastStatus; // Cache the last known status + this.applyStatusClasses(entry, lastStatus); + + const logElement = entry.element.querySelector('.log') as HTMLElement | null; + if (logElement) { + logElement.textContent = this.getStatusMessage(lastStatus); + } + } + this.queueEntries[queueId] = entry; } - // Save updated cache to localStorage localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); - - // After adding all entries, update the queue this.updateQueueOrder(); - - // Start monitoring for all active entries that are visible - // This is the key change to ensure continued status updates after page refresh this.startMonitoringActiveEntries(); } catch (error) { console.error("Error loading existing PRG files:", error); @@ -2028,32 +1901,27 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) if (data.last_line.status === 'cancelled' || data.last_line.status === 'cancel') { console.log('Cleaning up cancelled download immediately'); this.clearPollingInterval(queueId); - this.cleanupEntry(queueId); // Pass only queueId + this.cleanupEntry(queueId); return; // No need to process further } - // For completed tasks, start 2s UI timer - if (data.last_line.status === 'complete' || data.last_line.status === 'done') { - this.clearPollingInterval(queueId); + // Only set up cleanup if this is not an error that we're in the process of retrying + // If status is 'error' but the status message contains 'Retrying', don't clean up + const isRetrying = entry.isRetrying || + (data.last_line.status === 'error' && + entry.element.querySelector('.log')?.textContent?.includes('Retry')); + + if (!isRetrying) { setTimeout(() => { - this.cleanupEntry(queueId); // Pass only queueId - }, 2000); - // Do not return here, allow UI to update to complete state first - } else { - // For other terminal states like 'error' - // Only set up cleanup if this is not an error that we're in the process of retrying - const isRetrying = entry.isRetrying || - (data.last_line.status === 'error' && - entry.element.querySelector('.log')?.textContent?.includes('Retry')); - - if (!isRetrying) { - // Errors will use the handleDownloadCompletion logic which has its own timeout - // this.handleDownloadCompletion(entry, queueId, data.last_line); - // No, we want to ensure polling stops here for errors too if not retrying - this.clearPollingInterval(queueId); - // Existing logic for error display and auto-cleanup (15s) is below - // and cleanupEntry for errors will be called from there or from handleDownloadCompletion - } + // Double-check the entry still exists and has not been retried before cleaning up + const currentEntry = this.queueEntries[queueId]; // Get current entry + if (currentEntry && // Check if currentEntry exists + !currentEntry.isRetrying && + currentEntry.hasEnded) { + this.clearPollingInterval(queueId); + this.cleanupEntry(queueId); + } + }, data.last_line.status === 'complete' || data.last_line.status === 'done' ? 3000 : 5000); // 3s for complete/done, 5s for others } } @@ -2247,7 +2115,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) const closeErrorBtn = errorLogElement.querySelector('.close-error-btn') as HTMLButtonElement | null; if (closeErrorBtn) { closeErrorBtn.addEventListener('click', () => { - this.cleanupEntry(queueId); // Pass only queueId + this.cleanupEntry(queueId); }); } @@ -2273,7 +2141,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) !currentEntryForCleanup.isRetrying) { this.cleanupEntry(queueId); } - }, 15000); + }, 20000); // Changed from 15000 to 20000 } else { // Error UI already exists, just update the message text if it's different if (errorMessageElement.textContent !== errMsg) { @@ -2286,21 +2154,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) // Handle terminal states for non-error cases if (['complete', 'cancel', 'cancelled', 'done', 'skipped'].includes(status)) { entry.hasEnded = true; - // this.handleDownloadCompletion(entry, queueId, statusData); // Already called from fetchDownloadStatus for terminal states - // We need to ensure the 2-second rule for 'complete'/'done' is applied here too, if not already handled - if (status === 'complete' || status === 'done') { - if (!this.pollingIntervals[queueId]) { // Check if polling was already cleared (meaning timeout started) - this.clearPollingInterval(queueId); - setTimeout(() => { - this.cleanupEntry(queueId); // Pass only queueId - }, 2000); - } - } else if (status === 'cancel' || status === 'cancelled' || status === 'skipped') { - // For cancelled or skipped, can cleanup sooner or use existing server delete logic - this.clearPollingInterval(queueId); - this.cleanupEntry(queueId); // Pass only queueId - } - // Errors are handled by their specific block below + this.handleDownloadCompletion(entry, queueId, statusData); } // Cache the status for potential page reloads @@ -2774,79 +2628,127 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) for (const queueId in this.pollingIntervals) { this.clearPollingInterval(queueId); } - if (this.globalSyncIntervalId !== null) { - clearInterval(this.globalSyncIntervalId as number); - this.globalSyncIntervalId = null; - console.log('Stopped global task sync polling.'); - } } - async syncWithBackendTaskList() { + /* New method for periodic server sync */ + async periodicSyncWithServer() { + console.log("Performing periodic sync with server..."); try { const response = await fetch('/api/prgs/list'); if (!response.ok) { - console.error('Failed to fetch backend task list:', response.status); + console.error("Periodic sync: Failed to fetch task list from server", response.status); return; } - const backendTaskIds: string[] = await response.json(); - const backendTaskIdSet = new Set(backendTaskIds); + const serverTasks: any[] = await response.json(); - // console.log('Backend task IDs:', backendTaskIds); - // console.log('Frontend task IDs (prgFiles):', Object.values(this.queueEntries).map(e => e.prgFile)); + const localTaskPrgFiles = new Set(Object.values(this.queueEntries).map(entry => entry.prgFile)); + const serverTaskPrgFiles = new Set(serverTasks.map(task => task.task_id)); - // 1. Add new tasks from backend that are not in frontend - for (const taskId of backendTaskIds) { - if (!this.findQueueIdByPrgFile(taskId)) { - console.log(`Sync: Task ${taskId} found in backend but not frontend. Fetching details.`); - try { - const taskDetailsResponse = await fetch(`/api/prgs/${taskId}`); - if (taskDetailsResponse.ok) { - const taskDetails: StatusData = await taskDetailsResponse.json(); - // Construct a minimal item for addDownload. The actual details will be filled by status updates. - const item: QueueItem = { - name: taskDetails.last_line?.name || taskDetails.last_line?.song || taskDetails.last_line?.title || taskDetails.original_request?.name || taskId, - artist: taskDetails.last_line?.artist || taskDetails.original_request?.artist || '', - type: taskDetails.last_line?.type || taskDetails.original_request?.type || 'unknown' - }; - const requestUrl = taskDetails.original_url || taskDetails.original_request?.url || null; - this.addDownload(item, item.type || 'unknown', taskId, requestUrl, true); // true to start monitoring - } else { - console.warn(`Sync: Failed to fetch details for new task ${taskId} from backend.`); + const terminalStates = ['complete', 'done', 'cancelled', 'ERROR_AUTO_CLEANED', 'ERROR_RETRIED', 'cancel', 'interrupted', 'error']; + + // 1. Add new tasks from server not known locally or update existing ones + for (const serverTask of serverTasks) { + const taskId = serverTask.task_id; // This is the prgFile + const lastStatus = serverTask.last_status_obj; + const originalRequest = serverTask.original_request || {}; + + if (terminalStates.includes(lastStatus?.status)) { + // If server says it's terminal, and we have it locally, ensure it's cleaned up + const localEntry = Object.values(this.queueEntries).find(e => e.prgFile === taskId); + if (localEntry && !localEntry.hasEnded) { + console.log(`Periodic sync: Server task ${taskId} is terminal (${lastStatus.status}), cleaning up local entry.`); + // Use a status object for handleDownloadCompletion + this.handleDownloadCompletion(localEntry, localEntry.uniqueId, lastStatus); + } + continue; // Skip adding terminal tasks to UI if not already there + } + + if (!localTaskPrgFiles.has(taskId)) { + console.log(`Periodic sync: Found new non-terminal task ${taskId} on server. Adding to queue.`); + let itemType = serverTask.type || originalRequest.type || 'unknown'; + let dummyItem: QueueItem = { + name: serverTask.name || originalRequest.name || taskId, + artist: serverTask.artist || originalRequest.artist || '', + type: itemType, + url: originalRequest.url || lastStatus?.url || '', + endpoint: originalRequest.endpoint || '', + download_type: serverTask.download_type || originalRequest.download_type || '', + total_tracks: lastStatus?.total_tracks || originalRequest.total_tracks, + current_track: lastStatus?.current_track, + }; + + if (lastStatus && lastStatus.type === 'track' && lastStatus.parent) { + const parent = lastStatus.parent; + if (parent.type === 'album') { + itemType = 'album'; + dummyItem = { + name: parent.title || 'Unknown Album', + artist: parent.artist || 'Unknown Artist', + type: 'album', url: parent.url || '', + total_tracks: parent.total_tracks || lastStatus.total_tracks, + parent: parent }; + } else if (parent.type === 'playlist') { + itemType = 'playlist'; + dummyItem = { + name: parent.name || 'Unknown Playlist', + owner: parent.owner || 'Unknown Creator', + type: 'playlist', url: parent.url || '', + total_tracks: parent.total_tracks || lastStatus.total_tracks, + parent: parent }; + } + } + const requestUrl = originalRequest.url ? `/api/${itemType}/download/${originalRequest.url.split('/').pop()}?name=${encodeURIComponent(dummyItem.name || '')}&artist=${encodeURIComponent(dummyItem.artist || '')}` : null; + // Add with startMonitoring = true + const queueId = this.addDownload(dummyItem, itemType, taskId, requestUrl, true); + const newEntry = this.queueEntries[queueId]; + if (newEntry && lastStatus) { + // Manually set lastStatus and update UI as addDownload might not have full server info yet + newEntry.lastStatus = lastStatus; + if(lastStatus.parent) newEntry.parentInfo = lastStatus.parent; + this.applyStatusClasses(newEntry, lastStatus); + const logEl = newEntry.element.querySelector('.log') as HTMLElement | null; + if(logEl) logEl.textContent = this.getStatusMessage(lastStatus); + // Ensure polling is active for this newly added item + this.setupPollingInterval(newEntry.uniqueId); + } + } else { + // Task exists locally, check if status needs update from server list + const localEntry = Object.values(this.queueEntries).find(e => e.prgFile === taskId); + if (localEntry && lastStatus && JSON.stringify(localEntry.lastStatus) !== JSON.stringify(lastStatus)) { + if (!localEntry.hasEnded) { + console.log(`Periodic sync: Updating status for existing task ${taskId} from ${localEntry.lastStatus?.status} to ${lastStatus.status}`); + // Create a data object that handleStatusUpdate expects + const updateData: StatusData = { ...serverTask, last_line: lastStatus }; + this.handleStatusUpdate(localEntry.uniqueId, updateData); } - } catch (fetchError) { - console.error(`Sync: Error fetching details for task ${taskId}:`, fetchError); } } } - // 2. Remove stale tasks from frontend that are not in backend active list - const frontendPrgFiles = Object.values(this.queueEntries).map(entry => entry.prgFile); - for (const prgFile of frontendPrgFiles) { - const queueId = this.findQueueIdByPrgFile(prgFile); - if (queueId && !backendTaskIdSet.has(prgFile)) { - const entry = this.queueEntries[queueId]; - // Only remove if it's not already considered ended by frontend (e.g., completed and timer running) - if (entry && !entry.hasEnded) { - console.log(`Sync: Task ${prgFile} (queueId: ${queueId}) found in frontend but not in backend active list. Removing.`); - this.cleanupEntry(queueId); + // 2. Remove local tasks that are no longer on the server or are now terminal on server + for (const localEntry of Object.values(this.queueEntries)) { + if (!serverTaskPrgFiles.has(localEntry.prgFile)) { + if (!localEntry.hasEnded) { + console.log(`Periodic sync: Local task ${localEntry.prgFile} not found on server. Assuming completed/cleaned. Removing.`); + this.cleanupEntry(localEntry.uniqueId); + } + } else { + const serverEquivalent = serverTasks.find(st => st.task_id === localEntry.prgFile); + if (serverEquivalent && serverEquivalent.last_status_obj && terminalStates.includes(serverEquivalent.last_status_obj.status)) { + if (!localEntry.hasEnded) { + console.log(`Periodic sync: Local task ${localEntry.prgFile} is now terminal on server (${serverEquivalent.last_status_obj.status}). Cleaning up.`); + this.handleDownloadCompletion(localEntry, localEntry.uniqueId, serverEquivalent.last_status_obj); + } } } } + + this.updateQueueOrder(); + } catch (error) { - console.error('Error during global task sync:', error); + console.error("Error during periodic sync with server:", error); } } - - startGlobalTaskSync() { - if (this.globalSyncIntervalId !== null) { - clearInterval(this.globalSyncIntervalId as number); - } - this.syncWithBackendTaskList(); // Initial sync - this.globalSyncIntervalId = setInterval(() => { - this.syncWithBackendTaskList(); - }, 5000) as unknown as number; // Poll every 5 seconds - console.log('Started global task sync polling every 5 seconds.'); - } } // Singleton instance diff --git a/static/css/config/config.css b/static/css/config/config.css index 3d11079..f6df3b3 100644 --- a/static/css/config/config.css +++ b/static/css/config/config.css @@ -65,36 +65,6 @@ body { font-weight: bold; } -/* Back button as floating icon - keep this for our floating button */ -.back-button.floating-icon { - position: fixed; - width: 56px; - height: 56px; - bottom: 20px; - left: 20px; - background-color: var(--color-primary); - border-radius: 50%; - box-shadow: var(--shadow-lg); - z-index: 9999; - display: flex; - align-items: center; - justify-content: center; - transition: transform 0.2s ease, background-color 0.2s ease; - text-decoration: none !important; -} - -.back-button.floating-icon:hover { - background-color: var(--color-primary-hover); - transform: scale(1.05); -} - -.back-button.floating-icon img { - width: 24px; - height: 24px; - filter: brightness(0) invert(1); - margin: 0; -} - /* Queue Sidebar for Config Page */ #downloadQueue { position: fixed; diff --git a/static/css/history/history.css b/static/css/history/history.css new file mode 100644 index 0000000..ea4edba --- /dev/null +++ b/static/css/history/history.css @@ -0,0 +1,121 @@ +body { + font-family: sans-serif; + margin: 0; + background-color: #121212; + color: #e0e0e0; +} + +.container { + padding: 20px; + max-width: 1200px; + margin: auto; +} + +h1 { + color: #1DB954; /* Spotify Green */ + text-align: center; +} + +table { + width: 100%; + border-collapse: collapse; + margin-top: 20px; + background-color: #1e1e1e; +} + +th, td { + border: 1px solid #333; + padding: 10px 12px; + text-align: left; +} + +th { + background-color: #282828; + cursor: pointer; +} + +tr:nth-child(even) { + background-color: #222; +} + +.pagination { + margin-top: 20px; + text-align: center; +} + +.pagination button, .pagination select { + padding: 8px 12px; + margin: 0 5px; + background-color: #1DB954; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.pagination button:disabled { + background-color: #555; + cursor: not-allowed; +} + +.filters { + margin-bottom: 20px; + display: flex; + gap: 15px; + align-items: center; +} + +.filters label, .filters select, .filters input { + margin-right: 5px; +} + +.filters select, .filters input { + padding: 8px; + background-color: #282828; + color: #e0e0e0; + border: 1px solid #333; + border-radius: 4px; +} + +.status-COMPLETED { color: #1DB954; font-weight: bold; } +.status-ERROR { color: #FF4136; font-weight: bold; } +.status-CANCELLED { color: #AAAAAA; } + +.error-message-toggle { + cursor: pointer; + color: #FF4136; /* Red for error indicator */ + text-decoration: underline; +} + +.error-details { + display: none; /* Hidden by default */ + white-space: pre-wrap; /* Preserve formatting */ + background-color: #303030; + padding: 5px; + margin-top: 5px; + border-radius: 3px; + font-size: 0.9em; +} + +/* Styling for the Details icon button in the table */ +.details-btn { + background-color: transparent; /* Or a subtle color like #282828 */ + border: none; + border-radius: 50%; /* Make it circular */ + padding: 5px; /* Adjust padding to control size */ + cursor: pointer; + display: inline-flex; /* Important for aligning the image */ + align-items: center; + justify-content: center; + transition: background-color 0.2s ease; +} + +.details-btn img { + width: 16px; /* Icon size */ + height: 16px; + filter: invert(1); /* Make icon white if it's dark, adjust if needed */ +} + +.details-btn:hover { + background-color: #333; /* Darker on hover */ +} \ No newline at end of file diff --git a/static/css/main/base.css b/static/css/main/base.css index 06037c3..12c2968 100644 --- a/static/css/main/base.css +++ b/static/css/main/base.css @@ -158,44 +158,36 @@ a:hover, a:focus { background-color: var(--color-surface-hover); } -/* Floating icons (queue and settings) */ +/* General styles for floating action buttons (FABs) */ .floating-icon { - position: fixed; - width: 56px; - height: 56px; - bottom: 20px; - background-color: var(--color-primary); - border-radius: var(--radius-round); - box-shadow: var(--shadow-lg); - z-index: 9999; - display: flex; - align-items: center; - justify-content: center; - transition: transform 0.2s ease, background-color 0.2s ease; - text-decoration: none !important; + position: fixed; + z-index: 1000; /* Base z-index, can be overridden */ + border-radius: 50%; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); + display: flex; + align-items: center; + justify-content: center; + width: 48px; /* Standard size */ + height: 48px; /* Standard size */ + background-color: #282828; /* Dark background */ + transition: background-color 0.3s ease, transform 0.2s ease; + text-decoration: none !important; /* Ensure no underline for tags */ +} + +.floating-icon:hover { + background-color: #333; /* Slightly lighter on hover */ + transform: scale(1.05); } -.floating-icon:hover, .floating-icon:active { - background-color: var(--color-primary-hover); - transform: scale(1.05); + transform: scale(0.98); } .floating-icon img { - width: 24px; - height: 24px; - filter: brightness(0) invert(1); - margin: 0; -} - -/* Settings icon - bottom left */ -.settings-icon { - left: 20px; -} - -/* Queue icon - bottom right */ -.queue-icon { - right: 20px; + width: 24px; + height: 24px; + filter: invert(1); /* White icon */ + margin: 0; /* Reset any margin if inherited */ } /* Home button */ @@ -221,20 +213,17 @@ a:hover, a:focus { transform: scale(0.98); } -/* When home button is used as a floating button */ -.floating-icon.home-btn { - background-color: var(--color-primary); - padding: 0; - display: flex; - align-items: center; - justify-content: center; -} - -.floating-icon.home-btn img { - width: 24px; - height: 24px; - filter: brightness(0) invert(1); - margin: 0; +/* Styles for buttons that are specifically floating icons (like home button when it's a FAB) */ +/* This ensures that if a .home-btn also has .floating-icon, it gets the correct FAB styling. */ +.home-btn.floating-icon, +.settings-icon.floating-icon, /* If settings button is an or diff --git a/static/html/artist.html b/static/html/artist.html index 6a31561..bc93f87 100644 --- a/static/html/artist.html +++ b/static/html/artist.html @@ -51,6 +51,9 @@ + + History + diff --git a/static/html/config.html b/static/html/config.html index 7616c60..8794495 100644 --- a/static/html/config.html +++ b/static/html/config.html @@ -317,6 +317,9 @@ + + History + Back diff --git a/static/html/history.html b/static/html/history.html new file mode 100644 index 0000000..6a42101 --- /dev/null +++ b/static/html/history.html @@ -0,0 +1,83 @@ + + + + + + Download History + + + + + + + + + +
+

Download History

+ +
+ + + + + +
+ + + + + + + + + + + + + + + + +
NameArtistTypeStatusDate AddedDate Completed/EndedDetails
+ +
+ + + + Home + + + + + + + + + \ No newline at end of file diff --git a/static/html/main.html b/static/html/main.html index ed3cec0..e9a5b66 100755 --- a/static/html/main.html +++ b/static/html/main.html @@ -59,6 +59,9 @@ + + History + Settings diff --git a/static/html/playlist.html b/static/html/playlist.html index de78ec9..de08696 100644 --- a/static/html/playlist.html +++ b/static/html/playlist.html @@ -58,6 +58,9 @@ + + History + diff --git a/static/html/track.html b/static/html/track.html index 3da8c44..4dbb8bd 100644 --- a/static/html/track.html +++ b/static/html/track.html @@ -45,6 +45,9 @@ + + History + diff --git a/static/html/watch.html b/static/html/watch.html index ec30a9f..6462734 100644 --- a/static/html/watch.html +++ b/static/html/watch.html @@ -43,6 +43,9 @@ + + History + Home diff --git a/static/images/history.svg b/static/images/history.svg new file mode 100644 index 0000000..98aeee1 --- /dev/null +++ b/static/images/history.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/static/images/info.svg b/static/images/info.svg new file mode 100644 index 0000000..bbdc061 --- /dev/null +++ b/static/images/info.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file From 3ac8e2c4cb870acdfa54ac07a5c8b64ef8457f99 Mon Sep 17 00:00:00 2001 From: "cool.gitter.not.me.again.duh" Date: Thu, 29 May 2025 18:11:06 -0600 Subject: [PATCH 11/18] .env! --- .env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env b/.env index 8373b07..1ca1a5d 100644 --- a/.env +++ b/.env @@ -1,7 +1,7 @@ # Docker Compose environment variables # Redis connection (external or internal) -REDIS_HOST=localhost +REDIS_HOST=redis REDIS_PORT=6379 REDIS_DB=0 REDIS_PASSWORD=CHANGE_ME From 668647a388907b916812b0333a28333615a869da Mon Sep 17 00:00:00 2001 From: "cool.gitter.not.me.again.duh" Date: Thu, 29 May 2025 18:20:58 -0600 Subject: [PATCH 12/18] hopefully the last one --- .dockerignore | 6 +++++- .env | 13 ------------- .env.example | 20 ++++++++++++++++++++ .gitignore | 3 ++- Dockerfile | 4 ++-- docker-compose.yaml | 2 +- 6 files changed, 30 insertions(+), 18 deletions(-) delete mode 100644 .env create mode 100644 .env.example diff --git a/.dockerignore b/.dockerignore index 6ed6843..617bfc5 100755 --- a/.dockerignore +++ b/.dockerignore @@ -19,4 +19,8 @@ queue_state.json search_demo.py celery_worker.log static/js/* -logs/ \ No newline at end of file +logs/ +.env.example +.env +.venv +data diff --git a/.env b/.env deleted file mode 100644 index 1ca1a5d..0000000 --- a/.env +++ /dev/null @@ -1,13 +0,0 @@ -# Docker Compose environment variables - -# Redis connection (external or internal) -REDIS_HOST=redis -REDIS_PORT=6379 -REDIS_DB=0 -REDIS_PASSWORD=CHANGE_ME - -EXPLICIT_FILTER=false # Set to true to filter out explicit content - -PUID=1000 # User ID for the container -PGID=1000 # Group ID for the container -UMASK=0022 # Optional: Sets the default file permissions for newly created files within the container. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4e50c72 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Docker Compose environment variables# Delete all comments of this when deploying (everything that is ) + +# Redis connection (external or internal) +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_DB=0 +REDIS_PASSWORD=CHANGE_ME + +# Set to true to filter out explicit content + +EXPLICIT_FILTER=false + +# User ID for the container +PUID=1000 + +# Group ID for the container +PGID=1000 + +# Optional: Sets the default file permissions for newly created files within the container. +UMASK=0022 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7f9e133..f8065d8 100755 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,5 @@ logs/spotizerr.log /.venv static/js data -logs/ \ No newline at end of file +logs/ +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 82659d4..6d6a417 100755 --- a/Dockerfile +++ b/Dockerfile @@ -33,8 +33,8 @@ RUN npm install -g typescript RUN tsc # Create necessary directories with proper permissions -RUN mkdir -p downloads config creds logs && \ - chmod 777 downloads config creds logs +RUN mkdir -p downloads data/config data/creds data/watch data/history logs/tasks && \ + chmod -R 777 downloads data logs # Make entrypoint script executable RUN chmod +x entrypoint.sh diff --git a/docker-compose.yaml b/docker-compose.yaml index 44d56d9..3d23341 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -8,7 +8,7 @@ services: - ./logs:/app/logs # <-- Volume for persistent logs ports: - 7171:7171 - image: test + image: cooldockerizer9/spotizerr container_name: spotizerr-app restart: unless-stopped environment: From 1fa2c25e9b308b73d92876fb164be29fb677f213 Mon Sep 17 00:00:00 2001 From: "cool.gitter.not.me.again.duh" Date: Thu, 29 May 2025 18:24:25 -0600 Subject: [PATCH 13/18] Update readme --- README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index dca46fe..cda73e9 100755 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ mkdir spotizerr && cd spotizerr 3. Copy `docker-compose.yml` from this repo. 4. Create required directories: ```bash -mkdir -p creds config downloads logs cache +mkdir -p data/creds data/config data/watch data/history downloads logs/tasks .cache ``` 5. Launch containers: ```bash @@ -263,9 +263,14 @@ SPOTIPY_CACHE_PATH=/app/cache/.cache # Spotify token cache path - API errors: Ensure your Spotify client ID and client secret are correctly entered **Log Locations**: -- Credentials: `./creds/` directory -- Downloads: `./downloads/` directory -- Application logs: `docker logs spotizerr` +- Application Logs: `docker logs spotizerr` (for main app and Celery workers) +- Individual Task Logs: `./logs/tasks/` (inside the container, maps to your volume) +- Credentials: `./data/creds/` +- Configuration Files: `./data/config/` +- Downloaded Music: `./downloads/` +- Watch Feature Database: `./data/watch/` +- Download History Database: `./data/history/` +- Spotify Token Cache: `./.cache/` (if `SPOTIPY_CACHE_PATH` is set to `/app/cache/.cache` and mapped) ## Notes From 9138d3f4cffa2eb7c40ad1d653a85607ff45a811 Mon Sep 17 00:00:00 2001 From: "cool.gitter.not.me.again.duh" Date: Thu, 29 May 2025 20:20:33 -0600 Subject: [PATCH 14/18] improved ui for watching --- requirements.txt | 2 +- routes/artist.py | 1 - routes/utils/celery_tasks.py | 75 ++++-- routes/utils/watch/db.py | 58 +++-- routes/utils/watch/manager.py | 12 +- src/js/artist.ts | 386 ++++++++++++++++++------------- src/js/playlist.ts | 20 +- static/css/artist/artist.css | 163 +++++++++++-- static/css/playlist/playlist.css | 1 + static/css/watch/watch.css | 5 - 10 files changed, 482 insertions(+), 241 deletions(-) diff --git a/requirements.txt b/requirements.txt index 88789c3..30e3410 100755 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ click==8.2.1 click-didyoumean==0.3.1 click-plugins==1.1.1 click-repl==0.3.0 -deezspot @ git+https://github.com/Xoconoch/deezspot-fork-again@0b906e94e774cdeb8557a14a53236c0834fb1a2e +deezspot @ git+https://github.com/Xoconoch/deezspot-fork-again defusedxml==0.7.1 fastapi==0.115.12 Flask==3.1.1 diff --git a/routes/artist.py b/routes/artist.py index d6f0b5d..d585984 100644 --- a/routes/artist.py +++ b/routes/artist.py @@ -123,7 +123,6 @@ def get_artist_info(): ) try: - from routes.utils.get_info import get_spotify_info artist_info = get_spotify_info(spotify_id, "artist_discography") # If artist_info is successfully fetched (it contains album items), diff --git a/routes/utils/celery_tasks.py b/routes/utils/celery_tasks.py index 650878f..6f7001c 100644 --- a/routes/utils/celery_tasks.py +++ b/routes/utils/celery_tasks.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) # Setup Redis and Celery from routes.utils.celery_config import REDIS_URL, REDIS_BACKEND, REDIS_PASSWORD, get_config_params # Import for playlist watch DB update -from routes.utils.watch.db import add_single_track_to_playlist_db +from routes.utils.watch.db import add_single_track_to_playlist_db, add_or_update_album_for_artist # Import history manager function from .history_manager import add_entry_to_history @@ -840,6 +840,41 @@ class ProgressTrackingTask(Task): countdown=30 # Delay in seconds ) + # If from playlist_watch and successful, add track to DB + original_request = task_info.get("original_request", {}) + if original_request.get("source") == "playlist_watch" and task_info.get("download_type") == "track": # ensure it's a track for playlist + playlist_id = original_request.get("playlist_id") + track_item_for_db = original_request.get("track_item_for_db") + + if playlist_id and track_item_for_db and track_item_for_db.get('track'): + logger.info(f"Task {task_id} was from playlist watch for playlist {playlist_id}. Adding track to DB.") + try: + add_single_track_to_playlist_db(playlist_id, track_item_for_db) + except Exception as db_add_err: + logger.error(f"Failed to add track to DB for playlist {playlist_id} after successful download task {task_id}: {db_add_err}", exc_info=True) + else: + logger.warning(f"Task {task_id} was from playlist_watch but missing playlist_id or track_item_for_db for DB update. Original Request: {original_request}") + + # If from artist_watch and successful, update album in DB + if original_request.get("source") == "artist_watch" and task_info.get("download_type") == "album": + artist_spotify_id = original_request.get("artist_spotify_id") + album_data_for_db = original_request.get("album_data_for_db") + + if artist_spotify_id and album_data_for_db and album_data_for_db.get("id"): + album_spotify_id = album_data_for_db.get("id") + logger.info(f"Task {task_id} was from artist watch for artist {artist_spotify_id}, album {album_spotify_id}. Updating album in DB as complete.") + try: + add_or_update_album_for_artist( + artist_spotify_id=artist_spotify_id, + album_data=album_data_for_db, + task_id=task_id, + is_download_complete=True + ) + except Exception as db_update_err: + logger.error(f"Failed to update album {album_spotify_id} in DB for artist {artist_spotify_id} after successful download task {task_id}: {db_update_err}", exc_info=True) + else: + logger.warning(f"Task {task_id} was from artist_watch (album) but missing key data (artist_spotify_id or album_data_for_db) for DB update. Original Request: {original_request}") + else: # Generic done for other types logger.info(f"Task {task_id} completed: {content_type.upper()}") @@ -870,21 +905,16 @@ def task_prerun_handler(task_id=None, task=None, *args, **kwargs): def task_postrun_handler(task_id=None, task=None, retval=None, state=None, *args, **kwargs): """Signal handler when a task finishes""" try: - # Skip if task is already marked as complete or error in Redis for history logging purposes last_status_for_history = get_last_task_status(task_id) if last_status_for_history and last_status_for_history.get("status") in [ProgressState.COMPLETE, ProgressState.ERROR, ProgressState.CANCELLED, "ERROR_RETRIED", "ERROR_AUTO_CLEANED"]: - # Check if it was a REVOKED (cancelled) task, if so, ensure it's logged. if state == states.REVOKED and last_status_for_history.get("status") != ProgressState.CANCELLED: logger.info(f"Task {task_id} was REVOKED (likely cancelled), logging to history.") _log_task_to_history(task_id, 'CANCELLED', "Task was revoked/cancelled.") - # else: - # logger.debug(f"History: Task {task_id} already in terminal state {last_status_for_history.get('status')} in Redis. History logging likely handled.") - # return # Do not return here, let the normal status update proceed for Redis if necessary + # return # Let status update proceed if necessary task_info = get_task_info(task_id) current_redis_status = last_status_for_history.get("status") if last_status_for_history else None - # Update task status based on Celery task state if state == states.SUCCESS: if current_redis_status != ProgressState.COMPLETE: store_task_status(task_id, { @@ -898,16 +928,15 @@ def task_postrun_handler(task_id=None, task=None, retval=None, state=None, *args logger.info(f"Task {task_id} completed successfully: {task_info.get('name', 'Unknown')}") _log_task_to_history(task_id, 'COMPLETED') - # If the task was a single track, schedule its data for deletion after a delay - if task_info.get("download_type") == "track": + if task_info.get("download_type") == "track": # Applies to single track downloads and tracks from playlists/albums delayed_delete_task_data.apply_async( args=[task_id, "Task completed successfully and auto-cleaned."], - countdown=30 # Delay in seconds + countdown=30 ) - # If from playlist_watch and successful, add track to DB original_request = task_info.get("original_request", {}) - if original_request.get("source") == "playlist_watch": + # Handle successful track from playlist watch + if original_request.get("source") == "playlist_watch" and task_info.get("download_type") == "track": playlist_id = original_request.get("playlist_id") track_item_for_db = original_request.get("track_item_for_db") @@ -919,9 +948,29 @@ def task_postrun_handler(task_id=None, task=None, retval=None, state=None, *args logger.error(f"Failed to add track to DB for playlist {playlist_id} after successful download task {task_id}: {db_add_err}", exc_info=True) else: logger.warning(f"Task {task_id} was from playlist_watch but missing playlist_id or track_item_for_db for DB update. Original Request: {original_request}") + + # Handle successful album from artist watch + if original_request.get("source") == "artist_watch" and task_info.get("download_type") == "album": + artist_spotify_id = original_request.get("artist_spotify_id") + album_data_for_db = original_request.get("album_data_for_db") + + if artist_spotify_id and album_data_for_db and album_data_for_db.get("id"): + album_spotify_id = album_data_for_db.get("id") + logger.info(f"Task {task_id} was from artist watch for artist {artist_spotify_id}, album {album_spotify_id}. Updating album in DB as complete.") + try: + add_or_update_album_for_artist( + artist_spotify_id=artist_spotify_id, + album_data=album_data_for_db, + task_id=task_id, + is_download_complete=True + ) + except Exception as db_update_err: + logger.error(f"Failed to update album {album_spotify_id} in DB for artist {artist_spotify_id} after successful download task {task_id}: {db_update_err}", exc_info=True) + else: + logger.warning(f"Task {task_id} was from artist_watch (album) but missing key data (artist_spotify_id or album_data_for_db) for DB update. Original Request: {original_request}") except Exception as e: - logger.error(f"Error in task_postrun_handler: {e}") + logger.error(f"Error in task_postrun_handler: {e}", exc_info=True) @task_failure.connect def task_failure_handler(task_id=None, exception=None, traceback=None, *args, **kwargs): diff --git a/routes/utils/watch/db.py b/routes/utils/watch/db.py index d82129e..225690a 100644 --- a/routes/utils/watch/db.py +++ b/routes/utils/watch/db.py @@ -179,25 +179,31 @@ def get_playlist_track_ids_from_db(playlist_spotify_id: str): return track_ids def add_tracks_to_playlist_db(playlist_spotify_id: str, tracks_data: list): - """Adds or updates a list of tracks in the specified playlist's tracks table in playlists.db.""" + """ + Updates existing tracks in the playlist's DB table to mark them as currently present + in Spotify and updates their last_seen timestamp. Also refreshes metadata. + Does NOT insert new tracks. New tracks are only added upon successful download. + """ table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" if not tracks_data: return current_time = int(time.time()) - tracks_to_insert = [] + tracks_to_update = [] for track_item in tracks_data: track = track_item.get('track') if not track or not track.get('id'): - logger.warning(f"Skipping track due to missing data or ID in playlist {playlist_spotify_id}: {track_item}") + logger.warning(f"Skipping track update due to missing data or ID in playlist {playlist_spotify_id}: {track_item}") continue - # Ensure 'artists' and 'album' -> 'artists' are lists and extract names artist_names = ", ".join([artist['name'] for artist in track.get('artists', []) if artist.get('name')]) album_artist_names = ", ".join([artist['name'] for artist in track.get('album', {}).get('artists', []) if artist.get('name')]) - tracks_to_insert.append(( - track['id'], + # Prepare tuple for UPDATE statement. + # Order: title, artist_names, album_name, album_artist_names, track_number, + # album_spotify_id, duration_ms, added_at_playlist, + # is_present_in_spotify, last_seen_in_spotify, spotify_track_id (for WHERE) + tracks_to_update.append(( track.get('name', 'N/A'), artist_names, track.get('album', {}).get('name', 'N/A'), @@ -205,30 +211,44 @@ def add_tracks_to_playlist_db(playlist_spotify_id: str, tracks_data: list): track.get('track_number'), track.get('album', {}).get('id'), track.get('duration_ms'), - track_item.get('added_at'), # From playlist item - current_time, # added_to_db - 1, # is_present_in_spotify - current_time # last_seen_in_spotify + track_item.get('added_at'), # From playlist item, update if changed + 1, # is_present_in_spotify flag + current_time, # last_seen_in_spotify timestamp + # added_to_db is NOT updated here as this function only updates existing records. + track['id'] # spotify_track_id for the WHERE clause )) - if not tracks_to_insert: - logger.info(f"No valid tracks to insert for playlist {playlist_spotify_id}.") + if not tracks_to_update: + logger.info(f"No valid tracks to prepare for update for playlist {playlist_spotify_id}.") return try: with _get_playlists_db_connection() as conn: # Use playlists connection cursor = conn.cursor() - _create_playlist_tracks_table(playlist_spotify_id) # Ensure table exists + # The table should have been created when the playlist was added to watch + # or when the first track was successfully downloaded. + # _create_playlist_tracks_table(playlist_spotify_id) # Not strictly needed here if table creation is robust elsewhere. + # The fields in SET must match the order of ?s, excluding the last one for WHERE. + # This will only update rows where spotify_track_id matches. cursor.executemany(f""" - INSERT OR REPLACE INTO {table_name} - (spotify_track_id, title, artist_names, album_name, album_artist_names, track_number, album_spotify_id, duration_ms, added_at_playlist, added_to_db, is_present_in_spotify, last_seen_in_spotify) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, tracks_to_insert) + UPDATE {table_name} SET + title = ?, + artist_names = ?, + album_name = ?, + album_artist_names = ?, + track_number = ?, + album_spotify_id = ?, + duration_ms = ?, + added_at_playlist = ?, + is_present_in_spotify = ?, + last_seen_in_spotify = ? + WHERE spotify_track_id = ? + """, tracks_to_update) conn.commit() - logger.info(f"Added/updated {len(tracks_to_insert)} tracks in DB for playlist {playlist_spotify_id} in {PLAYLISTS_DB_PATH}.") + logger.info(f"Attempted to update metadata for {len(tracks_to_update)} tracks from API in DB for playlist {playlist_spotify_id}. Actual rows updated: {cursor.rowcount if cursor.rowcount != -1 else 'unknown'}.") except sqlite3.Error as e: - logger.error(f"Error adding tracks to playlist {playlist_spotify_id} in table {table_name} in {PLAYLISTS_DB_PATH}: {e}", exc_info=True) + logger.error(f"Error updating tracks in playlist {playlist_spotify_id} in table {table_name} in {PLAYLISTS_DB_PATH}: {e}", exc_info=True) # Not raising here to allow other operations to continue if one batch fails. def mark_tracks_as_not_present_in_spotify(playlist_spotify_id: str, track_ids_to_mark: list): diff --git a/routes/utils/watch/manager.py b/routes/utils/watch/manager.py index 560de77..c056e21 100644 --- a/routes/utils/watch/manager.py +++ b/routes/utils/watch/manager.py @@ -322,16 +322,14 @@ def check_watched_artists(specific_artist_id: str = None): task_id_or_none = download_queue_manager.add_task(task_payload, from_watch_job=True) if task_id_or_none: # Task was newly queued - add_or_update_album_for_artist(artist_spotify_id, album_data, task_id=task_id_or_none, is_download_complete=False) - logger.info(f"Artist Watch Manager: Queued download task {task_id_or_none} for new album '{album_name}' from artist '{artist_name}'.") + # REMOVED: add_or_update_album_for_artist(artist_spotify_id, album_data, task_id=task_id_or_none, is_download_complete=False) + # The album will be added/updated in the DB by celery_tasks.py upon successful download completion. + logger.info(f"Artist Watch Manager: Queued download task {task_id_or_none} for new album '{album_name}' from artist '{artist_name}'. DB entry will be created/updated on success.") queued_for_download_count += 1 - # If task_id_or_none is None, it was a duplicate. We can still log/record album_data if needed, but without task_id or as already seen. - # add_or_update_album_for_artist(artist_spotify_id, album_data, task_id=None) # This would just log metadata if not a duplicate. - # The current add_task logic in celery_manager might create an error task for duplicates, - # so we might not need to do anything special here for duplicates apart from not incrementing count. + # If task_id_or_none is None, it was a duplicate. Celery manager handles logging. except Exception as e: - logger.error(f"Artist Watch Manager: Failed to queue/record download for new album {album_id} ('{album_name}') from artist '{artist_name}': {e}", exc_info=True) + logger.error(f"Artist Watch Manager: Failed to queue download for new album {album_id} ('{album_name}') from artist '{artist_name}': {e}", exc_info=True) else: logger.info(f"Artist Watch Manager: Album '{album_name}' ({album_id}) by '{artist_name}' already known in DB (ID found in db_album_ids). Skipping queue.") # Optionally, update its entry (e.g. last_seen, or if details changed), but for now, we only queue new ones. diff --git a/src/js/artist.ts b/src/js/artist.ts index 54bc66d..83d9a76 100644 --- a/src/js/artist.ts +++ b/src/js/artist.ts @@ -76,13 +76,16 @@ document.addEventListener('DOMContentLoaded', () => { // This is done inside renderArtist after button element is potentially created. }); -function renderArtist(artistData: ArtistData, artistId: string) { +async 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'); + // Fetch watch status upfront to avoid race conditions for album button rendering + const isArtistActuallyWatched = await getArtistWatchStatus(artistId); + // Check if explicit filter is enabled const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled(); @@ -107,7 +110,7 @@ function renderArtist(artistData: ArtistData, artistId: string) { // Initialize Watch Button after other elements are rendered const watchArtistBtn = document.getElementById('watchArtistBtn') as HTMLButtonElement | null; if (watchArtistBtn) { - initializeWatchButton(artistId); + initializeWatchButton(artistId, isArtistActuallyWatched); } else { console.warn("Watch artist button not found in HTML."); } @@ -202,8 +205,9 @@ function renderArtist(artistData: ArtistData, artistId: string) { if (groupsContainer) { groupsContainer.innerHTML = ''; - // Determine if the artist is being watched to show/hide management buttons for albums - const isArtistWatched = watchArtistBtn && watchArtistBtn.dataset.watching === 'true'; + // Use the definitively fetched watch status for rendering album buttons + // const isArtistWatched = watchArtistBtn && watchArtistBtn.dataset.watching === 'true'; // Old way + const useThisWatchStatusForAlbums = isArtistActuallyWatched; // New way for (const [groupType, albums] of Object.entries(albumGroups)) { const groupSection = document.createElement('section'); @@ -230,58 +234,75 @@ function renderArtist(artistData: ArtistData, artistId: string) { if (!album) return; const albumElement = document.createElement('div'); albumElement.className = 'album-card'; + albumElement.dataset.albumId = album.id; let albumCardHTML = ` Album cover + class="album-cover ${album.is_locally_known === false ? 'album-missing-in-db' : ''}">
${album.name || 'Unknown Album'}
${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}
`; - - const actionsContainer = document.createElement('div'); - actionsContainer.className = 'album-actions-container'; - - if (!isExplicitFilterEnabled) { - const downloadBtnHTML = ` - - `; - actionsContainer.innerHTML += downloadBtnHTML; - } - - if (isArtistWatched) { - // Initial state is set based on album.is_locally_known - const isKnown = album.is_locally_known === true; - const initialStatus = isKnown ? "known" : "missing"; - const initialIcon = isKnown ? "/static/images/check.svg" : "/static/images/missing.svg"; - const initialTitle = isKnown ? "Click to mark as missing from DB" : "Click to mark as known in DB"; - - const toggleKnownBtnHTML = ` - - `; - actionsContainer.innerHTML += toggleKnownBtnHTML; - } - albumElement.innerHTML = albumCardHTML; - if (actionsContainer.hasChildNodes()) { - albumElement.appendChild(actionsContainer); + + const albumCardActions = document.createElement('div'); + albumCardActions.className = 'album-card-actions'; + + // Persistent Mark as Known/Missing button (if artist is watched) - Appears first (left) + if (useThisWatchStatusForAlbums && album.id) { + const toggleKnownBtn = document.createElement('button'); + toggleKnownBtn.className = 'toggle-known-status-btn persistent-album-action-btn'; + toggleKnownBtn.dataset.albumId = album.id; + + if (album.is_locally_known) { + toggleKnownBtn.dataset.status = 'known'; + toggleKnownBtn.innerHTML = 'Mark as missing'; + toggleKnownBtn.title = 'Mark album as not in local library (Missing)'; + toggleKnownBtn.classList.add('status-known'); // Green + } else { + toggleKnownBtn.dataset.status = 'missing'; + toggleKnownBtn.innerHTML = 'Mark as known'; + toggleKnownBtn.title = 'Mark album as in local library (Known)'; + toggleKnownBtn.classList.add('status-missing'); // Red + } + albumCardActions.appendChild(toggleKnownBtn); // Add to actions container } + + // Persistent Download Button (if not explicit filter) - Appears second (right) + if (!isExplicitFilterEnabled) { + const downloadBtn = document.createElement('button'); + downloadBtn.className = 'download-btn download-btn--circle persistent-download-btn'; + downloadBtn.innerHTML = 'Download album'; + downloadBtn.title = 'Download this album'; + downloadBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + downloadBtn.disabled = true; + downloadBtn.innerHTML = 'Queueing...'; + startDownload(album.id, 'album', { name: album.name, artist: album.artists?.[0]?.name || 'Unknown Artist', type: 'album' }) + .then(() => { + downloadBtn.innerHTML = 'Queued'; + showNotification(`Album '${album.name}' queued for download.`); + downloadQueue.toggleVisibility(true); + }) + .catch(err => { + downloadBtn.disabled = false; + downloadBtn.innerHTML = 'Download album'; + showError(`Failed to queue album: ${err?.message || 'Unknown error'}`); + }); + }); + albumCardActions.appendChild(downloadBtn); // Add to actions container + } + + // Only append albumCardActions if it has any buttons + if (albumCardActions.hasChildNodes()) { + albumElement.appendChild(albumCardActions); + } + albumsListContainer.appendChild(albumElement); }); groupSection.appendChild(albumsListContainer); @@ -311,56 +332,74 @@ function renderArtist(artistData: ArtistData, artistId: string) { if (!album) return; const albumElement = document.createElement('div'); albumElement.className = 'album-card'; + albumElement.dataset.albumId = album.id; // Set dataset for appears_on albums too + let albumCardHTML = ` Album cover + class="album-cover ${album.is_locally_known === false ? 'album-missing-in-db' : ''}">
${album.name || 'Unknown Album'}
${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}
`; - - const actionsContainer = document.createElement('div'); - actionsContainer.className = 'album-actions-container'; - - if (!isExplicitFilterEnabled) { - const downloadBtnHTML = ` - - `; - actionsContainer.innerHTML += downloadBtnHTML; - } - - if (isArtistWatched) { - // Initial state is set based on album.is_locally_known - const isKnown = album.is_locally_known === true; - const initialStatus = isKnown ? "known" : "missing"; - const initialIcon = isKnown ? "/static/images/check.svg" : "/static/images/missing.svg"; - const initialTitle = isKnown ? "Click to mark as missing from DB" : "Click to mark as known in DB"; - - const toggleKnownBtnHTML = ` - - `; - actionsContainer.innerHTML += toggleKnownBtnHTML; - } albumElement.innerHTML = albumCardHTML; - if (actionsContainer.hasChildNodes()) { - albumElement.appendChild(actionsContainer); + + const albumCardActions_AppearsOn = document.createElement('div'); + albumCardActions_AppearsOn.className = 'album-card-actions'; + + // Persistent Mark as Known/Missing button for appearing_on albums (if artist is watched) - Appears first (left) + if (useThisWatchStatusForAlbums && album.id) { + const toggleKnownBtn = document.createElement('button'); + toggleKnownBtn.className = 'toggle-known-status-btn persistent-album-action-btn'; + toggleKnownBtn.dataset.albumId = album.id; + if (album.is_locally_known) { + toggleKnownBtn.dataset.status = 'known'; + toggleKnownBtn.innerHTML = 'Mark as missing'; + toggleKnownBtn.title = 'Mark album as not in local library (Missing)'; + toggleKnownBtn.classList.add('status-known'); // Green + } else { + toggleKnownBtn.dataset.status = 'missing'; + toggleKnownBtn.innerHTML = 'Mark as known'; + toggleKnownBtn.title = 'Mark album as in local library (Known)'; + toggleKnownBtn.classList.add('status-missing'); // Red + } + albumCardActions_AppearsOn.appendChild(toggleKnownBtn); // Add to actions container } + + // Persistent Download Button for appearing_on albums (if not explicit filter) - Appears second (right) + if (!isExplicitFilterEnabled) { + const downloadBtn = document.createElement('button'); + downloadBtn.className = 'download-btn download-btn--circle persistent-download-btn'; + downloadBtn.innerHTML = 'Download album'; + downloadBtn.title = 'Download this album'; + downloadBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + downloadBtn.disabled = true; + downloadBtn.innerHTML = 'Queueing...'; + startDownload(album.id, 'album', { name: album.name, artist: album.artists?.[0]?.name || 'Unknown Artist', type: 'album' }) + .then(() => { + downloadBtn.innerHTML = 'Queued'; + showNotification(`Album '${album.name}' queued for download.`); + downloadQueue.toggleVisibility(true); + }) + .catch(err => { + downloadBtn.disabled = false; + downloadBtn.innerHTML = 'Download album'; + showError(`Failed to queue album: ${err?.message || 'Unknown error'}`); + }); + }); + albumCardActions_AppearsOn.appendChild(downloadBtn); // Add to actions container + } + + // Only append albumCardActions_AppearsOn if it has any buttons + if (albumCardActions_AppearsOn.hasChildNodes()) { + albumElement.appendChild(albumCardActions_AppearsOn); + } + appearingAlbumsListContainer.appendChild(albumElement); }); featuringSection.appendChild(appearingAlbumsListContainer); @@ -410,100 +449,104 @@ function attachGroupDownloadListeners(artistId: string, artistName: string) { } function attachAlbumActionListeners(artistIdForContext: string) { - document.querySelectorAll('.album-download-btn').forEach(btn => { - const button = btn as HTMLButtonElement; - button.addEventListener('click', (e) => { - e.stopPropagation(); - const currentTarget = e.currentTarget as HTMLButtonElement | null; - if (!currentTarget) return; - const itemId = currentTarget.dataset.id || ''; - const name = currentTarget.dataset.name || 'Unknown'; - const type = 'album'; - if (!itemId) { - showError('Could not get album ID for download'); - return; - } - currentTarget.remove(); - downloadQueue.download(itemId, type, { name, type }) - .catch((err: any) => showError('Download failed: ' + (err?.message || 'Unknown error'))); - }); - }); + const groupsContainer = document.getElementById('album-groups'); + if (!groupsContainer) return; - document.querySelectorAll('.toggle-known-status-btn').forEach((btn) => { - btn.addEventListener('click', async (e: Event) => { - e.stopPropagation(); - const button = e.currentTarget as HTMLButtonElement; - const albumId = button.dataset.id || ''; - const artistId = button.dataset.artistId || artistIdForContext; + groupsContainer.addEventListener('click', async (event) => { + const target = event.target as HTMLElement; + const button = target.closest('.toggle-known-status-btn') as HTMLButtonElement | null; + + if (button && button.dataset.albumId) { + const albumId = button.dataset.albumId; const currentStatus = button.dataset.status; - const img = button.querySelector('img'); - - if (!albumId || !artistId || !img) { - showError('Missing data for toggling album status'); - return; - } - + + // Optimistic UI update button.disabled = true; + const originalIcon = button.innerHTML; // Save original icon + button.innerHTML = 'Updating...'; + try { - if (currentStatus === 'missing') { - await handleMarkAlbumAsKnown(artistId, albumId); - button.dataset.status = 'known'; - img.src = '/static/images/check.svg'; - button.title = 'Click to mark as missing from DB'; - } else { - await handleMarkAlbumAsMissing(artistId, albumId); + if (currentStatus === 'known') { + await handleMarkAlbumAsMissing(artistIdForContext, albumId); button.dataset.status = 'missing'; - img.src = '/static/images/missing.svg'; - button.title = 'Click to mark as known in DB'; + button.innerHTML = 'Mark as known'; // Update to missing.svg + button.title = 'Mark album as in local library (Known)'; + button.classList.remove('status-known'); + button.classList.add('status-missing'); + const albumCard = button.closest('.album-card') as HTMLElement | null; + if (albumCard) { + const coverImg = albumCard.querySelector('.album-cover') as HTMLImageElement | null; + if (coverImg) coverImg.classList.add('album-missing-in-db'); + } + showNotification(`Album marked as missing from local library.`); + } else { + await handleMarkAlbumAsKnown(artistIdForContext, albumId); + button.dataset.status = 'known'; + button.innerHTML = 'Mark as missing'; // Update to check.svg + button.title = 'Mark album as not in local library (Missing)'; + button.classList.remove('status-missing'); + button.classList.add('status-known'); + const albumCard = button.closest('.album-card') as HTMLElement | null; + if (albumCard) { + const coverImg = albumCard.querySelector('.album-cover') as HTMLImageElement | null; + if (coverImg) coverImg.classList.remove('album-missing-in-db'); + } + showNotification(`Album marked as present in local library.`); } } catch (error) { + console.error('Failed to update album status:', error); showError('Failed to update album status. Please try again.'); + // Revert UI on error + button.dataset.status = currentStatus; // Revert status + button.innerHTML = originalIcon; // Revert icon + // Revert card style if needed (though if API failed, actual state is unchanged) + } finally { + button.disabled = false; // Re-enable button } - button.disabled = false; - }); + } }); } async function handleMarkAlbumAsKnown(artistId: string, albumId: string) { - try { - const response = await fetch(`/api/artist/watch/${artistId}/albums`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify([albumId]), - }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || `HTTP error! status: ${response.status}`); - } - const result = await response.json(); - showNotification(result.message || 'Album marked as known.'); - } catch (error: any) { - showError(`Failed to mark album as known: ${error.message}`); - throw error; // Re-throw for the caller to handle button state if needed + // Ensure albumId is a string and not undefined. + if (!albumId || typeof albumId !== 'string') { + console.error('Invalid albumId provided to handleMarkAlbumAsKnown:', albumId); + throw new Error('Invalid album ID.'); } + const response = await fetch(`/api/artist/watch/${artistId}/albums`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify([albumId]) // API expects an array of album IDs + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: 'Failed to mark album as known.' })); + throw new Error(errorData.message || `HTTP error! status: ${response.status}`); + } + return response.json(); } async function handleMarkAlbumAsMissing(artistId: string, albumId: string) { - try { - const response = await fetch(`/api/artist/watch/${artistId}/albums`, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify([albumId]), - }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || `HTTP error! status: ${response.status}`); - } - const result = await response.json(); - showNotification(result.message || 'Album marked as missing.'); - } catch (error: any) { - showError(`Failed to mark album as missing: ${error.message}`); - throw error; // Re-throw + // Ensure albumId is a string and not undefined. + if (!albumId || typeof albumId !== 'string') { + console.error('Invalid albumId provided to handleMarkAlbumAsMissing:', albumId); + throw new Error('Invalid album ID.'); } + const response = await fetch(`/api/artist/watch/${artistId}/albums`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify([albumId]) // API expects an array of album IDs + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: 'Failed to mark album as missing.' })); + throw new Error(errorData.message || `HTTP error! status: ${response.status}`); + } + // For DELETE, Spotify often returns 204 No Content, or we might return custom JSON. + // If expecting JSON: + // return response.json(); + // If handling 204 or simple success message: + const result = await response.json(); // Assuming the backend sends a JSON response + console.log('Mark as missing result:', result); + return result; } // Add startDownload function (similar to track.js and main.js) @@ -619,20 +662,20 @@ function updateWatchButton(artistId: string, isWatching: boolean) { } } -async function initializeWatchButton(artistId: string) { +async function initializeWatchButton(artistId: string, initialIsWatching: boolean) { const watchArtistBtn = document.getElementById('watchArtistBtn') as HTMLButtonElement | null; const syncArtistBtn = document.getElementById('syncArtistBtn') as HTMLButtonElement | null; if (!watchArtistBtn) return; try { - watchArtistBtn.disabled = true; // Disable while fetching status - if (syncArtistBtn) syncArtistBtn.disabled = true; // Also disable sync button initially + watchArtistBtn.disabled = true; + if (syncArtistBtn) syncArtistBtn.disabled = true; - const isWatching = await getArtistWatchStatus(artistId); - updateWatchButton(artistId, isWatching); + // const isWatching = await getArtistWatchStatus(artistId); // No longer fetch here, use parameter + updateWatchButton(artistId, initialIsWatching); // Use passed status watchArtistBtn.disabled = false; - if (syncArtistBtn) syncArtistBtn.disabled = !(watchArtistBtn.dataset.watching === 'true'); // Corrected logic + if (syncArtistBtn) syncArtistBtn.disabled = !(watchArtistBtn.dataset.watching === 'true'); watchArtistBtn.addEventListener('click', async () => { const currentlyWatching = watchArtistBtn.dataset.watching === 'true'; @@ -642,15 +685,22 @@ async function initializeWatchButton(artistId: string) { if (currentlyWatching) { await unwatchArtist(artistId); updateWatchButton(artistId, false); + // Re-fetch and re-render artist data + const newArtistData = await (await fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}`)).json() as ArtistData; + renderArtist(newArtistData, artistId); } else { await watchArtist(artistId); updateWatchButton(artistId, true); + // Re-fetch and re-render artist data + const newArtistData = await (await fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}`)).json() as ArtistData; + renderArtist(newArtistData, artistId); } } catch (error) { - updateWatchButton(artistId, currentlyWatching); + // On error, revert button to its state before the click attempt + updateWatchButton(artistId, currentlyWatching); } watchArtistBtn.disabled = false; - if (syncArtistBtn) syncArtistBtn.disabled = !(watchArtistBtn.dataset.watching === 'true'); // Corrected logic + if (syncArtistBtn) syncArtistBtn.disabled = !(watchArtistBtn.dataset.watching === 'true'); }); // Add event listener for the sync button @@ -675,8 +725,10 @@ async function initializeWatchButton(artistId: string) { } catch (error) { if (watchArtistBtn) watchArtistBtn.disabled = false; - if (syncArtistBtn) syncArtistBtn.disabled = true; // Keep sync disabled on error - updateWatchButton(artistId, false); + if (syncArtistBtn) syncArtistBtn.disabled = true; + updateWatchButton(artistId, false); // On error fetching initial status (though now it's passed) + // This line might be less relevant if initialIsWatching is guaranteed by caller + // but as a fallback it sets to a non-watching state. } } diff --git a/src/js/playlist.ts b/src/js/playlist.ts index 322a2d1..c2873fe 100644 --- a/src/js/playlist.ts +++ b/src/js/playlist.ts @@ -664,10 +664,16 @@ async function watchPlaylist(playlistId: string) { throw new Error(errorData.error || 'Failed to watch playlist'); } updateWatchButtons(true, playlistId); - showNotification(`Playlist added to watchlist. It will be synced shortly.`); + // Re-fetch and re-render playlist data + const newPlaylistInfoResponse = await fetch(`/api/playlist/info?id=${encodeURIComponent(playlistId)}`); + if (!newPlaylistInfoResponse.ok) throw new Error('Failed to re-fetch playlist info after watch.'); + const newPlaylistData = await newPlaylistInfoResponse.json() as Playlist; + renderPlaylist(newPlaylistData); + + showNotification(`Playlist added to watchlist. Tracks are being updated.`); } catch (error: any) { showError(`Error watching playlist: ${error.message}`); - if (watchBtn) watchBtn.disabled = false; + if (watchBtn) watchBtn.disabled = false; // Re-enable on error before potential UI revert } } @@ -685,10 +691,16 @@ async function unwatchPlaylist(playlistId: string) { throw new Error(errorData.error || 'Failed to unwatch playlist'); } updateWatchButtons(false, playlistId); - showNotification('Playlist removed from watchlist.'); + // Re-fetch and re-render playlist data + const newPlaylistInfoResponse = await fetch(`/api/playlist/info?id=${encodeURIComponent(playlistId)}`); + if (!newPlaylistInfoResponse.ok) throw new Error('Failed to re-fetch playlist info after unwatch.'); + const newPlaylistData = await newPlaylistInfoResponse.json() as Playlist; + renderPlaylist(newPlaylistData); + + showNotification('Playlist removed from watchlist. Track statuses updated.'); } catch (error: any) { showError(`Error unwatching playlist: ${error.message}`); - if (watchBtn) watchBtn.disabled = false; + if (watchBtn) watchBtn.disabled = false; // Re-enable on error before potential UI revert } } diff --git a/static/css/artist/artist.css b/static/css/artist/artist.css index 3c829d9..a4eaa11 100644 --- a/static/css/artist/artist.css +++ b/static/css/artist/artist.css @@ -484,49 +484,164 @@ a:focus { /* Toggle Known Status Button for Tracks/Albums */ .toggle-known-status-btn { - width: 32px; - height: 32px; - padding: 0; - border-radius: 50%; - border: none; - display: inline-flex; + background-color: transparent; + border: 1px solid var(--color-text-secondary); + color: var(--color-text-secondary); + padding: 5px; + border-radius: 50%; /* Make it circular */ + cursor: pointer; + display: flex; align-items: center; justify-content: center; - cursor: pointer; - transition: background-color 0.2s ease, transform 0.2s ease; - margin-left: 0.5rem; /* Spacing from other buttons if any */ + width: 30px; /* Fixed size */ + height: 30px; /* Fixed size */ + transition: background-color 0.2s, border-color 0.2s, color 0.2s, opacity 0.2s; /* Added opacity */ + /* opacity: 0; Initially hidden, JS will make it visible if artist is watched via persistent-album-action-btn */ } .toggle-known-status-btn img { - width: 18px; /* Adjust icon size as needed */ - height: 18px; - filter: brightness(0) invert(1); /* White icon */ + width: 16px; /* Adjust icon size */ + height: 16px; + filter: brightness(0) invert(1); /* Make icon white consistently */ + margin: 0; /* Ensure no accidental margin for centering */ +} + +.toggle-known-status-btn:hover { + border-color: var(--color-primary); + background-color: rgba(var(--color-primary-rgb), 0.1); } .toggle-known-status-btn[data-status="known"] { - background-color: #28a745; /* Green for known/available */ + /* Optional: specific styles if it's already known, e.g., a slightly different border */ + border-color: var(--color-success); /* Green border for known items */ } -.toggle-known-status-btn[data-status="known"]:hover { - background-color: #218838; /* Darker green on hover */ +.toggle-known-status-btn[data-status="known"]:hover img { + /* REMOVE THE LINE BELOW THIS COMMENT */ + /* filter: invert(20%) sepia(100%) saturate(500%) hue-rotate(330deg); Removed */ } .toggle-known-status-btn[data-status="missing"] { - background-color: #dc3545; /* Red for missing */ + /* Optional: specific styles if it's missing, e.g., a warning color */ + border-color: var(--color-warning); /* Orange border for missing items */ } -.toggle-known-status-btn[data-status="missing"]:hover { - background-color: #c82333; /* Darker red on hover */ +.toggle-known-status-btn[data-status="missing"]:hover img { + /* REMOVE THE LINE BELOW THIS COMMENT */ + /* filter: invert(60%) sepia(100%) saturate(500%) hue-rotate(80deg); Removed */ } .toggle-known-status-btn:active { transform: scale(0.95); } -.album-actions-container { - display: flex; - align-items: center; - /* If you want buttons at the bottom of the card or specific positioning, adjust here */ - /* For now, they will flow naturally. Adding padding if needed. */ - padding-top: 0.5rem; +/* Ensure album download button also fits well within actions container */ +.album-actions-container .album-download-btn { + width: 30px; + height: 30px; + padding: 5px; /* Ensure padding doesn't make it too big */ +} + +.album-actions-container .album-download-btn img { + width: 16px; + height: 16px; +} + +/* Album actions container */ +.album-actions-container { + /* position: absolute; */ /* No longer needed if buttons are positioned individually */ + /* bottom: 8px; */ + /* right: 8px; */ + /* display: flex; */ + /* gap: 8px; */ + /* background-color: rgba(0, 0, 0, 0.6); */ + /* padding: 5px; */ + /* border-radius: var(--radius-sm); */ + /* opacity: 0; */ /* Ensure it doesn't hide buttons if it still wraps them elsewhere */ + /* transition: opacity 0.2s ease-in-out; */ + display: none; /* Hide this container if it solely relied on hover and now buttons are persistent */ +} + +/* .album-card:hover .album-actions-container { */ + /* opacity: 1; */ /* Remove this hover effect */ +/* } */ + +/* Album card actions container - for persistent buttons at the bottom */ +.album-card-actions { + display: flex; + justify-content: space-between; /* Pushes children to ends */ + align-items: center; + padding: 8px; /* Spacing around the buttons */ + border-top: 1px solid var(--color-surface-darker, #2a2a2a); /* Separator line */ + /* Ensure it takes up full width of the card if not already */ + width: 100%; +} + +/* Persistent action button (e.g., toggle known/missing) on album card - BOTTOM-LEFT */ +.persistent-album-action-btn { + /* position: absolute; */ /* No longer absolute */ + /* bottom: 8px; */ + /* left: 8px; */ + /* z-index: 2; */ + opacity: 1; /* Ensure it is visible */ + /* Specific margin if needed, but flexbox space-between should handle it */ + margin: 0; /* Reset any previous margins */ +} + +/* Persistent download button on album card - BOTTOM-RIGHT */ +.persistent-download-btn { + /* position: absolute; */ /* No longer absolute */ + /* bottom: 8px; */ + /* right: 8px; */ + /* z-index: 2; */ + opacity: 1; /* Ensure it is visible */ + /* Specific margin if needed, but flexbox space-between should handle it */ + margin: 0; /* Reset any previous margins */ +} + +.album-cover.album-missing-in-db { + border: 3px dashed var(--color-warning); /* Example: orange dashed border */ + opacity: 0.7; +} + +/* NEW STYLES FOR BUTTON STATES */ +.persistent-album-action-btn.status-missing { + background-color: #d9534f; /* Bootstrap's btn-danger red */ + border-color: #d43f3a; +} + +.persistent-album-action-btn.status-missing:hover { + background-color: #c9302c; + border-color: #ac2925; +} + +/* Ensure icon is white on colored background */ +.persistent-album-action-btn.status-missing img { + filter: brightness(0) invert(1); +} + +.persistent-album-action-btn.status-known { + background-color: #5cb85c; /* Bootstrap's btn-success green */ + border-color: #4cae4c; +} + +.persistent-album-action-btn.status-known:hover { + background-color: #449d44; + border-color: #398439; +} + +/* Ensure icon is white on colored background */ +.persistent-album-action-btn.status-known img { + filter: brightness(0) invert(1); +} +/* END OF NEW STYLES */ + +/* Spinning Icon Animation */ +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(-360deg); } +} + +.icon-spin { + animation: spin 1s linear infinite; } diff --git a/static/css/playlist/playlist.css b/static/css/playlist/playlist.css index 3b04807..7511d07 100644 --- a/static/css/playlist/playlist.css +++ b/static/css/playlist/playlist.css @@ -389,6 +389,7 @@ a:focus { height: 20px; filter: brightness(0) invert(1); /* Ensure the icon appears white */ display: block; + margin: 0; /* Explicitly remove any margin */ } /* Hover and active states for the circular download button */ diff --git a/static/css/watch/watch.css b/static/css/watch/watch.css index 5c4215b..b4e9598 100644 --- a/static/css/watch/watch.css +++ b/static/css/watch/watch.css @@ -172,11 +172,6 @@ body { border: none; } -.item-actions .check-item-now-btn, -.item-actions .unwatch-item-btn { - /* Shared properties are in .item-actions .btn-icon */ -} - .item-actions .check-item-now-btn { background-color: var(--color-accent-green); } From d975182a662c55efa5324704e4cc6685d9e15160 Mon Sep 17 00:00:00 2001 From: "cool.gitter.not.me.again.duh" Date: Thu, 29 May 2025 21:00:45 -0600 Subject: [PATCH 15/18] updated styles --- static/css/artist/artist.css | 10 ---------- static/css/watch/watch.css | 1 + 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/static/css/artist/artist.css b/static/css/artist/artist.css index a4eaa11..62f6723 100644 --- a/static/css/artist/artist.css +++ b/static/css/artist/artist.css @@ -516,21 +516,11 @@ a:focus { border-color: var(--color-success); /* Green border for known items */ } -.toggle-known-status-btn[data-status="known"]:hover img { - /* REMOVE THE LINE BELOW THIS COMMENT */ - /* filter: invert(20%) sepia(100%) saturate(500%) hue-rotate(330deg); Removed */ -} - .toggle-known-status-btn[data-status="missing"] { /* Optional: specific styles if it's missing, e.g., a warning color */ border-color: var(--color-warning); /* Orange border for missing items */ } -.toggle-known-status-btn[data-status="missing"]:hover img { - /* REMOVE THE LINE BELOW THIS COMMENT */ - /* filter: invert(60%) sepia(100%) saturate(500%) hue-rotate(80deg); Removed */ -} - .toggle-known-status-btn:active { transform: scale(0.95); } diff --git a/static/css/watch/watch.css b/static/css/watch/watch.css index b4e9598..f62b7c3 100644 --- a/static/css/watch/watch.css +++ b/static/css/watch/watch.css @@ -111,6 +111,7 @@ body { display: -webkit-box; -webkit-line-clamp: 2; /* Limit to 2 lines */ -webkit-box-orient: vertical; + line-clamp: 2; overflow: hidden; text-overflow: ellipsis; min-height: 2.4em; /* Reserve space for two lines */ From 9d6ad8e62258eba6e812145efeeb96431d722901 Mon Sep 17 00:00:00 2001 From: "cool.gitter.not.me.again.duh" Date: Thu, 29 May 2025 21:52:46 -0600 Subject: [PATCH 16/18] correct css again --- src/js/watch.ts | 2 +- static/css/watch/watch.css | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/js/watch.ts b/src/js/watch.ts index b93d136..b5beb11 100644 --- a/src/js/watch.ts +++ b/src/js/watch.ts @@ -535,7 +535,7 @@ function showEmptyState(show: boolean) { async function unwatchItem(itemId: string, itemType: 'artist' | 'playlist', buttonElement: HTMLButtonElement, cardElement: HTMLElement) { const originalButtonContent = buttonElement.innerHTML; buttonElement.disabled = true; - buttonElement.innerHTML = 'Unwatching...'; // Assuming a small loader icon + buttonElement.innerHTML = 'Unwatching...'; // Assuming a small loader icon const endpoint = `/api/${itemType}/watch/${itemId}`; diff --git a/static/css/watch/watch.css b/static/css/watch/watch.css index f62b7c3..1a33a65 100644 --- a/static/css/watch/watch.css +++ b/static/css/watch/watch.css @@ -343,4 +343,17 @@ body { .notification-toast.hide { opacity: 0; transform: translateY(100%); /* Slide down for exit, or could keep translateX if preferred */ +} + +@keyframes spin-counter-clockwise { + from { + transform: rotate(0deg); + } + to { + transform: rotate(-360deg); + } +} + +.spin-counter-clockwise { + animation: spin-counter-clockwise 1s linear infinite; } \ No newline at end of file From 00164efc1fffc48c43b476863904173de27bb246 Mon Sep 17 00:00:00 2001 From: "cool.gitter.not.me.again.duh" Date: Thu, 29 May 2025 22:10:21 -0600 Subject: [PATCH 17/18] correct docker compose --- docker-compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 3d23341..e554adf 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -8,7 +8,7 @@ services: - ./logs:/app/logs # <-- Volume for persistent logs ports: - 7171:7171 - image: cooldockerizer9/spotizerr + image: cooldockerizer93/spotizerr container_name: spotizerr-app restart: unless-stopped environment: From 8f96f227fd7f01d16c0a7c369de82e1323f9b253 Mon Sep 17 00:00:00 2001 From: "cool.gitter.not.me.again.duh" Date: Thu, 29 May 2025 22:18:26 -0600 Subject: [PATCH 18/18] remove .env --- .env | 18 ------------------ .env.example | 1 - 2 files changed, 19 deletions(-) delete mode 100644 .env diff --git a/.env b/.env deleted file mode 100644 index 399a6a3..0000000 --- a/.env +++ /dev/null @@ -1,18 +0,0 @@ -# Docker Compose environment variables - -# Redis connection (external or internal) -REDIS_HOST=redis -REDIS_PORT=6379 -REDIS_DB=0 -REDIS_PASSWORD=CHANGE_ME - -# Optional: Redis connection details -EXPLICIT_FILTER=false - -# User ID for the container -PUID=1000 -# Group ID for the container -PGID=1000 -# Optional: Sets the default file permissions for newly created files within the container. -UMASK=0022 - diff --git a/.env.example b/.env.example index 4e50c72..20ecd23 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,6 @@ REDIS_DB=0 REDIS_PASSWORD=CHANGE_ME # Set to true to filter out explicit content - EXPLICIT_FILTER=false # User ID for the container