From 48b188446bfc347797a128331a168a5c7fd81070 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Fri, 6 Feb 2026 09:24:50 -0800 Subject: [PATCH] feat: discovery match cache, mobile sync layout fixes - Add global discovery_match_cache table to cache successful track matches (title+artist+provider -> matched result) across all discovery sources - Cache check before API search in YouTube, ListenBrainz, Tidal, and Beatport discovery workers; cache write after high-confidence matches - Re-discovering playlists or overlapping tracks across sources skips API lookups - Fix Spotify tab sidebar forcing 2-column grid on mobile via inline JS styles - Add mobile responsive styles for Spotify playlist cards (stack layout vertically) --- database/music_database.py | 67 ++++++++++++++ web_server.py | 176 ++++++++++++++++++++++++++++++++++++- webui/static/mobile.css | 39 ++++++++ webui/static/script.js | 5 +- 4 files changed, 282 insertions(+), 5 deletions(-) diff --git a/database/music_database.py b/database/music_database.py index 7701eedc..594e5185 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -753,6 +753,25 @@ class MusicDatabase: cursor.execute("CREATE INDEX IF NOT EXISTS idx_listenbrainz_tracks_position ON listenbrainz_tracks (playlist_id, position)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_recent_albums_artist ON discovery_recent_albums (artist_spotify_id)") + # Discovery Match Cache - caches successful discovery matches across all sources + cursor.execute(""" + CREATE TABLE IF NOT EXISTS discovery_match_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + normalized_title TEXT NOT NULL, + normalized_artist TEXT NOT NULL, + provider TEXT NOT NULL, + match_confidence REAL NOT NULL, + matched_data_json TEXT NOT NULL, + original_title TEXT, + original_artist TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + use_count INTEGER DEFAULT 1, + UNIQUE(normalized_title, normalized_artist, provider) + ) + """) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_cache_lookup ON discovery_match_cache (normalized_title, normalized_artist, provider)") + logger.info("Discovery tables created successfully") except Exception as e: @@ -4330,6 +4349,54 @@ class MusicDatabase: 'error': str(e) } + # ==================== Discovery Match Cache Methods ==================== + + def get_discovery_cache_match(self, normalized_title: str, normalized_artist: str, provider: str) -> Optional[Dict]: + """Look up a cached discovery match. Returns the matched_data dict or None. + Also bumps last_used_at and use_count on hit.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT matched_data_json, match_confidence FROM discovery_match_cache + WHERE normalized_title = ? AND normalized_artist = ? AND provider = ? + """, (normalized_title, normalized_artist, provider)) + row = cursor.fetchone() + if row: + # Bump usage stats + cursor.execute(""" + UPDATE discovery_match_cache + SET last_used_at = CURRENT_TIMESTAMP, use_count = use_count + 1 + WHERE normalized_title = ? AND normalized_artist = ? AND provider = ? + """, (normalized_title, normalized_artist, provider)) + conn.commit() + return json.loads(row['matched_data_json']) + return None + except Exception as e: + logger.error(f"Error reading discovery cache: {e}") + return None + + def save_discovery_cache_match(self, normalized_title: str, normalized_artist: str, + provider: str, confidence: float, matched_data: Dict, + original_title: str = None, original_artist: str = None) -> bool: + """Save a discovery match to cache. Uses INSERT OR REPLACE for upsert.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute(""" + INSERT OR REPLACE INTO discovery_match_cache + (normalized_title, normalized_artist, provider, match_confidence, + matched_data_json, original_title, original_artist, + created_at, last_used_at, use_count) + VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1) + """, (normalized_title, normalized_artist, provider, confidence, + json.dumps(matched_data), original_title, original_artist)) + conn.commit() + return True + except Exception as e: + logger.error(f"Error saving discovery cache: {e}") + return False + # Thread-safe singleton pattern for database access _database_instances: Dict[int, MusicDatabase] = {} # Thread ID -> Database instance _database_lock = threading.Lock() diff --git a/web_server.py b/web_server.py index 89bc0ac6..b0352ba4 100644 --- a/web_server.py +++ b/web_server.py @@ -15821,6 +15821,13 @@ def update_tidal_playlist_phase(playlist_id): return jsonify({"error": str(e)}), 500 +def _get_discovery_cache_key(title, artist): + """Normalize title/artist for discovery cache lookup using matching_engine.""" + norm_title = matching_engine.clean_title(title) + norm_artist = matching_engine.clean_artist(artist) + return (norm_title, norm_artist) + + def _run_tidal_discovery_worker(playlist_id): """Background worker for Tidal discovery process (Spotify preferred, iTunes fallback)""" try: @@ -15855,6 +15862,34 @@ def _run_tidal_discovery_worker(playlist_id): try: print(f"🔍 [{i+1}/{len(playlist.tracks)}] Searching {discovery_source.upper()}: {tidal_track.name} by {', '.join(tidal_track.artists)}") + # Check discovery cache first + cache_key = _get_discovery_cache_key(tidal_track.name, tidal_track.artists[0] if tidal_track.artists else '') + try: + cache_db = get_database() + cached_match = cache_db.get_discovery_cache_match(cache_key[0], cache_key[1], discovery_source) + if cached_match: + print(f"⚡ CACHE HIT [{i+1}/{len(playlist.tracks)}]: {tidal_track.name} by {', '.join(tidal_track.artists)}") + result = { + 'tidal_track': { + 'id': tidal_track.id, + 'name': tidal_track.name, + 'artists': tidal_track.artists or [], + 'album': getattr(tidal_track, 'album', 'Unknown Album'), + 'duration_ms': getattr(tidal_track, 'duration_ms', 0), + }, + 'spotify_data': cached_match, + 'match_data': cached_match, + 'status': 'found', + 'discovery_source': discovery_source + } + successful_discoveries += 1 + state['spotify_matches'] = successful_discoveries + state['discovery_results'].append(result) + state['discovery_progress'] = int(((i + 1) / len(playlist.tracks)) * 100) + continue + except Exception as cache_err: + print(f"⚠️ Cache lookup error: {cache_err}") + # Use the search function with appropriate provider track_result = _search_spotify_for_tidal_track( tidal_track, @@ -15925,6 +15960,19 @@ def _run_tidal_discovery_worker(playlist_id): successful_discoveries += 1 state['spotify_matches'] = successful_discoveries + # Save to discovery cache if match found + if result['status'] == 'found' and result.get('match_data'): + try: + cache_db = get_database() + cache_db.save_discovery_cache_match( + cache_key[0], cache_key[1], discovery_source, 0.80, + result['match_data'], tidal_track.name, + tidal_track.artists[0] if tidal_track.artists else '' + ) + print(f"💾 CACHE SAVED: {tidal_track.name}") + except Exception as cache_err: + print(f"⚠️ Cache save error: {cache_err}") + state['discovery_results'].append(result) state['discovery_progress'] = int(((i + 1) / len(playlist.tracks)) * 100) @@ -16578,7 +16626,34 @@ def _run_youtube_discovery_worker(url_hash): cleaned_artist = track['artists'][0] if track['artists'] else 'Unknown Artist' print(f"🔍 Searching {discovery_source} for: '{cleaned_artist}' - '{cleaned_title}'") - + + # Check discovery cache first + cache_key = _get_discovery_cache_key(cleaned_title, cleaned_artist) + try: + cache_db = get_database() + cached_match = cache_db.get_discovery_cache_match(cache_key[0], cache_key[1], discovery_source) + if cached_match: + print(f"⚡ CACHE HIT [{i+1}/{len(tracks)}]: {cleaned_artist} - {cleaned_title}") + result = { + 'index': i, + 'yt_track': cleaned_title, + 'yt_artist': cleaned_artist, + 'status': '✅ Found', + 'status_class': 'found', + 'spotify_track': cached_match.get('name', ''), + 'spotify_artist': cached_match.get('artists', [''])[0] if cached_match.get('artists') else '', + 'spotify_album': cached_match.get('album', {}).get('name', '') if isinstance(cached_match.get('album'), dict) else cached_match.get('album', ''), + 'duration': f"{track['duration_ms'] // 60000}:{(track['duration_ms'] % 60000) // 1000:02d}" if track['duration_ms'] else '0:00', + 'discovery_source': discovery_source, + 'matched_data': cached_match, + 'spotify_data': cached_match + } + state['spotify_matches'] += 1 + state['discovery_results'].append(result) + continue + except Exception as cache_err: + print(f"⚠️ Cache lookup error: {cache_err}") + # Try multiple search strategies using matching_engine for better accuracy matched_track = None best_confidence = 0.0 @@ -16761,11 +16836,23 @@ def _run_youtube_discovery_worker(url_hash): } # Keep spotify_data for backward compatibility result['spotify_data'] = result['matched_data'] - + + # Save to discovery cache (only Strategy 1 high-confidence matches) + if best_confidence >= 0.7: + try: + cache_db = get_database() + cache_db.save_discovery_cache_match( + cache_key[0], cache_key[1], discovery_source, best_confidence, + result['matched_data'], cleaned_title, cleaned_artist + ) + print(f"💾 CACHE SAVED: {cleaned_artist} - {cleaned_title} (confidence: {best_confidence:.3f})") + except Exception as cache_err: + print(f"⚠️ Cache save error: {cache_err}") + state['discovery_results'].append(result) print(f" {'✅' if matched_track else '❌'} Track {i+1}/{len(tracks)}: {result['status']}") - + except Exception as e: print(f"❌ Error processing track {i}: {e}") # Add failed result @@ -16833,6 +16920,33 @@ def _run_listenbrainz_discovery_worker(playlist_mbid): print(f"🔍 Searching {discovery_source} for: '{cleaned_artist}' - '{cleaned_title}'") + # Check discovery cache first + cache_key = _get_discovery_cache_key(cleaned_title, cleaned_artist) + try: + cache_db = get_database() + cached_match = cache_db.get_discovery_cache_match(cache_key[0], cache_key[1], discovery_source) + if cached_match: + print(f"⚡ CACHE HIT [{i+1}/{len(tracks)}]: {cleaned_artist} - {cleaned_title}") + result = { + 'index': i, + 'lb_track': cleaned_title, + 'lb_artist': cleaned_artist, + 'status': '✅ Found', + 'status_class': 'found', + 'spotify_track': cached_match.get('name', ''), + 'spotify_artist': cached_match.get('artists', [''])[0] if cached_match.get('artists') else '', + 'spotify_album': cached_match.get('album', {}).get('name', '') if isinstance(cached_match.get('album'), dict) else cached_match.get('album', ''), + 'duration': f"{duration_ms // 60000}:{(duration_ms % 60000) // 1000:02d}" if duration_ms else '0:00', + 'discovery_source': discovery_source, + 'matched_data': cached_match, + 'spotify_data': cached_match + } + state['spotify_matches'] += 1 + state['discovery_results'].append(result) + continue + except Exception as cache_err: + print(f"⚠️ Cache lookup error: {cache_err}") + # Try multiple search strategies using matching_engine for better accuracy matched_track = None best_confidence = 0.0 @@ -17014,6 +17128,18 @@ def _run_listenbrainz_discovery_worker(playlist_mbid): # Keep spotify_data for backward compatibility result['spotify_data'] = result['matched_data'] + # Save to discovery cache (only Strategy 1 high-confidence matches) + if best_confidence >= 0.7: + try: + cache_db = get_database() + cache_db.save_discovery_cache_match( + cache_key[0], cache_key[1], discovery_source, best_confidence, + result['matched_data'], cleaned_title, cleaned_artist + ) + print(f"💾 CACHE SAVED: {cleaned_artist} - {cleaned_title} (confidence: {best_confidence:.3f})") + except Exception as cache_err: + print(f"⚠️ Cache save error: {cache_err}") + state['discovery_results'].append(result) print(f" {'✅' if matched_track else '❌'} Track {i+1}/{len(tracks)}: {result['status']}") @@ -23075,6 +23201,34 @@ def _run_beatport_discovery_worker(url_hash): print(f"🔍 Searching {discovery_source.upper()} for: '{track_artist}' - '{track_title}'") + # Check discovery cache first + cache_key = _get_discovery_cache_key(track_title, track_artist) + try: + cache_db = get_database() + cached_match = cache_db.get_discovery_cache_match(cache_key[0], cache_key[1], discovery_source) + if cached_match: + print(f"⚡ CACHE HIT [{i+1}/{len(tracks)}]: {track_artist} - {track_title}") + # Convert artists from ['str'] to [{'name': 'str'}] for Beatport frontend format + beatport_artists = cached_match.get('artists', []) + if beatport_artists and isinstance(beatport_artists[0], str): + cached_match['artists'] = [{'name': a} for a in beatport_artists] + result_entry = { + 'index': i, + 'beatport_track': { + 'title': track_title, + 'artist': track_artist + }, + 'status': 'found', + 'status_class': 'found', + 'discovery_source': discovery_source, + 'spotify_data': cached_match + } + state['spotify_matches'] += 1 + state['discovery_results'].append(result_entry) + continue + except Exception as cache_err: + print(f"⚠️ Cache lookup error: {cache_err}") + # Use matching engine for sophisticated track matching (like other discovery processes) found_track = None @@ -23334,6 +23488,22 @@ def _run_beatport_discovery_worker(url_hash): state['spotify_matches'] += 1 + # Save to discovery cache (normalize artists from [{name:str}] to [str] for canonical format) + if best_confidence >= 0.75: + try: + cache_data = dict(result_entry['spotify_data']) + cache_artists = cache_data.get('artists', []) + if cache_artists and isinstance(cache_artists[0], dict): + cache_data['artists'] = [a.get('name', '') for a in cache_artists] + cache_db = get_database() + cache_db.save_discovery_cache_match( + cache_key[0], cache_key[1], discovery_source, best_confidence, + cache_data, track_title, track_artist + ) + print(f"💾 CACHE SAVED: {track_artist} - {track_title} (confidence: {best_confidence:.3f})") + except Exception as cache_err: + print(f"⚠️ Cache save error: {cache_err}") + state['discovery_results'].append(result_entry) # Small delay to avoid rate limiting diff --git a/webui/static/mobile.css b/webui/static/mobile.css index 2e04a4c8..3ca1ae63 100644 --- a/webui/static/mobile.css +++ b/webui/static/mobile.css @@ -460,6 +460,45 @@ padding: 10px 8px; } + /* Spotify playlist cards - stack vertically on mobile */ + .playlist-card { + padding: 16px; + margin: 8px 4px; + border-radius: 14px; + } + + .playlist-card-main { + flex-direction: column; + align-items: stretch; + gap: 12px; + } + + .playlist-card-content { + min-width: 0; + width: 100%; + } + + .playlist-card-name { + font-size: 15px; + margin-bottom: 6px; + } + + .playlist-card-info { + font-size: 13px; + } + + .playlist-card-actions { + margin-left: 0; + display: flex; + gap: 8px; + } + + .playlist-card-actions button { + flex: 1; + padding: 10px 14px; + font-size: 12px; + } + .beatport-tabs { flex-wrap: wrap; gap: 4px; diff --git a/webui/static/script.js b/webui/static/script.js index b4c0a40f..111f6b8d 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -14712,9 +14712,10 @@ function initializeSyncPage() { }); document.getElementById(`${tabId}-tab-content`).classList.add('active'); - // Show/hide sidebar based on active tab + // Show/hide sidebar based on active tab (skip on mobile where sidebar is always hidden) if (syncSidebar && syncContentArea) { - if (tabId === 'spotify') { + const isMobile = window.innerWidth <= 1300; + if (tabId === 'spotify' && !isMobile) { syncSidebar.style.display = ''; syncContentArea.style.gridTemplateColumns = '2.5fr 0.75fr'; } else {