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:
parent
4f56cdf365
commit
48b188446b
4 changed files with 282 additions and 5 deletions
|
|
@ -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()
|
||||
|
|
|
|||
176
web_server.py
176
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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue