diff --git a/database/music_database.py b/database/music_database.py index ef2dadda..06d3839c 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -4859,6 +4859,8 @@ class MusicDatabase: 'image_url': artist.thumb_url, 'genres': artist.genres, 'musicbrainz_id': row['musicbrainz_id'], + 'spotify_artist_id': row['spotify_artist_id'], + 'itunes_artist_id': row['itunes_artist_id'], 'deezer_id': row['deezer_id'], 'audiodb_id': row['audiodb_id'], 'album_count': row['album_count'] or 0, diff --git a/web_server.py b/web_server.py index f5347c66..b84bcab9 100644 --- a/web_server.py +++ b/web_server.py @@ -21541,6 +21541,81 @@ def add_batch_to_watchlist(): print(f"Error batch adding to watchlist: {e}") return jsonify({"success": False, "error": str(e)}), 500 +@app.route('/api/library/watchlist-all-unwatched', methods=['POST']) +def watchlist_all_unwatched_library_artists(): + """Add all unwatched library artists (that have valid external IDs) to the watchlist""" + try: + database = get_database() + active_source = _get_active_discovery_source() + + # Get ALL unwatched artists from library (no pagination) + result = database.get_library_artists( + search_query='', + letter='all', + page=1, + limit=99999, + watchlist_filter='unwatched' + ) + + unwatched_artists = result.get('artists', []) + added = 0 + skipped_no_id = 0 + skipped_already = 0 + + for artist in unwatched_artists: + # Determine the external ID to use based on active source + artist_id = None + if active_source == 'spotify' and artist.get('spotify_artist_id'): + artist_id = artist['spotify_artist_id'] + elif artist.get('itunes_artist_id'): + artist_id = artist['itunes_artist_id'] + elif artist.get('spotify_artist_id'): + # Fallback: use spotify if itunes not available + artist_id = artist['spotify_artist_id'] + + if not artist_id: + skipped_no_id += 1 + continue + + artist_name = artist.get('name', '') + if not artist_name: + continue + + # Check if already watched (shouldn't be since we filtered, but safety check) + if database.is_artist_in_watchlist(artist_id): + skipped_already += 1 + continue + + success = database.add_artist_to_watchlist(artist_id, artist_name) + if success: + added += 1 + # Use library thumb_url if available (no HTTP calls needed) + if artist.get('image_url'): + try: + database.update_watchlist_artist_image(artist_id, artist['image_url']) + except Exception: + pass + + total_unwatched = len(unwatched_artists) + message_parts = [f"Added {added} artist{'s' if added != 1 else ''} to watchlist"] + if skipped_no_id > 0: + message_parts.append(f"{skipped_no_id} skipped (no matching ID yet)") + + return jsonify({ + "success": True, + "added": added, + "skipped_no_id": skipped_no_id, + "skipped_already": skipped_already, + "total_unwatched": total_unwatched, + "message": " — ".join(message_parts) + }) + + except Exception as e: + print(f"Error bulk watchlisting library artists: {e}") + import traceback + traceback.print_exc() + return jsonify({"success": False, "error": str(e)}), 500 + @app.route('/api/watchlist/remove-batch', methods=['POST']) def remove_batch_from_watchlist(): """Remove multiple artists from the watchlist""" diff --git a/webui/index.html b/webui/index.html index 1fb88ee4..639214e5 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1902,6 +1902,10 @@ + diff --git a/webui/static/mobile.css b/webui/static/mobile.css index d81707c9..8116056b 100644 --- a/webui/static/mobile.css +++ b/webui/static/mobile.css @@ -870,6 +870,19 @@ max-width: 100%; } + .watchlist-filter { + flex-wrap: wrap; + } + + .library-watchlist-all-btn { + font-size: 12px; + padding: 5px 12px; + } + + .library-card-watchlist-btn { + opacity: 1; + } + .alphabet-selector-inner { flex-wrap: wrap; min-width: 0; diff --git a/webui/static/script.js b/webui/static/script.js index 86717e0c..a9f27900 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -27551,6 +27551,7 @@ function initializeLibrarySearch() { function initializeWatchlistFilter() { const filterButtons = document.querySelectorAll(".watchlist-filter-btn"); + const watchAllBtn = document.getElementById("library-watchlist-all-btn"); filterButtons.forEach(button => { button.addEventListener("click", () => { @@ -27560,6 +27561,15 @@ function initializeWatchlistFilter() { filterButtons.forEach(btn => btn.classList.remove("active")); button.classList.add("active"); + // Show/hide "Watch All Unwatched" button + if (watchAllBtn) { + if (filter === "unwatched") { + watchAllBtn.classList.remove("hidden"); + } else { + watchAllBtn.classList.add("hidden"); + } + } + // Update state and reload libraryPageState.watchlistFilter = filter; libraryPageState.currentPage = 1; @@ -27710,7 +27720,8 @@ function createLibraryArtistCard(artist) { badgeOffset += 32; }); - // Add watchlist indicator if artist is on the watchlist + // Add watchlist button/indicator on the card + const hasExternalId = artist.itunes_artist_id || artist.spotify_artist_id; if (artist.is_watched) { const watchIcon = document.createElement('div'); watchIcon.className = 'watchlist-card-icon'; @@ -27718,6 +27729,17 @@ function createLibraryArtistCard(artist) { watchIcon.style.top = badgeOffset + 'px'; watchIcon.textContent = '👁️'; card.appendChild(watchIcon); + } else if (hasExternalId) { + const watchBtn = document.createElement('button'); + watchBtn.className = 'library-card-watchlist-btn'; + watchBtn.style.top = badgeOffset + 'px'; + watchBtn.title = 'Add to Watchlist'; + watchBtn.innerHTML = '👁️Watch'; + watchBtn.onclick = (e) => { + e.stopPropagation(); + toggleLibraryCardWatchlist(watchBtn, artist); + }; + card.appendChild(watchBtn); } // Create image element @@ -27843,6 +27865,113 @@ function showLibraryEmpty(show) { } } +async function addAllUnwatchedToWatchlist(btn) { + if (btn.classList.contains('adding') || btn.classList.contains('all-added')) return; + + btn.classList.add('adding'); + const textSpan = btn.querySelector('.watchlist-all-text'); + const originalText = textSpan.textContent; + textSpan.textContent = 'Adding...'; + + try { + const response = await fetch('/api/library/watchlist-all-unwatched', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + const data = await response.json(); + + if (data.success) { + btn.classList.remove('adding'); + btn.classList.add('all-added'); + + let resultText = `Added ${data.added}`; + if (data.skipped_no_id > 0) { + resultText += ` (${data.skipped_no_id} unmatched)`; + } + textSpan.textContent = resultText; + + if (data.added > 0) { + showToast(data.message, 'success'); + // Reload the library to reflect changes + setTimeout(() => { + loadLibraryArtists(); + }, 1500); + } else if (data.skipped_no_id > 0) { + showToast(`No artists could be added — ${data.skipped_no_id} don't have matching IDs yet. Background workers will match them over time.`, 'warning'); + } else { + showToast('No unwatched artists found', 'info'); + } + + // Reset button after a delay + setTimeout(() => { + btn.classList.remove('all-added'); + textSpan.textContent = originalText; + }, 5000); + } else { + throw new Error(data.error || 'Failed to add artists'); + } + } catch (error) { + console.error('Error bulk adding unwatched artists:', error); + btn.classList.remove('adding'); + textSpan.textContent = originalText; + showToast('Failed to add artists to watchlist', 'error'); + } +} + +async function toggleLibraryCardWatchlist(btn, artist) { + if (btn.disabled) return; + btn.disabled = true; + + const icon = btn.querySelector('.watchlist-icon'); + const text = btn.querySelector('.watchlist-text'); + const isWatching = btn.classList.contains('watching'); + + text.textContent = '...'; + + try { + // Determine external ID: prefer iTunes, fallback Spotify + const artistId = artist.itunes_artist_id || artist.spotify_artist_id; + + if (isWatching) { + const response = await fetch('/api/watchlist/remove', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artist_id: artistId }) + }); + const data = await response.json(); + if (!data.success) throw new Error(data.error); + + btn.classList.remove('watching'); + btn.title = 'Add to Watchlist'; + text.textContent = 'Watch'; + showToast(`Removed ${artist.name} from watchlist`, 'success'); + } else { + const response = await fetch('/api/watchlist/add', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artist_id: artistId, artist_name: artist.name }) + }); + const data = await response.json(); + if (!data.success) throw new Error(data.error); + + btn.classList.add('watching'); + btn.title = 'Remove from Watchlist'; + text.textContent = 'Watching'; + showToast(`Added ${artist.name} to watchlist`, 'success'); + } + + if (typeof updateWatchlistCount === 'function') { + updateWatchlistCount(); + } + } catch (error) { + console.error('Error toggling library card watchlist:', error); + text.textContent = isWatching ? 'Watching' : 'Watch'; + showToast(`Error: ${error.message}`, 'error'); + } finally { + btn.disabled = false; + } +} + // =============================================== // Artist Detail Page Functions // =============================================== diff --git a/webui/static/style.css b/webui/static/style.css index ee4f01c0..429b776c 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -13509,6 +13509,50 @@ body { background: rgba(var(--accent-rgb), 0.15); } +.library-watchlist-all-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 16px; + border: 1px solid rgba(var(--accent-rgb), 0.4); + background: rgba(var(--accent-rgb), 0.12); + color: rgba(var(--accent-rgb), 1); + font-size: 13px; + font-weight: 600; + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', system-ui, sans-serif; + cursor: pointer; + border-radius: 20px; + transition: all 0.2s ease-out; + margin-left: 4px; +} + +.library-watchlist-all-btn:hover { + background: rgba(var(--accent-rgb), 0.25); + border-color: rgba(var(--accent-rgb), 0.6); + transform: scale(1.02); +} + +.library-watchlist-all-btn.adding { + opacity: 0.7; + pointer-events: none; + cursor: wait; +} + +.library-watchlist-all-btn.all-added { + background: rgba(var(--accent-rgb), 0.2); + border-color: rgba(var(--accent-rgb), 0.5); + color: #fff; + pointer-events: none; +} + +.library-watchlist-all-btn .watchlist-all-icon { + font-size: 14px; +} + +.library-watchlist-all-btn.hidden { + display: none; +} + .alphabet-selector { overflow-x: auto; -webkit-overflow-scrolling: touch; @@ -25410,6 +25454,66 @@ body { pointer-events: none; } +.library-card-watchlist-btn { + position: absolute; + right: 8px; + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 12px; + color: #e0e0e0; + font-size: 11px; + font-weight: 600; + font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif; + cursor: pointer; + z-index: 100; + transition: all 0.2s ease; + opacity: 0; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); +} + +.library-artist-card:hover .library-card-watchlist-btn { + opacity: 1; +} + +.library-card-watchlist-btn:hover { + background: rgba(var(--accent-rgb), 0.25); + border-color: rgba(var(--accent-rgb), 0.4); + color: #fff; + transform: scale(1.05); +} + +.library-card-watchlist-btn.watching { + background: rgba(255, 193, 7, 0.2); + color: #ffc107; + border-color: rgba(255, 193, 7, 0.4); + opacity: 1; +} + +.library-card-watchlist-btn.watching:hover { + background: rgba(255, 193, 7, 0.35); + border-color: rgba(255, 193, 7, 0.6); + color: #fff; +} + +.library-card-watchlist-btn:disabled { + opacity: 0.5; + cursor: wait; + transform: none; +} + +.library-card-watchlist-btn .watchlist-icon { + font-size: 12px; +} + +.library-card-watchlist-btn .watchlist-text { + line-height: 1; +} + /* ============================= */ /* IMPORT MODAL */ /* ============================= */