Cache similar artist metadata at scan time to eliminate redundant Spotify API calls

This commit is contained in:
Broque Thomas 2026-03-13 13:35:54 -07:00
parent ae6fb929bf
commit 7da7f3b112
4 changed files with 134 additions and 39 deletions

View file

@ -1483,14 +1483,17 @@ class WatchlistScanner:
stored_count = 0 stored_count = 0
for rank, similar_artist in enumerate(similar_artists, 1): for rank, similar_artist in enumerate(similar_artists, 1):
try: try:
# similar_artist has 'name', 'spotify_id', and 'itunes_id' keys # similar_artist has 'name', 'spotify_id', 'itunes_id', 'image_url', 'genres', 'popularity'
success = self.database.add_or_update_similar_artist( success = self.database.add_or_update_similar_artist(
source_artist_id=source_artist_id, source_artist_id=source_artist_id,
similar_artist_name=similar_artist['name'], similar_artist_name=similar_artist['name'],
similar_artist_spotify_id=similar_artist.get('spotify_id'), similar_artist_spotify_id=similar_artist.get('spotify_id'),
similar_artist_itunes_id=similar_artist.get('itunes_id'), similar_artist_itunes_id=similar_artist.get('itunes_id'),
similarity_rank=rank, similarity_rank=rank,
profile_id=profile_id profile_id=profile_id,
image_url=similar_artist.get('image_url'),
genres=similar_artist.get('genres'),
popularity=similar_artist.get('popularity', 0)
) )
if success: if success:

View file

@ -5216,25 +5216,36 @@ class MusicDatabase:
similar_artist_spotify_id: Optional[str] = None, similar_artist_spotify_id: Optional[str] = None,
similar_artist_itunes_id: Optional[str] = None, similar_artist_itunes_id: Optional[str] = None,
similarity_rank: int = 1, similarity_rank: int = 1,
profile_id: int = 1) -> bool: profile_id: int = 1,
image_url: Optional[str] = None,
genres: Optional[list] = None,
popularity: int = 0) -> bool:
"""Add or update a similar artist recommendation (supports both Spotify and iTunes IDs)""" """Add or update a similar artist recommendation (supports both Spotify and iTunes IDs)"""
try: try:
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
genres_json = json.dumps(genres) if genres else None
# Use artist name as the unique key (allows storing both IDs for same artist) # Use artist name as the unique key (allows storing both IDs for same artist)
cursor.execute(""" cursor.execute("""
INSERT INTO similar_artists INSERT INTO similar_artists
(source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id, similar_artist_name, similarity_rank, occurrence_count, last_updated, profile_id) (source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id, similar_artist_name,
VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, ?) similarity_rank, occurrence_count, last_updated, profile_id,
image_url, genres, popularity, metadata_updated_at)
VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(profile_id, source_artist_id, similar_artist_name) ON CONFLICT(profile_id, source_artist_id, similar_artist_name)
DO UPDATE SET DO UPDATE SET
similar_artist_spotify_id = COALESCE(excluded.similar_artist_spotify_id, similar_artist_spotify_id), similar_artist_spotify_id = COALESCE(excluded.similar_artist_spotify_id, similar_artist_spotify_id),
similar_artist_itunes_id = COALESCE(excluded.similar_artist_itunes_id, similar_artist_itunes_id), similar_artist_itunes_id = COALESCE(excluded.similar_artist_itunes_id, similar_artist_itunes_id),
similarity_rank = excluded.similarity_rank, similarity_rank = excluded.similarity_rank,
occurrence_count = occurrence_count + 1, occurrence_count = occurrence_count + 1,
last_updated = CURRENT_TIMESTAMP last_updated = CURRENT_TIMESTAMP,
""", (source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id, similar_artist_name, similarity_rank, profile_id)) image_url = COALESCE(excluded.image_url, image_url),
genres = COALESCE(excluded.genres, genres),
popularity = CASE WHEN excluded.popularity > 0 THEN excluded.popularity ELSE popularity END,
metadata_updated_at = CASE WHEN excluded.image_url IS NOT NULL THEN CURRENT_TIMESTAMP ELSE metadata_updated_at END
""", (source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id, similar_artist_name,
similarity_rank, profile_id, image_url, genres_json, popularity))
conn.commit() conn.commit()
return True return True
@ -5338,6 +5349,29 @@ class MusicDatabase:
logger.error(f"Error updating similar artist metadata: {e}") logger.error(f"Error updating similar artist metadata: {e}")
return False return False
def update_similar_artist_metadata_by_external_id(self, external_id: str, source: str = 'spotify',
image_url: str = None, genres: list = None,
popularity: int = None) -> bool:
"""Cache artist metadata by Spotify or iTunes ID (updates all rows for that artist)"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
genres_json = json.dumps(genres) if genres else None
if source == 'spotify':
where_clause = "similar_artist_spotify_id = ?"
else:
where_clause = "similar_artist_itunes_id = ?"
cursor.execute(f"""
UPDATE similar_artists
SET image_url = ?, genres = ?, popularity = ?, metadata_updated_at = CURRENT_TIMESTAMP
WHERE {where_clause}
""", (image_url, genres_json, popularity or 0, external_id))
conn.commit()
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error updating similar artist metadata by external ID: {e}")
return False
def has_fresh_similar_artists(self, source_artist_id: str, days_threshold: int = 30, require_itunes: bool = True, require_spotify: bool = False, profile_id: int = 1) -> bool: def has_fresh_similar_artists(self, source_artist_id: str, days_threshold: int = 30, require_itunes: bool = True, require_spotify: bool = False, profile_id: int = 1) -> bool:
""" """
Check if we have cached similar artists that are still fresh (<days_threshold old). Check if we have cached similar artists that are still fresh (<days_threshold old).

View file

@ -29142,7 +29142,7 @@ def get_discover_similar_artists():
else: else:
artist_id = artist.similar_artist_itunes_id or artist.similar_artist_spotify_id artist_id = artist.similar_artist_itunes_id or artist.similar_artist_spotify_id
result_artists.append({ artist_data = {
"artist_id": artist_id, "artist_id": artist_id,
"spotify_artist_id": artist.similar_artist_spotify_id, "spotify_artist_id": artist.similar_artist_spotify_id,
"itunes_artist_id": artist.similar_artist_itunes_id, "itunes_artist_id": artist.similar_artist_itunes_id,
@ -29150,7 +29150,15 @@ def get_discover_similar_artists():
"occurrence_count": artist.occurrence_count, "occurrence_count": artist.occurrence_count,
"similarity_rank": artist.similarity_rank, "similarity_rank": artist.similarity_rank,
"source": active_source, "source": active_source,
}) }
# Include cached metadata if available
if artist.image_url:
artist_data["image_url"] = artist.image_url
if artist.genres:
artist_data["genres"] = artist.genres[:3]
if artist.popularity:
artist_data["popularity"] = artist.popularity
result_artists.append(artist_data)
print(f"[Similar Artists] {len(similar_artists)} from DB, {len(result_artists)} valid for {active_source}") print(f"[Similar Artists] {len(similar_artists)} from DB, {len(result_artists)} valid for {active_source}")
@ -29168,7 +29176,9 @@ def get_discover_similar_artists():
@app.route('/api/discover/similar-artists/enrich', methods=['POST']) @app.route('/api/discover/similar-artists/enrich', methods=['POST'])
def enrich_similar_artists(): def enrich_similar_artists():
"""Enrich a batch of artist IDs with images/genres from Spotify or iTunes""" """Enrich a batch of artist IDs with images/genres from Spotify or iTunes.
Uses cached metadata from DB when available, only makes API calls for uncached artists,
and saves new results back to DB for future use."""
try: try:
data = request.get_json() data = request.get_json()
artist_ids = data.get('artist_ids', []) artist_ids = data.get('artist_ids', [])
@ -29177,37 +29187,82 @@ def enrich_similar_artists():
if not artist_ids: if not artist_ids:
return jsonify({"success": True, "artists": {}}) return jsonify({"success": True, "artists": {}})
database = get_database()
enriched = {} enriched = {}
uncached_ids = []
if source == 'spotify' and spotify_client and spotify_client.is_authenticated() and not _spotify_rate_limited(): # Check DB cache first — get all similar artists and index by external ID
try: cached_artists = database.get_top_similar_artists(limit=500, profile_id=get_current_profile_id())
batch_result = spotify_client.sp.artists(artist_ids[:50]) cache_map = {}
if batch_result and 'artists' in batch_result: for artist in cached_artists:
for sp_artist in batch_result['artists']: ext_id = artist.similar_artist_spotify_id if source == 'spotify' else artist.similar_artist_itunes_id
if sp_artist: if ext_id and ext_id not in cache_map:
enriched[sp_artist['id']] = { cache_map[ext_id] = artist
"artist_name": sp_artist.get('name'),
"image_url": sp_artist['images'][0]['url'] if sp_artist.get('images') else None, for aid in artist_ids[:50]:
"genres": sp_artist.get('genres', [])[:3], cached = cache_map.get(aid)
"popularity": sp_artist.get('popularity', 0) if cached and cached.image_url:
} # Use cached metadata
except Exception as e: enriched[aid] = {
print(f"Error enriching Spotify batch: {e}") "artist_name": cached.similar_artist_name,
else: "image_url": cached.image_url,
from core.itunes_client import iTunesClient "genres": cached.genres[:3] if cached.genres else [],
itunes_client = iTunesClient() "popularity": cached.popularity or 0
for aid in artist_ids[:50]: }
else:
uncached_ids.append(aid)
# Only make API calls for uncached artists
if uncached_ids:
if source == 'spotify' and spotify_client and spotify_client.is_authenticated() and not _spotify_rate_limited():
try: try:
itunes_artist = itunes_client.get_artist(aid) batch_result = spotify_client.sp.artists(uncached_ids[:50])
if itunes_artist: if batch_result and 'artists' in batch_result:
enriched[aid] = { for sp_artist in batch_result['artists']:
"artist_name": itunes_artist.get('name'), if sp_artist:
"image_url": itunes_artist.get('images', [{}])[0].get('url') if itunes_artist.get('images') else None, img_url = sp_artist['images'][0].get('url') if sp_artist.get('images') else None
"genres": itunes_artist.get('genres', [])[:3], genres = sp_artist.get('genres', [])[:3]
"popularity": 0 pop = sp_artist.get('popularity', 0)
} enriched[sp_artist['id']] = {
except Exception: "artist_name": sp_artist.get('name'),
pass "image_url": img_url,
"genres": genres,
"popularity": pop
}
# Cache to DB for future use
database.update_similar_artist_metadata_by_external_id(
sp_artist['id'], 'spotify',
image_url=img_url, genres=genres, popularity=pop
)
except Exception as e:
print(f"Error enriching Spotify batch: {e}")
else:
from core.itunes_client import iTunesClient
itunes_client = iTunesClient()
for aid in uncached_ids[:50]:
try:
itunes_artist = itunes_client.get_artist(aid)
if itunes_artist:
img_url = itunes_artist.get('images', [{}])[0].get('url') if itunes_artist.get('images') else None
genres = itunes_artist.get('genres', [])[:3]
enriched[aid] = {
"artist_name": itunes_artist.get('name'),
"image_url": img_url,
"genres": genres,
"popularity": 0
}
# Cache to DB for future use
database.update_similar_artist_metadata_by_external_id(
aid, 'itunes',
image_url=img_url, genres=genres, popularity=0
)
except Exception:
pass
cached_count = len(enriched) - len([aid for aid in uncached_ids if aid in enriched])
api_count = len([aid for aid in uncached_ids if aid in enriched])
if uncached_ids:
print(f"[Enrich] {cached_count} from cache, {api_count} from API ({len(uncached_ids) - api_count} missed)")
return jsonify({"success": True, "artists": enriched}) return jsonify({"success": True, "artists": enriched})

View file

@ -42118,9 +42118,12 @@ async function openRecommendedArtistsModal() {
renderRecommendedArtistsModal(modal, data.artists); renderRecommendedArtistsModal(modal, data.artists);
// Phase 2: Enrich with images/genres progressively in batches of 50 // Phase 2: Enrich with images/genres progressively in batches of 50
// Skip artists that already have cached metadata from the initial response
const source = data.source || 'spotify'; const source = data.source || 'spotify';
const idKey = source === 'spotify' ? 'spotify_artist_id' : 'itunes_artist_id'; const idKey = source === 'spotify' ? 'spotify_artist_id' : 'itunes_artist_id';
const allIds = data.artists.map(a => a[idKey]).filter(Boolean); const allIds = data.artists
.filter(a => !a.image_url) // Only enrich artists without cached images
.map(a => a[idKey]).filter(Boolean);
for (let i = 0; i < allIds.length; i += 50) { for (let i = 0; i < allIds.length; i += 50) {
const batchIds = allIds.slice(i, i + 50); const batchIds = allIds.slice(i, i + 50);