improved ui for watching

This commit is contained in:
cool.gitter.not.me.again.duh
2025-05-29 20:20:33 -06:00
parent 1fa2c25e9b
commit 9138d3f4cf
10 changed files with 482 additions and 241 deletions

View File

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

View File

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

View File

@@ -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")
@@ -920,8 +949,28 @@ def task_postrun_handler(task_id=None, task=None, retval=None, state=None, *args
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):

View File

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

View File

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

View File

@@ -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 = `
<a href="/album/${album.id || ''}" class="album-link">
<img src="${album.images?.[1]?.url || album.images?.[0]?.url || '/static/images/placeholder.jpg'}"
alt="Album cover"
class="album-cover">
class="album-cover ${album.is_locally_known === false ? 'album-missing-in-db' : ''}">
</a>
<div class="album-info">
<div class="album-title">${album.name || 'Unknown Album'}</div>
<div class="album-artist">${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}</div>
</div>
`;
const actionsContainer = document.createElement('div');
actionsContainer.className = 'album-actions-container';
if (!isExplicitFilterEnabled) {
const downloadBtnHTML = `
<button class="download-btn download-btn--circle album-download-btn"
data-id="${album.id || ''}"
data-type="${album.album_type || 'album'}"
data-name="${album.name || 'Unknown Album'}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
</button>
`;
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 = `
<button class="action-btn toggle-known-status-btn"
data-id="${album.id || ''}"
data-artist-id="${artistId}"
data-status="${initialStatus}"
title="${initialTitle}">
<img src="${initialIcon}" alt="Mark as Missing/Known">
</button>
`;
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 = '<img src="/static/images/check.svg" alt="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 = '<img src="/static/images/missing.svg" alt="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 = '<img src="/static/images/download.svg" alt="Download album">';
downloadBtn.title = 'Download this album';
downloadBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
downloadBtn.disabled = true;
downloadBtn.innerHTML = '<img src="/static/images/refresh.svg" alt="Queueing..." class="icon-spin">';
startDownload(album.id, 'album', { name: album.name, artist: album.artists?.[0]?.name || 'Unknown Artist', type: 'album' })
.then(() => {
downloadBtn.innerHTML = '<img src="/static/images/check.svg" alt="Queued">';
showNotification(`Album '${album.name}' queued for download.`);
downloadQueue.toggleVisibility(true);
})
.catch(err => {
downloadBtn.disabled = false;
downloadBtn.innerHTML = '<img src="/static/images/download.svg" alt="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 = `
<a href="/album/${album.id || ''}" class="album-link">
<img src="${album.images?.[1]?.url || album.images?.[0]?.url || '/static/images/placeholder.jpg'}"
alt="Album cover"
class="album-cover">
class="album-cover ${album.is_locally_known === false ? 'album-missing-in-db' : ''}">
</a>
<div class="album-info">
<div class="album-title">${album.name || 'Unknown Album'}</div>
<div class="album-artist">${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}</div>
</div>
`;
const actionsContainer = document.createElement('div');
actionsContainer.className = 'album-actions-container';
if (!isExplicitFilterEnabled) {
const downloadBtnHTML = `
<button class="download-btn download-btn--circle album-download-btn"
data-id="${album.id || ''}"
data-type="${album.album_type || 'album'}"
data-name="${album.name || 'Unknown Album'}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
</button>
`;
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 = `
<button class="action-btn toggle-known-status-btn"
data-id="${album.id || ''}"
data-artist-id="${artistId}"
data-status="${initialStatus}"
title="${initialTitle}">
<img src="${initialIcon}" alt="Mark as Missing/Known">
</button>
`;
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 = '<img src="/static/images/check.svg" alt="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 = '<img src="/static/images/missing.svg" alt="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 = '<img src="/static/images/download.svg" alt="Download album">';
downloadBtn.title = 'Download this album';
downloadBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
downloadBtn.disabled = true;
downloadBtn.innerHTML = '<img src="/static/images/refresh.svg" alt="Queueing..." class="icon-spin">';
startDownload(album.id, 'album', { name: album.name, artist: album.artists?.[0]?.name || 'Unknown Artist', type: 'album' })
.then(() => {
downloadBtn.innerHTML = '<img src="/static/images/check.svg" alt="Queued">';
showNotification(`Album '${album.name}' queued for download.`);
downloadQueue.toggleVisibility(true);
})
.catch(err => {
downloadBtn.disabled = false;
downloadBtn.innerHTML = '<img src="/static/images/download.svg" alt="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 = '<img src="/static/images/refresh.svg" alt="Updating..." class="icon-spin">';
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 = '<img src="/static/images/missing.svg" alt="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 = '<img src="/static/images/check.svg" alt="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 {
// 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]),
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(() => ({}));
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
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 {
// 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]),
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(() => ({}));
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
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) {
// 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.
}
}

View File

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

View File

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

View File

@@ -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 */

View File

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