From 1749bb62a5322768a04d57625a689a0f9585919d Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Mon, 24 Nov 2025 17:16:12 -0800 Subject: [PATCH] foundation for allowing user to decide which releases to grab per watchlist artist --- database/music_database.py | 27 ++++ web_server.py | 111 ++++++++++++++ webui/index.html | 67 ++++++++ webui/static/script.js | 202 +++++++++++++++++++++++- webui/static/style.css | 307 +++++++++++++++++++++++++++++++++++++ 5 files changed, 712 insertions(+), 2 deletions(-) diff --git a/database/music_database.py b/database/music_database.py index 0267f3be..6ee0c645 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -86,6 +86,9 @@ class WatchlistArtist: created_at: Optional[datetime] = None updated_at: Optional[datetime] = None image_url: Optional[str] = None + include_albums: bool = True + include_eps: bool = True + include_singles: bool = True @dataclass class SimilarArtist: @@ -262,6 +265,9 @@ class MusicDatabase: # Add image_url column to watchlist_artists (migration) self._add_watchlist_artist_image_column(cursor) + # Add album type filter columns to watchlist_artists (migration) + self._add_watchlist_album_type_filters(cursor) + conn.commit() logger.info("Database initialized successfully") @@ -591,6 +597,27 @@ class MusicDatabase: logger.error(f"Error adding image_url column to watchlist_artists: {e}") # Don't raise - this is a migration, database can still function + def _add_watchlist_album_type_filters(self, cursor): + """Add album type filter columns to watchlist_artists table""" + try: + cursor.execute("PRAGMA table_info(watchlist_artists)") + columns = [column[1] for column in cursor.fetchall()] + + columns_to_add = { + 'include_albums': ('INTEGER', '1'), # 1 = True (include albums) + 'include_eps': ('INTEGER', '1'), # 1 = True (include EPs) + 'include_singles': ('INTEGER', '1') # 1 = True (include singles) + } + + for column_name, (column_type, default_value) in columns_to_add.items(): + if column_name not in columns: + cursor.execute(f"ALTER TABLE watchlist_artists ADD COLUMN {column_name} {column_type} DEFAULT {default_value}") + logger.info(f"Added {column_name} column to watchlist_artists table") + + except Exception as e: + logger.error(f"Error adding album type filter columns to watchlist_artists: {e}") + # Don't raise - this is a migration, database can still function + def close(self): """Close database connection (no-op since we create connections per operation)""" # Each operation creates and closes its own connection, so nothing to do here diff --git a/web_server.py b/web_server.py index f84506e4..066e6bb6 100644 --- a/web_server.py +++ b/web_server.py @@ -12,6 +12,7 @@ import shutil import glob import uuid import re +import sqlite3 from pathlib import Path from urllib.parse import urljoin @@ -14915,6 +14916,116 @@ def get_similar_artists_update_status(): print(f"Error getting similar artists status: {e}") return jsonify({"success": False, "error": str(e)}), 500 +@app.route('/api/watchlist/artist//config', methods=['GET', 'POST']) +def watchlist_artist_config(artist_id): + """Get or update watchlist artist configuration""" + try: + from database.music_database import get_database + + database = get_database() + + if request.method == 'GET': + # Get current config from database + conn = sqlite3.connect(str(database.database_path)) + cursor = conn.cursor() + cursor.execute(""" + SELECT include_albums, include_eps, include_singles, artist_name, image_url + FROM watchlist_artists + WHERE spotify_artist_id = ? + """, (artist_id,)) + result = cursor.fetchone() + conn.close() + + if not result: + return jsonify({"success": False, "error": "Artist not found in watchlist"}), 404 + + # Get artist info from Spotify + artist_info = None + if spotify_client and spotify_client.is_authenticated(): + try: + artist_data = spotify_client.sp.artist(artist_id) + if artist_data: + artist_info = { + 'id': artist_data['id'], + 'name': artist_data['name'], + 'image_url': artist_data['images'][0]['url'] if artist_data.get('images') else None, + 'followers': artist_data.get('followers', {}).get('total', 0), + 'popularity': artist_data.get('popularity', 0), + 'genres': artist_data.get('genres', []) + } + except Exception as e: + print(f"Warning: Could not fetch artist info from Spotify: {e}") + + # Fallback to database info if Spotify fetch failed + if not artist_info: + artist_info = { + 'id': artist_id, + 'name': result[3], # artist_name + 'image_url': result[4], # image_url + 'followers': 0, + 'popularity': 0, + 'genres': [] + } + + config = { + 'include_albums': bool(result[0]), # Convert INTEGER to boolean + 'include_eps': bool(result[1]), + 'include_singles': bool(result[2]) + } + + return jsonify({ + "success": True, + "config": config, + "artist": artist_info + }) + + else: # POST + data = request.get_json() + if not data: + return jsonify({"success": False, "error": "No data provided"}), 400 + + include_albums = data.get('include_albums', True) + include_eps = data.get('include_eps', True) + include_singles = data.get('include_singles', True) + + # Validate at least one is selected + if not (include_albums or include_eps or include_singles): + return jsonify({"success": False, "error": "At least one release type must be selected"}), 400 + + # Update database + conn = sqlite3.connect(str(database.database_path)) + cursor = conn.cursor() + cursor.execute(""" + UPDATE watchlist_artists + SET include_albums = ?, include_eps = ?, include_singles = ?, updated_at = CURRENT_TIMESTAMP + WHERE spotify_artist_id = ? + """, (int(include_albums), int(include_eps), int(include_singles), artist_id)) + conn.commit() + + if cursor.rowcount == 0: + conn.close() + return jsonify({"success": False, "error": "Artist not found in watchlist"}), 404 + + conn.close() + + print(f"✅ Updated watchlist config for artist {artist_id}: albums={include_albums}, eps={include_eps}, singles={include_singles}") + + return jsonify({ + "success": True, + "message": "Artist configuration updated successfully", + "config": { + 'include_albums': include_albums, + 'include_eps': include_eps, + 'include_singles': include_singles + } + }) + + except Exception as e: + print(f"Error in watchlist artist config: {e}") + import traceback + traceback.print_exc() + return jsonify({"success": False, "error": str(e)}), 500 + def _update_similar_artists_worker(): """Background worker to update similar artists for all watchlist artists""" global similar_artists_update_state diff --git a/webui/index.html b/webui/index.html index a37d9050..80dd5cba 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2790,6 +2790,73 @@ + + +
diff --git a/webui/static/script.js b/webui/static/script.js index 85afc71c..6ca9e53b 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -19822,7 +19822,10 @@ async function showWatchlistModal() {
${artistsData.artists.map(artist => ` -
+
${artist.image_url ? ` ${escapeHtml(artist.artist_name)}
@@ -19867,6 +19871,22 @@ async function showWatchlistModal() { }); }); + // Add click handlers to artist items (except for remove button) + modal.querySelectorAll('.watchlist-artist-item').forEach(item => { + item.addEventListener('click', (e) => { + // Don't trigger if clicking the remove button + if (e.target.closest('.watchlist-remove-btn')) { + return; + } + + const artistId = item.getAttribute('data-artist-id'); + const artistName = item.querySelector('.watchlist-artist-name').textContent; + + console.log(`🎵 Artist card clicked: ${artistName} (${artistId})`); + openWatchlistArtistConfigModal(artistId, artistName); + }); + }); + // Show modal modal.style.display = 'flex'; @@ -19890,6 +19910,184 @@ function closeWatchlistModal() { } } +/** + * Open watchlist artist configuration modal + * @param {string} artistId - Spotify artist ID + * @param {string} artistName - Artist name + */ +async function openWatchlistArtistConfigModal(artistId, artistName) { + try { + console.log(`🎨 Opening config modal for artist: ${artistName} (${artistId})`); + + // Fetch artist config and info + const response = await fetch(`/api/watchlist/artist/${artistId}/config`); + const data = await response.json(); + + if (!data.success) { + console.error('Error loading artist config:', data.error); + showToast(`Error loading artist configuration: ${data.error}`, 'error'); + return; + } + + const { config, artist } = data; + + // Generate hero section + const heroHTML = ` + ${artist.image_url ? ` + ${escapeHtml(artist.name)} + ` : ''} +
+

${escapeHtml(artist.name)}

+
+
+ ${formatNumber(artist.followers)} + Followers +
+
+ ${artist.popularity}/100 + Popularity +
+
+ ${artist.genres && artist.genres.length > 0 ? ` +
+ ${artist.genres.slice(0, 3).map(genre => + `${escapeHtml(genre)}` + ).join('')} +
+ ` : ''} +
+ `; + + // Populate hero section + const heroContainer = document.getElementById('watchlist-artist-config-hero'); + if (heroContainer) { + heroContainer.innerHTML = heroHTML; + } + + // Set checkbox states + document.getElementById('config-include-albums').checked = config.include_albums; + document.getElementById('config-include-eps').checked = config.include_eps; + document.getElementById('config-include-singles').checked = config.include_singles; + + // Store artist ID for saving + const modal = document.getElementById('watchlist-artist-config-modal'); + if (modal) { + modal.setAttribute('data-artist-id', artistId); + } + + // Show modal + const overlay = document.getElementById('watchlist-artist-config-modal-overlay'); + if (overlay) { + overlay.classList.remove('hidden'); + } + + // Add save button handler + const saveBtn = document.getElementById('save-artist-config-btn'); + if (saveBtn) { + // Remove old listeners + const newSaveBtn = saveBtn.cloneNode(true); + saveBtn.parentNode.replaceChild(newSaveBtn, saveBtn); + + // Add new listener + newSaveBtn.addEventListener('click', () => saveWatchlistArtistConfig(artistId)); + } + + } catch (error) { + console.error('Error opening watchlist artist config modal:', error); + showToast(`Error: ${error.message}`, 'error'); + } +} + +/** + * Close watchlist artist configuration modal + */ +function closeWatchlistArtistConfigModal() { + const overlay = document.getElementById('watchlist-artist-config-modal-overlay'); + if (overlay) { + overlay.classList.add('hidden'); + } + + // Clear hero content + const heroContainer = document.getElementById('watchlist-artist-config-hero'); + if (heroContainer) { + heroContainer.innerHTML = ''; + } +} + +/** + * Save watchlist artist configuration + * @param {string} artistId - Spotify artist ID + */ +async function saveWatchlistArtistConfig(artistId) { + try { + const includeAlbums = document.getElementById('config-include-albums').checked; + const includeEps = document.getElementById('config-include-eps').checked; + const includeSingles = document.getElementById('config-include-singles').checked; + + // Validate at least one is selected + if (!includeAlbums && !includeEps && !includeSingles) { + showToast('Please select at least one release type', 'error'); + return; + } + + // Disable save button + const saveBtn = document.getElementById('save-artist-config-btn'); + if (saveBtn) { + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + } + + // Send update to backend + const response = await fetch(`/api/watchlist/artist/${artistId}/config`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + include_albums: includeAlbums, + include_eps: includeEps, + include_singles: includeSingles + }) + }); + + const data = await response.json(); + + if (data.success) { + showToast('Artist preferences saved successfully', 'success'); + closeWatchlistArtistConfigModal(); + + // Refresh watchlist modal if it's open + const watchlistModal = document.getElementById('watchlist-modal'); + if (watchlistModal && watchlistModal.style.display === 'flex') { + await showWatchlistModal(); + } + } else { + showToast(`Error saving preferences: ${data.error}`, 'error'); + } + + } catch (error) { + console.error('Error saving watchlist artist config:', error); + showToast(`Error: ${error.message}`, 'error'); + } finally { + // Re-enable save button + const saveBtn = document.getElementById('save-artist-config-btn'); + if (saveBtn) { + saveBtn.disabled = false; + saveBtn.textContent = 'Save Preferences'; + } + } +} + +/** + * Format large numbers with commas + * @param {number} num - Number to format + * @returns {string} Formatted number + */ +function formatNumber(num) { + if (!num) return '0'; + return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} + /** * Filter watchlist artists based on search input */ diff --git a/webui/static/style.css b/webui/static/style.css index 18af6d3d..f27069fe 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -7960,6 +7960,313 @@ body { border-bottom: 1px solid rgba(255, 255, 255, 0.08); } +/* ===== WATCHLIST ARTIST CONFIG MODAL STYLES ===== */ + +#watchlist-artist-config-modal-overlay { + z-index: 11000; /* Higher than default modal-overlay to appear on top of watchlist modal */ +} + +.watchlist-artist-config-modal { + background: linear-gradient(135deg, + rgba(26, 26, 26, 0.98) 0%, + rgba(18, 18, 18, 0.98) 100%); + border-radius: 24px; + box-shadow: 0 25px 80px rgba(0, 0, 0, 0.6); + max-width: 580px; + width: 90%; + max-height: 90vh; + overflow: hidden; + display: flex; + flex-direction: column; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.watchlist-artist-config-header { + position: relative; + background: linear-gradient(180deg, + rgba(29, 185, 84, 0.15) 0%, + rgba(29, 185, 84, 0.05) 50%, + transparent 100%); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.watchlist-artist-config-hero { + padding: 32px 28px; + display: flex; + align-items: center; + gap: 24px; +} + +.watchlist-artist-config-hero-image { + width: 100px; + height: 100px; + border-radius: 50%; + object-fit: cover; + border: 3px solid rgba(29, 185, 84, 0.3); + box-shadow: 0 8px 24px rgba(29, 185, 84, 0.2); + flex-shrink: 0; +} + +.watchlist-artist-config-hero-info { + flex: 1; + min-width: 0; +} + +.watchlist-artist-config-hero-name { + font-size: 28px; + font-weight: 700; + color: #ffffff; + margin-bottom: 8px; + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.watchlist-artist-config-hero-stats { + display: flex; + gap: 20px; + margin-top: 12px; + flex-wrap: wrap; +} + +.watchlist-artist-config-stat { + display: flex; + flex-direction: column; + gap: 2px; +} + +.watchlist-artist-config-stat-value { + font-size: 18px; + font-weight: 600; + color: rgba(29, 185, 84, 1); +} + +.watchlist-artist-config-stat-label { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.watchlist-artist-config-hero-genres { + margin-top: 12px; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.watchlist-artist-config-genre-tag { + padding: 4px 12px; + background: rgba(29, 185, 84, 0.15); + border: 1px solid rgba(29, 185, 84, 0.3); + border-radius: 12px; + font-size: 12px; + color: rgba(29, 185, 84, 1); + font-weight: 500; +} + +.watchlist-artist-config-close { + position: absolute; + top: 20px; + right: 20px; + font-size: 32px; + color: rgba(255, 255, 255, 0.6); + cursor: pointer; + transition: all 0.2s ease; + line-height: 1; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + background: rgba(0, 0, 0, 0.3); +} + +.watchlist-artist-config-close:hover { + color: #ffffff; + background: rgba(255, 59, 48, 0.2); + transform: scale(1.1); +} + +.watchlist-artist-config-content { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.watchlist-artist-config-body { + padding: 28px; + flex: 1; +} + +.config-section { + margin-bottom: 24px; +} + +.config-section-title { + font-size: 20px; + font-weight: 600; + color: #ffffff; + margin-bottom: 8px; +} + +.config-section-subtitle { + font-size: 14px; + color: rgba(255, 255, 255, 0.6); + margin-bottom: 24px; +} + +.config-options { + display: flex; + flex-direction: column; + gap: 16px; +} + +.config-option { + display: flex; + align-items: center; + padding: 20px; + background: rgba(255, 255, 255, 0.03); + border: 2px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + cursor: pointer; + transition: all 0.3s ease; + position: relative; +} + +.config-option:hover { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(29, 185, 84, 0.3); + transform: translateY(-2px); +} + +.config-option input[type="checkbox"] { + width: 24px; + height: 24px; + margin-right: 16px; + cursor: pointer; + accent-color: #1db954; + flex-shrink: 0; +} + +.config-option-content { + display: flex; + align-items: center; + flex: 1; + gap: 16px; +} + +.config-option-icon { + font-size: 32px; + flex-shrink: 0; +} + +.config-option-text { + display: flex; + flex-direction: column; + gap: 4px; +} + +.config-option-title { + font-size: 16px; + font-weight: 600; + color: #ffffff; +} + +.config-option-description { + font-size: 13px; + color: rgba(255, 255, 255, 0.5); +} + +.config-option input[type="checkbox"]:checked + .config-option-content { + opacity: 1; +} + +.config-option input[type="checkbox"]:not(:checked) + .config-option-content { + opacity: 0.5; +} + +.watchlist-artist-config-footer { + padding: 20px 28px; + border-top: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(0, 0, 0, 0.2); +} + +.config-modal-actions { + display: flex; + gap: 12px; + justify-content: flex-end; +} + +.config-modal-btn { + padding: 12px 28px; + border-radius: 12px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + border: none; + outline: none; +} + +.config-modal-btn-primary { + background: linear-gradient(135deg, #1ed760, #1db954); + color: #000; + box-shadow: 0 4px 16px rgba(29, 185, 84, 0.3); +} + +.config-modal-btn-primary:hover { + background: linear-gradient(135deg, #1fdf64, #1ed760); + box-shadow: 0 6px 20px rgba(29, 185, 84, 0.4); + transform: translateY(-1px); +} + +.config-modal-btn-primary:active { + transform: translateY(0); +} + +.config-modal-btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.config-modal-btn-secondary { + background: rgba(255, 255, 255, 0.08); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.15); +} + +.config-modal-btn-secondary:hover { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.25); +} + +/* Mobile Responsive */ +@media (max-width: 640px) { + .watchlist-artist-config-modal { + width: 95%; + max-width: none; + } + + .watchlist-artist-config-hero { + flex-direction: column; + text-align: center; + padding: 24px 20px; + } + + .watchlist-artist-config-hero-stats { + justify-content: center; + } + + .config-modal-actions { + flex-direction: column-reverse; + } + + .config-modal-btn { + width: 100%; + } +} + /* Add these styles to the end of style.css */ .sync-progress-indicator {