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)
This commit is contained in:
Broque Thomas 2026-02-06 09:24:50 -08:00
parent 4f56cdf365
commit 48b188446b
4 changed files with 282 additions and 5 deletions

View file

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

View file

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

View file

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

View file

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