diff --git a/web_server.py b/web_server.py
index ac2ef73f..34e86c0b 100644
--- a/web_server.py
+++ b/web_server.py
@@ -4969,6 +4969,15 @@ def _process_wishlist_automatically():
schedule_next_wishlist_processing()
return
+ # Check if watchlist scan is currently running
+ global watchlist_scan_state
+ if (watchlist_scan_state and
+ isinstance(watchlist_scan_state, dict) and
+ watchlist_scan_state.get('status') == 'scanning'):
+ print("👁️ Watchlist scan in progress, skipping automatic wishlist processing to avoid conflicts.")
+ schedule_next_wishlist_processing()
+ return
+
wishlist_auto_processing = True
# Use app context for database operations
@@ -9733,6 +9742,416 @@ def hydrate_artist_bubbles():
'error': str(e)
}), 500
+# --- Watchlist API Endpoints ---
+
+@app.route('/api/watchlist/count', methods=['GET'])
+def get_watchlist_count():
+ """Get the number of artists in the watchlist"""
+ try:
+ database = get_database()
+ count = database.get_watchlist_count()
+ return jsonify({"success": True, "count": count})
+ except Exception as e:
+ print(f"Error getting watchlist count: {e}")
+ return jsonify({"success": False, "error": str(e)}), 500
+
+@app.route('/api/watchlist/artists', methods=['GET'])
+def get_watchlist_artists():
+ """Get all artists in the watchlist"""
+ try:
+ database = get_database()
+ watchlist_artists = database.get_watchlist_artists()
+
+ # Convert to JSON serializable format
+ artists_data = []
+ for artist in watchlist_artists:
+ artists_data.append({
+ "id": artist.id,
+ "spotify_artist_id": artist.spotify_artist_id,
+ "artist_name": artist.artist_name,
+ "date_added": artist.date_added.isoformat() if artist.date_added else None,
+ "last_scan_timestamp": artist.last_scan_timestamp.isoformat() if artist.last_scan_timestamp else None,
+ "created_at": artist.created_at.isoformat() if artist.created_at else None,
+ "updated_at": artist.updated_at.isoformat() if artist.updated_at else None
+ })
+
+ return jsonify({"success": True, "artists": artists_data})
+ except Exception as e:
+ print(f"Error getting watchlist artists: {e}")
+ return jsonify({"success": False, "error": str(e)}), 500
+
+@app.route('/api/watchlist/add', methods=['POST'])
+def add_to_watchlist():
+ """Add an artist to the watchlist"""
+ try:
+ data = request.get_json()
+ artist_id = data.get('artist_id')
+ artist_name = data.get('artist_name')
+
+ if not artist_id or not artist_name:
+ return jsonify({"success": False, "error": "Missing artist_id or artist_name"}), 400
+
+ database = get_database()
+ success = database.add_artist_to_watchlist(artist_id, artist_name)
+
+ if success:
+ return jsonify({"success": True, "message": f"Added {artist_name} to watchlist"})
+ else:
+ return jsonify({"success": False, "error": "Failed to add artist to watchlist"}), 500
+
+ except Exception as e:
+ print(f"Error adding to watchlist: {e}")
+ return jsonify({"success": False, "error": str(e)}), 500
+
+@app.route('/api/watchlist/remove', methods=['POST'])
+def remove_from_watchlist():
+ """Remove an artist from the watchlist"""
+ try:
+ data = request.get_json()
+ artist_id = data.get('artist_id')
+
+ if not artist_id:
+ return jsonify({"success": False, "error": "Missing artist_id"}), 400
+
+ database = get_database()
+ success = database.remove_artist_from_watchlist(artist_id)
+
+ if success:
+ return jsonify({"success": True, "message": "Removed artist from watchlist"})
+ else:
+ return jsonify({"success": False, "error": "Failed to remove artist from watchlist"}), 500
+
+ except Exception as e:
+ print(f"Error removing from watchlist: {e}")
+ return jsonify({"success": False, "error": str(e)}), 500
+
+@app.route('/api/watchlist/check', methods=['POST'])
+def check_watchlist_status():
+ """Check if an artist is in the watchlist"""
+ try:
+ data = request.get_json()
+ artist_id = data.get('artist_id')
+
+ if not artist_id:
+ return jsonify({"success": False, "error": "Missing artist_id"}), 400
+
+ database = get_database()
+ is_watching = database.is_artist_in_watchlist(artist_id)
+
+ return jsonify({"success": True, "is_watching": is_watching})
+
+ except Exception as e:
+ print(f"Error checking watchlist status: {e}")
+ return jsonify({"success": False, "error": str(e)}), 500
+
+@app.route('/api/watchlist/scan', methods=['POST'])
+def start_watchlist_scan():
+ """Start a watchlist scan for new releases"""
+ try:
+ if not spotify_client or not spotify_client.is_authenticated():
+ return jsonify({"success": False, "error": "Spotify client not available or not authenticated"}), 400
+
+ # Check if wishlist auto-processing is currently running
+ global wishlist_auto_processing
+ if wishlist_auto_processing:
+ return jsonify({"success": False, "error": "Wishlist auto-processing is currently running. Please wait for it to complete before starting a watchlist scan."}), 409
+
+ # Start the scan in a background thread
+ def run_scan():
+ try:
+ global watchlist_scan_state
+ from core.watchlist_scanner import get_watchlist_scanner
+ from database.music_database import get_database
+
+ # Get list of artists to scan
+ database = get_database()
+ watchlist_artists = database.get_watchlist_artists()
+
+ if not watchlist_artists:
+ watchlist_scan_state['status'] = 'completed'
+ watchlist_scan_state['summary'] = {
+ 'total_artists': 0,
+ 'successful_scans': 0,
+ 'new_tracks_found': 0,
+ 'tracks_added_to_wishlist': 0
+ }
+ return
+
+ scanner = get_watchlist_scanner(spotify_client)
+
+ # Initialize detailed progress tracking
+ watchlist_scan_state.update({
+ 'total_artists': len(watchlist_artists),
+ 'current_artist_index': 0,
+ 'current_artist_name': '',
+ 'current_phase': 'starting',
+ 'albums_to_check': 0,
+ 'albums_checked': 0,
+ 'current_album': '',
+ 'tracks_found_this_scan': 0,
+ 'tracks_added_this_scan': 0
+ })
+
+ scan_results = []
+
+ for i, artist in enumerate(watchlist_artists):
+ try:
+ # Update progress
+ watchlist_scan_state.update({
+ 'current_artist_index': i + 1,
+ 'current_artist_name': artist.artist_name,
+ 'current_phase': 'fetching_discography',
+ 'albums_to_check': 0,
+ 'albums_checked': 0,
+ 'current_album': ''
+ })
+
+ # Get artist discography
+ albums = scanner.get_artist_discography(artist.spotify_artist_id, artist.last_scan_timestamp)
+
+ if albums is None:
+ scan_results.append(type('ScanResult', (), {
+ 'artist_name': artist.artist_name,
+ 'spotify_artist_id': artist.spotify_artist_id,
+ 'albums_checked': 0,
+ 'new_tracks_found': 0,
+ 'tracks_added_to_wishlist': 0,
+ 'success': False,
+ 'error_message': "Failed to get artist discography"
+ })())
+ continue
+
+ # Update with album count
+ watchlist_scan_state.update({
+ 'current_phase': 'checking_albums',
+ 'albums_to_check': len(albums),
+ 'albums_checked': 0
+ })
+
+ # Track progress for this artist
+ artist_new_tracks = 0
+ artist_added_tracks = 0
+
+ # Scan each album
+ for album_index, album in enumerate(albums):
+ watchlist_scan_state.update({
+ 'albums_checked': album_index + 1,
+ 'current_album': album.name,
+ 'current_phase': f'checking_album_{album_index + 1}_of_{len(albums)}'
+ })
+
+ try:
+ # Get album tracks
+ album_data = scanner.spotify_client.get_album(album.id)
+ if not album_data or 'tracks' not in album_data:
+ continue
+
+ tracks = album_data['tracks']['items']
+
+ # Check each track
+ for track in tracks:
+ if scanner.is_track_missing_from_library(track):
+ artist_new_tracks += 1
+ watchlist_scan_state['tracks_found_this_scan'] += 1
+
+ # Add to wishlist
+ if scanner.add_track_to_wishlist(track, album_data, artist):
+ artist_added_tracks += 1
+ watchlist_scan_state['tracks_added_this_scan'] += 1
+
+ # Small delay between albums
+ import time
+ time.sleep(0.5)
+
+ except Exception as e:
+ print(f"Error checking album {album.name}: {e}")
+ continue
+
+ # Update scan timestamp
+ scanner.update_artist_scan_timestamp(artist.spotify_artist_id)
+
+ # Store result
+ scan_results.append(type('ScanResult', (), {
+ 'artist_name': artist.artist_name,
+ 'spotify_artist_id': artist.spotify_artist_id,
+ 'albums_checked': len(albums),
+ 'new_tracks_found': artist_new_tracks,
+ 'tracks_added_to_wishlist': artist_added_tracks,
+ 'success': True,
+ 'error_message': None
+ })())
+
+ print(f"✅ Scanned {artist.artist_name}: {artist_new_tracks} new tracks found, {artist_added_tracks} added to wishlist")
+
+ # Delay between artists
+ if i < len(watchlist_artists) - 1:
+ watchlist_scan_state['current_phase'] = 'rate_limiting'
+ time.sleep(2.0)
+
+ except Exception as e:
+ print(f"Error scanning artist {artist.artist_name}: {e}")
+ scan_results.append(type('ScanResult', (), {
+ 'artist_name': artist.artist_name,
+ 'spotify_artist_id': artist.spotify_artist_id,
+ 'albums_checked': 0,
+ 'new_tracks_found': 0,
+ 'tracks_added_to_wishlist': 0,
+ 'success': False,
+ 'error_message': str(e)
+ })())
+
+ # Store final results
+ watchlist_scan_state['status'] = 'completed'
+ watchlist_scan_state['results'] = scan_results
+ watchlist_scan_state['completed_at'] = datetime.now()
+ watchlist_scan_state['current_phase'] = 'completed'
+
+ # Calculate summary
+ successful_scans = [r for r in scan_results if r.success]
+ total_new_tracks = sum(r.new_tracks_found for r in successful_scans)
+ total_added_to_wishlist = sum(r.tracks_added_to_wishlist for r in successful_scans)
+
+ watchlist_scan_state['summary'] = {
+ 'total_artists': len(scan_results),
+ 'successful_scans': len(successful_scans),
+ 'new_tracks_found': total_new_tracks,
+ 'tracks_added_to_wishlist': total_added_to_wishlist
+ }
+
+ print(f"Watchlist scan completed: {len(successful_scans)}/{len(scan_results)} artists scanned successfully")
+ print(f"Found {total_new_tracks} new tracks, added {total_added_to_wishlist} to wishlist")
+
+ except Exception as e:
+ print(f"Error during watchlist scan: {e}")
+ watchlist_scan_state['status'] = 'error'
+ watchlist_scan_state['error'] = str(e)
+
+ # Initialize scan state
+ global watchlist_scan_state
+ watchlist_scan_state = {
+ 'status': 'scanning',
+ 'started_at': datetime.now(),
+ 'results': [],
+ 'summary': {},
+ 'error': None
+ }
+
+ # Start scan in background
+ thread = threading.Thread(target=run_scan)
+ thread.daemon = True
+ thread.start()
+
+ return jsonify({"success": True, "message": "Watchlist scan started"})
+
+ except Exception as e:
+ print(f"Error starting watchlist scan: {e}")
+ return jsonify({"success": False, "error": str(e)}), 500
+
+@app.route('/api/watchlist/scan/status', methods=['GET'])
+def get_watchlist_scan_status():
+ """Get the current status of watchlist scanning"""
+ try:
+ global watchlist_scan_state
+ if 'watchlist_scan_state' not in globals():
+ return jsonify({
+ "success": True,
+ "status": "idle",
+ "summary": {}
+ })
+
+ # Convert datetime objects to ISO format for JSON serialization
+ state = watchlist_scan_state.copy()
+ if 'started_at' in state and state['started_at']:
+ state['started_at'] = state['started_at'].isoformat()
+ if 'completed_at' in state and state['completed_at']:
+ state['completed_at'] = state['completed_at'].isoformat()
+
+ return jsonify({"success": True, **state})
+
+ except Exception as e:
+ print(f"Error getting watchlist scan status: {e}")
+ return jsonify({"success": False, "error": str(e)}), 500
+
+# --- Watchlist Auto-Scanning System ---
+
+watchlist_scan_state = {
+ 'status': 'idle',
+ 'results': [],
+ 'summary': {},
+ 'error': None
+}
+
+def start_watchlist_auto_scanning():
+ """Start automatic daily watchlist scanning"""
+ def daily_scan():
+ while True:
+ try:
+ # Wait 24 hours (86400 seconds)
+ time.sleep(86400)
+
+ # Check if we have artists to scan and Spotify client is available
+ database = get_database()
+ watchlist_count = database.get_watchlist_count()
+
+ if watchlist_count > 0 and spotify_client and spotify_client.is_authenticated():
+ # Check if wishlist auto-processing is currently running
+ global wishlist_auto_processing
+ if wishlist_auto_processing:
+ print("👁️ Skipping automatic daily watchlist scan: wishlist auto-processing is currently running")
+ continue # Skip this cycle, will try again in 24 hours
+
+ print(f"Starting automatic daily watchlist scan for {watchlist_count} artists...")
+
+ # Update global scan state
+ global watchlist_scan_state
+ watchlist_scan_state = {
+ 'status': 'scanning',
+ 'started_at': datetime.now(),
+ 'results': [],
+ 'summary': {},
+ 'error': None
+ }
+
+ # Run the scan
+ from core.watchlist_scanner import get_watchlist_scanner
+ scanner = get_watchlist_scanner(spotify_client)
+ results = scanner.scan_all_watchlist_artists()
+
+ # Update state with results
+ watchlist_scan_state['status'] = 'completed'
+ watchlist_scan_state['results'] = results
+ watchlist_scan_state['completed_at'] = datetime.now()
+
+ # Calculate summary
+ successful_scans = [r for r in results if r.success]
+ total_new_tracks = sum(r.new_tracks_found for r in successful_scans)
+ total_added_to_wishlist = sum(r.tracks_added_to_wishlist for r in successful_scans)
+
+ watchlist_scan_state['summary'] = {
+ 'total_artists': len(results),
+ 'successful_scans': len(successful_scans),
+ 'new_tracks_found': total_new_tracks,
+ 'tracks_added_to_wishlist': total_added_to_wishlist
+ }
+
+ print(f"Automatic watchlist scan completed: {len(successful_scans)}/{len(results)} artists scanned successfully")
+ print(f"Found {total_new_tracks} new tracks, added {total_added_to_wishlist} to wishlist")
+
+ else:
+ print("Skipping automatic watchlist scan: no artists in watchlist or Spotify client unavailable")
+
+ except Exception as e:
+ print(f"Error during automatic watchlist scan: {e}")
+ if 'watchlist_scan_state' in globals():
+ watchlist_scan_state['status'] = 'error'
+ watchlist_scan_state['error'] = str(e)
+
+ # Start the daily scanning thread
+ thread = threading.Thread(target=daily_scan)
+ thread.daemon = True
+ thread.start()
+ print("✅ Automatic daily watchlist scanning started")
+
# --- Main Execution ---
if __name__ == '__main__':
@@ -9749,4 +10168,9 @@ if __name__ == '__main__':
start_wishlist_auto_processing()
print("✅ Automatic wishlist processing started")
+ # Start automatic watchlist scanning when server starts
+ print("🔧 Starting automatic watchlist scanning...")
+ start_watchlist_auto_scanning()
+ print("✅ Automatic watchlist scanning started")
+
app.run(host='0.0.0.0', port=5001, debug=True)
diff --git a/webui/index.html b/webui/index.html
index 6d3ba5cb..359b4e3c 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -577,18 +577,25 @@
`;
@@ -10678,6 +10704,140 @@ function updateArtistDetailHeader(artist) {
const genres = artist.genres?.slice(0, 4).join(' • ') || 'Various genres';
genresElement.textContent = genres;
}
+
+ // Initialize watchlist button
+ initializeArtistDetailWatchlistButton(artist);
+}
+
+/**
+ * Initialize watchlist button for artist detail page
+ */
+async function initializeArtistDetailWatchlistButton(artist) {
+ const button = document.getElementById('artist-detail-watchlist-btn');
+ if (!button) return;
+
+ // Set up click handler
+ button.onclick = (event) => toggleArtistDetailWatchlist(event, artist.id, artist.name);
+
+ // Check and update current status
+ await updateArtistDetailWatchlistButton(artist.id);
+}
+
+/**
+ * Toggle watchlist status for artist detail page
+ */
+async function toggleArtistDetailWatchlist(event, artistId, artistName) {
+ event.preventDefault();
+
+ const button = document.getElementById('artist-detail-watchlist-btn');
+ const icon = button.querySelector('.watchlist-icon');
+ const text = button.querySelector('.watchlist-text');
+
+ // Show loading state
+ const originalText = text.textContent;
+ text.textContent = 'Loading...';
+ button.disabled = true;
+
+ try {
+ // Check current status
+ const checkResponse = await fetch('/api/watchlist/check', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ artist_id: artistId })
+ });
+
+ const checkData = await checkResponse.json();
+ if (!checkData.success) {
+ throw new Error(checkData.error || 'Failed to check watchlist status');
+ }
+
+ const isWatching = checkData.is_watching;
+
+ // Toggle watchlist status
+ const endpoint = isWatching ? '/api/watchlist/remove' : '/api/watchlist/add';
+ const payload = isWatching ?
+ { artist_id: artistId } :
+ { artist_id: artistId, artist_name: artistName };
+
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+
+ const data = await response.json();
+ if (!data.success) {
+ throw new Error(data.error || 'Failed to update watchlist');
+ }
+
+ // Update button appearance
+ if (isWatching) {
+ // Was watching, now removed
+ icon.textContent = '👁️';
+ text.textContent = 'Add to Watchlist';
+ button.classList.remove('watching');
+ console.log(`❌ Removed ${artistName} from watchlist`);
+ } else {
+ // Was not watching, now added
+ icon.textContent = '👁️';
+ text.textContent = 'Remove from Watchlist';
+ button.classList.add('watching');
+ console.log(`✅ Added ${artistName} to watchlist`);
+ }
+
+ // Update dashboard watchlist count
+ updateWatchlistButtonCount();
+
+ // Update any visible artist cards
+ updateArtistCardWatchlistStatus();
+
+ } catch (error) {
+ console.error('Error toggling watchlist:', error);
+ text.textContent = originalText;
+
+ // Show error feedback
+ const originalBackground = button.style.background;
+ button.style.background = 'rgba(255, 59, 48, 0.3)';
+ setTimeout(() => {
+ button.style.background = originalBackground;
+ }, 2000);
+ } finally {
+ button.disabled = false;
+ }
+}
+
+/**
+ * Update artist detail watchlist button status
+ */
+async function updateArtistDetailWatchlistButton(artistId) {
+ const button = document.getElementById('artist-detail-watchlist-btn');
+ if (!button) return;
+
+ try {
+ const response = await fetch('/api/watchlist/check', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ artist_id: artistId })
+ });
+
+ const data = await response.json();
+ if (data.success) {
+ const icon = button.querySelector('.watchlist-icon');
+ const text = button.querySelector('.watchlist-text');
+
+ if (data.is_watching) {
+ icon.textContent = '👁️';
+ text.textContent = 'Remove from Watchlist';
+ button.classList.add('watching');
+ } else {
+ icon.textContent = '👁️';
+ text.textContent = 'Add to Watchlist';
+ button.classList.remove('watching');
+ }
+ }
+ } catch (error) {
+ console.error('Error checking watchlist status:', error);
+ }
}
/**
@@ -11820,6 +11980,447 @@ function escapeHtml(text) {
return div.innerHTML;
}
+// --- Watchlist Functions ---
+
+/**
+ * Toggle an artist's watchlist status
+ */
+async function toggleWatchlist(event, artistId, artistName) {
+ // Prevent event bubbling to parent card
+ event.stopPropagation();
+
+ const button = event.currentTarget;
+ const icon = button.querySelector('.watchlist-icon');
+ const text = button.querySelector('.watchlist-text');
+
+ // Show loading state
+ const originalText = text.textContent;
+ text.textContent = 'Loading...';
+ button.disabled = true;
+
+ try {
+ // Check current status
+ const checkResponse = await fetch('/api/watchlist/check', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ artist_id: artistId })
+ });
+
+ const checkData = await checkResponse.json();
+ if (!checkData.success) {
+ throw new Error(checkData.error || 'Failed to check watchlist status');
+ }
+
+ const isWatching = checkData.is_watching;
+
+ // Toggle watchlist status
+ const endpoint = isWatching ? '/api/watchlist/remove' : '/api/watchlist/add';
+ const payload = isWatching ?
+ { artist_id: artistId } :
+ { artist_id: artistId, artist_name: artistName };
+
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+
+ const data = await response.json();
+ if (!data.success) {
+ throw new Error(data.error || 'Failed to update watchlist');
+ }
+
+ // Update button appearance
+ if (isWatching) {
+ // Was watching, now removed
+ icon.textContent = '👁️';
+ text.textContent = 'Add to Watchlist';
+ button.classList.remove('watching');
+ console.log(`❌ Removed ${artistName} from watchlist`);
+ } else {
+ // Was not watching, now added
+ icon.textContent = '👁️';
+ text.textContent = 'Watching...';
+ button.classList.add('watching');
+ console.log(`✅ Added ${artistName} to watchlist`);
+ }
+
+ // Update dashboard watchlist count
+ updateWatchlistButtonCount();
+
+ } catch (error) {
+ console.error('Error toggling watchlist:', error);
+ text.textContent = originalText;
+
+ // Show error feedback
+ const originalBackground = button.style.background;
+ button.style.background = 'rgba(255, 59, 48, 0.3)';
+ setTimeout(() => {
+ button.style.background = originalBackground;
+ }, 2000);
+ } finally {
+ button.disabled = false;
+ }
+}
+
+/**
+ * Update the watchlist button count on dashboard
+ */
+async function updateWatchlistButtonCount() {
+ try {
+ const response = await fetch('/api/watchlist/count');
+ const data = await response.json();
+
+ if (data.success) {
+ const watchlistButton = document.getElementById('watchlist-button');
+ if (watchlistButton) {
+ watchlistButton.textContent = `👁️ Watchlist (${data.count})`;
+ }
+ }
+ } catch (error) {
+ console.error('Error updating watchlist count:', error);
+ }
+}
+
+/**
+ * Check and update watchlist status for all visible artist cards
+ */
+async function updateArtistCardWatchlistStatus() {
+ const artistCards = document.querySelectorAll('.artist-card');
+
+ for (const card of artistCards) {
+ const artistId = card.dataset.artistId;
+ if (!artistId) continue;
+
+ try {
+ const response = await fetch('/api/watchlist/check', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ artist_id: artistId })
+ });
+
+ const data = await response.json();
+ if (data.success) {
+ const button = card.querySelector('.watchlist-toggle-btn');
+ const icon = button.querySelector('.watchlist-icon');
+ const text = button.querySelector('.watchlist-text');
+
+ if (data.is_watching) {
+ icon.textContent = '👁️';
+ text.textContent = 'Watching...';
+ button.classList.add('watching');
+ } else {
+ icon.textContent = '👁️';
+ text.textContent = 'Add to Watchlist';
+ button.classList.remove('watching');
+ }
+ }
+ } catch (error) {
+ console.error(`Error checking watchlist status for artist ${artistId}:`, error);
+ }
+ }
+}
+
+/**
+ * Show watchlist modal
+ */
+async function showWatchlistModal() {
+ try {
+ // Check if watchlist has any artists
+ const countResponse = await fetch('/api/watchlist/count');
+ const countData = await countResponse.json();
+
+ if (!countData.success) {
+ console.error('Error getting watchlist count:', countData.error);
+ return;
+ }
+
+ if (countData.count === 0) {
+ // Show empty state message
+ alert('Your watchlist is empty!\n\nAdd artists to your watchlist from the Artists page to monitor them for new releases.');
+ return;
+ }
+
+ // Get watchlist artists
+ const artistsResponse = await fetch('/api/watchlist/artists');
+ const artistsData = await artistsResponse.json();
+
+ if (!artistsData.success) {
+ console.error('Error getting watchlist artists:', artistsData.error);
+ return;
+ }
+
+ // Create modal if it doesn't exist
+ let modal = document.getElementById('watchlist-modal');
+ if (!modal) {
+ modal = document.createElement('div');
+ modal.id = 'watchlist-modal';
+ modal.className = 'modal-overlay';
+ document.body.appendChild(modal);
+ }
+
+ // Get scan status
+ const statusResponse = await fetch('/api/watchlist/scan/status');
+ const statusData = await statusResponse.json();
+ const scanStatus = statusData.success ? statusData.status : 'idle';
+
+ // Build modal content
+ modal.innerHTML = `
+
+
+
+
+
+
+
+
+
+ ${artistsData.artists.map(artist => `
+
+
+ ${escapeHtml(artist.artist_name)}
+ Added ${new Date(artist.date_added).toLocaleDateString()}
+ ${artist.last_scan_timestamp ? `
+ Last scanned ${new Date(artist.last_scan_timestamp).toLocaleDateString()}
+ ` : ''}
+
+
+
+ `).join('')}
+
+
+
+
+
+ `;
+
+ // Show modal
+ modal.style.display = 'flex';
+
+ // Start polling for scan status if scanning
+ if (scanStatus === 'scanning') {
+ pollWatchlistScanStatus();
+ }
+
+ } catch (error) {
+ console.error('Error showing watchlist modal:', error);
+ }
+}
+
+/**
+ * Close watchlist modal
+ */
+function closeWatchlistModal() {
+ const modal = document.getElementById('watchlist-modal');
+ if (modal) {
+ modal.style.display = 'none';
+ }
+}
+
+/**
+ * Start watchlist scan
+ */
+async function startWatchlistScan() {
+ try {
+ const button = document.getElementById('scan-watchlist-btn');
+ button.disabled = true;
+ button.textContent = 'Starting scan...';
+
+ const response = await fetch('/api/watchlist/scan', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' }
+ });
+
+ const data = await response.json();
+ if (!data.success) {
+ throw new Error(data.error || 'Failed to start scan');
+ }
+
+ button.textContent = 'Scanning...';
+
+ // Show scan status
+ const statusDiv = document.getElementById('watchlist-scan-status');
+ if (statusDiv) {
+ statusDiv.style.display = 'flex';
+ document.getElementById('scan-status-text').textContent = 'scanning';
+ }
+
+ // Start polling for updates
+ pollWatchlistScanStatus();
+
+ } catch (error) {
+ console.error('Error starting watchlist scan:', error);
+ const button = document.getElementById('scan-watchlist-btn');
+ button.disabled = false;
+ button.textContent = 'Scan for New Releases';
+ alert(`Error starting scan: ${error.message}`);
+ }
+}
+
+/**
+ * Poll watchlist scan status
+ */
+async function pollWatchlistScanStatus() {
+ try {
+ const response = await fetch('/api/watchlist/scan/status');
+ const data = await response.json();
+
+ if (data.success) {
+ const statusText = document.getElementById('scan-status-text');
+ const button = document.getElementById('scan-watchlist-btn');
+
+ if (statusText) {
+ // Show detailed progress if scanning
+ if (data.status === 'scanning' && data.current_artist_name) {
+ const artistProgress = `${data.current_artist_index || 0}/${data.total_artists || 0}`;
+ let detailText = `Scanning ${data.current_artist_name} (${artistProgress})`;
+
+ if (data.current_phase === 'fetching_discography') {
+ detailText += ' - Fetching releases...';
+ } else if (data.current_phase === 'checking_albums' && data.albums_to_check > 0) {
+ const albumProgress = `${data.albums_checked || 0}/${data.albums_to_check}`;
+ detailText += ` - Checking albums (${albumProgress})`;
+ } else if (data.current_phase && data.current_phase.startsWith('checking_album_')) {
+ detailText += ` - "${data.current_album || 'Unknown Album'}"`;
+ } else if (data.current_phase === 'rate_limiting') {
+ detailText += ' - Rate limiting...';
+ }
+
+ // Add running totals
+ if (data.tracks_found_this_scan > 0 || data.tracks_added_this_scan > 0) {
+ detailText += ` | Found: ${data.tracks_found_this_scan || 0}, Added: ${data.tracks_added_this_scan || 0}`;
+ }
+
+ statusText.textContent = detailText;
+ } else {
+ statusText.textContent = data.status;
+ }
+ }
+
+ if (data.status === 'completed') {
+ if (button) {
+ button.disabled = false;
+ button.textContent = 'Scan for New Releases';
+ }
+
+ // Update status display with results
+ const statusDiv = document.getElementById('watchlist-scan-status');
+ if (statusDiv && data.summary) {
+ const newTracks = data.summary.new_tracks_found || 0;
+ const addedTracks = data.summary.tracks_added_to_wishlist || 0;
+ const totalArtists = data.summary.total_artists || 0;
+ const successfulScans = data.summary.successful_scans || 0;
+
+ let completionMessage = `Scan completed: ${successfulScans}/${totalArtists} artists scanned`;
+ if (newTracks > 0) {
+ completionMessage += `, found ${newTracks} new track${newTracks !== 1 ? 's' : ''}`;
+ if (addedTracks > 0) {
+ completionMessage += `, added ${addedTracks} to wishlist`;
+ }
+ } else {
+ completionMessage += ', no new tracks found';
+ }
+
+ statusDiv.innerHTML = `
+
+ ${completionMessage}
+
+ `;
+ }
+
+ // Update watchlist count
+ updateWatchlistButtonCount();
+
+ console.log('Watchlist scan completed:', data.summary);
+ return; // Stop polling
+
+ } else if (data.status === 'error') {
+ if (button) {
+ button.disabled = false;
+ button.textContent = 'Scan for New Releases';
+ }
+ console.error('Watchlist scan error:', data.error);
+ return; // Stop polling
+ }
+ }
+
+ // Continue polling if still scanning
+ if (data.success && data.status === 'scanning') {
+ setTimeout(pollWatchlistScanStatus, 2000); // Poll every 2 seconds
+ }
+
+ } catch (error) {
+ console.error('Error polling watchlist scan status:', error);
+ }
+}
+
+/**
+ * Remove artist from watchlist via modal
+ */
+async function removeFromWatchlistModal(artistId, artistName) {
+ try {
+ 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 || 'Failed to remove from watchlist');
+ }
+
+ console.log(`❌ Removed ${artistName} from watchlist`);
+
+ // Refresh the modal
+ showWatchlistModal();
+
+ // Update button count
+ updateWatchlistButtonCount();
+
+ // Update any visible artist cards
+ updateArtistCardWatchlistStatus();
+
+ } catch (error) {
+ console.error('Error removing from watchlist:', error);
+ alert(`Error removing ${artistName} from watchlist: ${error.message}`);
+ }
+}
+
// --- Global Cleanup on Page Unload ---
// Note: Automatic wishlist processing now runs server-side and continues even when browser is closed
\ No newline at end of file
diff --git a/webui/static/style.css b/webui/static/style.css
index 036ec57c..8cfc5258 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -5099,6 +5099,66 @@ body {
transform: translateY(-1px);
}
+/* Watchlist Modal Styles */
+.watchlist-artists-list {
+ max-height: 400px;
+ overflow-y: auto;
+ margin-top: 20px;
+}
+
+.watchlist-artist-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px 20px 16px 24px;
+ margin-bottom: 12px;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 12px;
+ transition: all 0.2s ease;
+}
+
+.watchlist-artist-item:hover {
+ background: rgba(255, 255, 255, 0.06);
+ border-color: rgba(29, 185, 84, 0.2);
+ transform: translateY(-1px);
+}
+
+.watchlist-artist-info {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ flex: 1;
+}
+
+.watchlist-artist-name {
+ font-size: 16px;
+ font-weight: 600;
+ color: #ffffff;
+ margin-bottom: 4px;
+}
+
+.watchlist-artist-date {
+ font-size: 13px;
+ color: rgba(255, 255, 255, 0.6);
+ font-weight: 400;
+}
+
+.watchlist-artist-scan {
+ font-size: 12px;
+ color: rgba(29, 185, 84, 0.7);
+ font-weight: 500;
+}
+
+.watchlist-actions {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ justify-content: center;
+ padding: 20px 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+}
+
/* Add these styles to the end of style.css */
.sync-progress-indicator {
@@ -6022,6 +6082,66 @@ body {
font-size: 12px;
}
+/* Artist Card Actions (Watchlist) */
+.artist-card-actions {
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.watchlist-toggle-btn {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 16px;
+ border: none;
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.08);
+ color: #e0e0e0;
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ width: 100%;
+ justify-content: center;
+ font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+}
+
+.watchlist-toggle-btn:hover:not(:disabled) {
+ background: rgba(29, 185, 84, 0.15);
+ color: #ffffff;
+ border-color: rgba(29, 185, 84, 0.3);
+ transform: translateY(-1px);
+}
+
+.watchlist-toggle-btn.watching {
+ background: rgba(255, 193, 7, 0.15);
+ color: #ffc107;
+ border-color: rgba(255, 193, 7, 0.3);
+}
+
+.watchlist-toggle-btn.watching:hover:not(:disabled) {
+ background: rgba(255, 193, 7, 0.25);
+ color: #ffffff;
+ border-color: rgba(255, 193, 7, 0.5);
+}
+
+.watchlist-toggle-btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.watchlist-icon {
+ font-size: 14px;
+}
+
+.watchlist-text {
+ flex: 1;
+ text-align: center;
+}
+
/* Artist Card Animation States */
.artist-card.loading {
animation: artistCardPulse 2s ease-in-out infinite;
@@ -6109,12 +6229,20 @@ body {
.artist-detail-header {
display: flex;
align-items: center;
+ justify-content: space-between;
gap: 30px;
margin-bottom: 40px;
padding-bottom: 30px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
+.artist-detail-header-left {
+ display: flex;
+ align-items: center;
+ gap: 30px;
+ flex: 1;
+}
+
.artist-detail-back-button {
display: flex;
align-items: center;
@@ -6139,6 +6267,49 @@ body {
transform: translateY(-1px);
}
+.artist-detail-watchlist-btn {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 18px;
+ font-size: 14px;
+ font-weight: 600;
+ color: #e0e0e0;
+
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 16px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ outline: none;
+ font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif;
+}
+
+.artist-detail-watchlist-btn:hover:not(:disabled) {
+ background: rgba(29, 185, 84, 0.15);
+ color: #ffffff;
+ border-color: rgba(29, 185, 84, 0.3);
+ transform: translateY(-1px);
+}
+
+.artist-detail-watchlist-btn.watching {
+ background: rgba(255, 193, 7, 0.15);
+ color: #ffc107;
+ border-color: rgba(255, 193, 7, 0.3);
+}
+
+.artist-detail-watchlist-btn.watching:hover:not(:disabled) {
+ background: rgba(255, 193, 7, 0.25);
+ color: #ffffff;
+ border-color: rgba(255, 193, 7, 0.5);
+}
+
+.artist-detail-watchlist-btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ transform: none;
+}
+
.artist-detail-info {
display: flex;
align-items: center;