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