Harden metadata cache: prevent simplified data from overwriting full entries, fix connection leaks, and add inline TTL enforcement

This commit is contained in:
Broque Thomas 2026-03-14 13:39:12 -07:00
parent 0b8bfa1e6b
commit cf917279c2
3 changed files with 411 additions and 347 deletions

View file

@ -403,16 +403,31 @@ class iTunesClient:
""" """
Perform a batched lookup of artist IDs to get clean artist names. Perform a batched lookup of artist IDs to get clean artist names.
Returns a map of {artist_id: clean_artist_name} Returns a map of {artist_id: clean_artist_name}
Checks cache first to avoid unnecessary API calls.
""" """
if not artist_ids: if not artist_ids:
return {} return {}
clean_names = {} clean_names = {}
uncached_ids = []
# Check cache first
cache = get_metadata_cache()
for aid in artist_ids:
cached = cache.get_entity('itunes', 'artist', aid)
if cached and cached.get('artistName'):
clean_names[aid] = cached['artistName']
else:
uncached_ids.append(aid)
if not uncached_ids:
return clean_names
# iTunes lookup allows comma-separated IDs, but keep batch size reasonable (e.g. 50) # iTunes lookup allows comma-separated IDs, but keep batch size reasonable (e.g. 50)
batch_size = 50 batch_size = 50
for i in range(0, len(artist_ids), batch_size): for i in range(0, len(uncached_ids), batch_size):
batch = artist_ids[i:i+batch_size] batch = uncached_ids[i:i+batch_size]
ids_str = ",".join(batch) ids_str = ",".join(batch)
try: try:
@ -425,6 +440,8 @@ class iTunesClient:
a_name = item.get('artistName', '') a_name = item.get('artistName', '')
if a_id and a_name: if a_id and a_name:
clean_names[a_id] = a_name clean_names[a_id] = a_name
# Populate artist cache from lookup results
cache.store_entity('itunes', 'artist', a_id, item)
except Exception as e: except Exception as e:
logger.warning(f"Failed batch artist lookup: {e}") logger.warning(f"Failed batch artist lookup: {e}")
@ -571,10 +588,10 @@ class iTunesClient:
result = albums[:limit] result = albums[:limit]
# Cache individual albums + search mapping # Cache individual albums + search mapping (skip if full data already cached)
entries = [(str(ad.get('collectionId', '')), ad) for ad in raw_items if ad.get('collectionId')] entries = [(str(ad.get('collectionId', '')), ad) for ad in raw_items if ad.get('collectionId')]
if entries: if entries:
cache.store_entities_bulk('itunes', 'album', entries) cache.store_entities_bulk('itunes', 'album', entries, skip_if_exists=True)
# Only cache IDs for the albums we're actually returning # Only cache IDs for the albums we're actually returning
result_ids = [str(ad.get('collectionId', '')) for ad in raw_items[:limit] if ad.get('collectionId')] result_ids = [str(ad.get('collectionId', '')) for ad in raw_items[:limit] if ad.get('collectionId')]
if result_ids: if result_ids:
@ -782,13 +799,13 @@ class iTunesClient:
# Cache the album tracks listing # Cache the album tracks listing
cache.store_entity('itunes', 'album', f"{album_id}_tracks", result) cache.store_entity('itunes', 'album', f"{album_id}_tracks", result)
# Also cache individual tracks from the raw results # Also cache individual tracks from the raw results (skip if full data already cached)
track_entries = [] track_entries = []
for item in results: for item in results:
if item.get('wrapperType') == 'track' and item.get('kind') == 'song' and item.get('trackId'): if item.get('wrapperType') == 'track' and item.get('kind') == 'song' and item.get('trackId'):
track_entries.append((str(item['trackId']), item)) track_entries.append((str(item['trackId']), item))
if track_entries: if track_entries:
cache.store_entities_bulk('itunes', 'track', track_entries) cache.store_entities_bulk('itunes', 'track', track_entries, skip_if_exists=True)
return result return result
@ -1032,14 +1049,14 @@ class iTunesClient:
# Extract albums from dict # Extract albums from dict
albums = [item['album'] for item in seen_albums.values()] albums = [item['album'] for item in seen_albums.values()]
# Cache individual albums opportunistically # Cache individual albums opportunistically (skip if full data already cached)
album_entries = [] album_entries = []
for album_data in results: for album_data in results:
if album_data.get('wrapperType') == 'collection' and album_data.get('collectionId'): if album_data.get('wrapperType') == 'collection' and album_data.get('collectionId'):
album_entries.append((str(album_data['collectionId']), album_data)) album_entries.append((str(album_data['collectionId']), album_data))
if album_entries: if album_entries:
cache = get_metadata_cache() cache = get_metadata_cache()
cache.store_entities_bulk('itunes', 'album', album_entries) cache.store_entities_bulk('itunes', 'album', album_entries, skip_if_exists=True)
logger.info(f"Retrieved {len(albums)} unique albums for artist {artist_id} (filtered from {len(results)} results)") logger.info(f"Retrieved {len(albums)} unique albums for artist {artist_id} (filtered from {len(results)} results)")
return albums[:limit] return albums[:limit]

View file

@ -46,24 +46,36 @@ class MetadataCache:
try: try:
db = self._get_db() db = self._get_db()
conn = db._get_connection() conn = db._get_connection()
cursor = conn.cursor() try:
cursor.execute(""" cursor = conn.cursor()
SELECT id, raw_json FROM metadata_cache_entities
WHERE source = ? AND entity_type = ? AND entity_id = ?
""", (source, entity_type, entity_id))
row = cursor.fetchone()
if row:
# Touch: update access stats
cursor.execute(""" cursor.execute("""
UPDATE metadata_cache_entities SELECT id, raw_json, updated_at, ttl_days FROM metadata_cache_entities
SET last_accessed_at = CURRENT_TIMESTAMP, access_count = access_count + 1 WHERE source = ? AND entity_type = ? AND entity_id = ?
WHERE id = ? """, (source, entity_type, entity_id))
""", (row['id'],)) row = cursor.fetchone()
conn.commit() if row:
# Inline TTL check — don't serve stale data
try:
updated = datetime.fromisoformat(row['updated_at'])
age_days = (datetime.now() - updated).days
if age_days > (row['ttl_days'] or 30):
cursor.execute("DELETE FROM metadata_cache_entities WHERE id = ?", (row['id'],))
conn.commit()
return None
except (ValueError, TypeError):
pass
# Touch: update access stats
cursor.execute("""
UPDATE metadata_cache_entities
SET last_accessed_at = CURRENT_TIMESTAMP, access_count = access_count + 1
WHERE id = ?
""", (row['id'],))
conn.commit()
return json.loads(row['raw_json'])
return None
finally:
conn.close() conn.close()
return json.loads(row['raw_json'])
conn.close()
return None
except Exception as e: except Exception as e:
logger.debug(f"Cache lookup error ({source}/{entity_type}/{entity_id}): {e}") logger.debug(f"Cache lookup error ({source}/{entity_type}/{entity_id}): {e}")
return None return None
@ -77,76 +89,22 @@ class MetadataCache:
raw_json = json.dumps(raw_data, default=str) raw_json = json.dumps(raw_data, default=str)
db = self._get_db() db = self._get_db()
conn = db._get_connection() conn = db._get_connection()
cursor = conn.cursor() try:
cursor.execute(""" cursor = conn.cursor()
INSERT OR REPLACE INTO metadata_cache_entities
(source, entity_type, entity_id, name, image_url, external_urls,
genres, popularity, followers,
artist_name, artist_id, release_date, total_tracks, album_type, label,
album_name, album_id, duration_ms, track_number, disc_number, explicit, isrc, preview_url,
raw_json, updated_at, last_accessed_at, access_count)
VALUES (?, ?, ?, ?, ?, ?,
?, ?, ?,
?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?,
?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP,
COALESCE((SELECT access_count FROM metadata_cache_entities
WHERE source = ? AND entity_type = ? AND entity_id = ?), 0) + 1)
""", (
source, entity_type, entity_id,
fields.get('name', ''),
fields.get('image_url'),
fields.get('external_urls'),
fields.get('genres'),
fields.get('popularity'),
fields.get('followers'),
fields.get('artist_name'),
fields.get('artist_id'),
fields.get('release_date'),
fields.get('total_tracks'),
fields.get('album_type'),
fields.get('label'),
fields.get('album_name'),
fields.get('album_id'),
fields.get('duration_ms'),
fields.get('track_number'),
fields.get('disc_number'),
fields.get('explicit'),
fields.get('isrc'),
fields.get('preview_url'),
raw_json,
source, entity_type, entity_id,
))
conn.commit()
conn.close()
except Exception as e:
logger.debug(f"Cache store error ({source}/{entity_type}/{entity_id}): {e}")
def store_entities_bulk(self, source: str, entity_type: str, items: List[Tuple[str, dict]]) -> None:
"""Store multiple entities at once. items = [(entity_id, raw_data), ...]"""
if not items:
return
try:
db = self._get_db()
conn = db._get_connection()
cursor = conn.cursor()
for entity_id, raw_data in items:
if not entity_id or not raw_data:
continue
fields = self._extract_fields(source, entity_type, raw_data)
raw_json = json.dumps(raw_data, default=str)
cursor.execute(""" cursor.execute("""
INSERT OR REPLACE INTO metadata_cache_entities INSERT OR REPLACE INTO metadata_cache_entities
(source, entity_type, entity_id, name, image_url, external_urls, (source, entity_type, entity_id, name, image_url, external_urls,
genres, popularity, followers, genres, popularity, followers,
artist_name, artist_id, release_date, total_tracks, album_type, label, artist_name, artist_id, release_date, total_tracks, album_type, label,
album_name, album_id, duration_ms, track_number, disc_number, explicit, isrc, preview_url, album_name, album_id, duration_ms, track_number, disc_number, explicit, isrc, preview_url,
raw_json, updated_at, last_accessed_at) raw_json, updated_at, last_accessed_at, access_count)
VALUES (?, ?, ?, ?, ?, ?, VALUES (?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP,
COALESCE((SELECT access_count FROM metadata_cache_entities
WHERE source = ? AND entity_type = ? AND entity_id = ?), 0) + 1)
""", ( """, (
source, entity_type, entity_id, source, entity_type, entity_id,
fields.get('name', ''), fields.get('name', ''),
@ -170,9 +128,86 @@ class MetadataCache:
fields.get('isrc'), fields.get('isrc'),
fields.get('preview_url'), fields.get('preview_url'),
raw_json, raw_json,
source, entity_type, entity_id,
)) ))
conn.commit() conn.commit()
conn.close() finally:
conn.close()
except Exception as e:
logger.debug(f"Cache store error ({source}/{entity_type}/{entity_id}): {e}")
def store_entities_bulk(self, source: str, entity_type: str, items: List[Tuple[str, dict]],
skip_if_exists: bool = False) -> None:
"""Store multiple entities at once. items = [(entity_id, raw_data), ...]
Args:
skip_if_exists: If True, don't overwrite existing entries. Use this for
opportunistic caching of simplified data (e.g. from list endpoints)
to avoid replacing richer data from detail endpoints.
"""
if not items:
return
try:
db = self._get_db()
conn = db._get_connection()
try:
cursor = conn.cursor()
for entity_id, raw_data in items:
if not entity_id or not raw_data:
continue
if skip_if_exists:
cursor.execute("""
SELECT 1 FROM metadata_cache_entities
WHERE source = ? AND entity_type = ? AND entity_id = ?
""", (source, entity_type, entity_id))
if cursor.fetchone():
continue
fields = self._extract_fields(source, entity_type, raw_data)
raw_json = json.dumps(raw_data, default=str)
cursor.execute("""
INSERT OR REPLACE INTO metadata_cache_entities
(source, entity_type, entity_id, name, image_url, external_urls,
genres, popularity, followers,
artist_name, artist_id, release_date, total_tracks, album_type, label,
album_name, album_id, duration_ms, track_number, disc_number, explicit, isrc, preview_url,
raw_json, updated_at, last_accessed_at, access_count)
VALUES (?, ?, ?, ?, ?, ?,
?, ?, ?,
?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?,
?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP,
COALESCE((SELECT access_count FROM metadata_cache_entities
WHERE source = ? AND entity_type = ? AND entity_id = ?), 0) + 1)
""", (
source, entity_type, entity_id,
fields.get('name', ''),
fields.get('image_url'),
fields.get('external_urls'),
fields.get('genres'),
fields.get('popularity'),
fields.get('followers'),
fields.get('artist_name'),
fields.get('artist_id'),
fields.get('release_date'),
fields.get('total_tracks'),
fields.get('album_type'),
fields.get('label'),
fields.get('album_name'),
fields.get('album_id'),
fields.get('duration_ms'),
fields.get('track_number'),
fields.get('disc_number'),
fields.get('explicit'),
fields.get('isrc'),
fields.get('preview_url'),
raw_json,
source, entity_type, entity_id,
))
conn.commit()
finally:
conn.close()
except Exception as e: except Exception as e:
logger.debug(f"Cache bulk store error ({source}/{entity_type}): {e}") logger.debug(f"Cache bulk store error ({source}/{entity_type}): {e}")
@ -186,29 +221,31 @@ class MetadataCache:
try: try:
db = self._get_db() db = self._get_db()
conn = db._get_connection() conn = db._get_connection()
cursor = conn.cursor() try:
# Batch query in chunks of 500 to avoid SQLite variable limit cursor = conn.cursor()
for i in range(0, len(entity_ids), 500): # Batch query in chunks of 500 to avoid SQLite variable limit
chunk = entity_ids[i:i + 500] for i in range(0, len(entity_ids), 500):
placeholders = ','.join('?' * len(chunk)) chunk = entity_ids[i:i + 500]
cursor.execute(f""" placeholders = ','.join('?' * len(chunk))
SELECT entity_id, raw_json FROM metadata_cache_entities cursor.execute(f"""
WHERE source = ? AND entity_type = ? AND entity_id IN ({placeholders}) SELECT entity_id, raw_json FROM metadata_cache_entities
""", [source, entity_type] + chunk) WHERE source = ? AND entity_type = ? AND entity_id IN ({placeholders})
for row in cursor.fetchall(): """, [source, entity_type] + chunk)
found[row['entity_id']] = json.loads(row['raw_json']) for row in cursor.fetchall():
# Touch all found entries found[row['entity_id']] = json.loads(row['raw_json'])
if found: # Touch all found entries
found_in_chunk = [eid for eid in chunk if eid in found] if found:
if found_in_chunk: found_in_chunk = [eid for eid in chunk if eid in found]
ph2 = ','.join('?' * len(found_in_chunk)) if found_in_chunk:
cursor.execute(f""" ph2 = ','.join('?' * len(found_in_chunk))
UPDATE metadata_cache_entities cursor.execute(f"""
SET last_accessed_at = CURRENT_TIMESTAMP, access_count = access_count + 1 UPDATE metadata_cache_entities
WHERE source = ? AND entity_type = ? AND entity_id IN ({ph2}) SET last_accessed_at = CURRENT_TIMESTAMP, access_count = access_count + 1
""", [source, entity_type] + found_in_chunk) WHERE source = ? AND entity_type = ? AND entity_id IN ({ph2})
conn.commit() """, [source, entity_type] + found_in_chunk)
conn.close() conn.commit()
finally:
conn.close()
missing = [eid for eid in entity_ids if eid not in found] missing = [eid for eid in entity_ids if eid not in found]
except Exception as e: except Exception as e:
logger.debug(f"Cache batch lookup error: {e}") logger.debug(f"Cache batch lookup error: {e}")
@ -226,58 +263,57 @@ class MetadataCache:
try: try:
db = self._get_db() db = self._get_db()
conn = db._get_connection() conn = db._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT id, result_ids, created_at FROM metadata_cache_searches
WHERE source = ? AND search_type = ? AND query_normalized = ? AND search_limit = ?
""", (source, search_type, normalized, limit))
row = cursor.fetchone()
if not row:
conn.close()
return None
# Check TTL (7 days for searches)
try: try:
created = datetime.fromisoformat(row['created_at']) cursor = conn.cursor()
age_days = (datetime.now() - created).days
if age_days > 7:
# Expired — delete and return miss
cursor.execute("DELETE FROM metadata_cache_searches WHERE id = ?", (row['id'],))
conn.commit()
conn.close()
return None
except (ValueError, TypeError):
pass
# Touch search entry
cursor.execute("""
UPDATE metadata_cache_searches
SET last_accessed_at = CURRENT_TIMESTAMP, access_count = access_count + 1
WHERE id = ?
""", (row['id'],))
conn.commit()
# Resolve entity IDs to full data
result_ids = json.loads(row['result_ids'])
if not result_ids:
conn.close()
return []
results = []
for eid in result_ids:
cursor.execute(""" cursor.execute("""
SELECT raw_json FROM metadata_cache_entities SELECT id, result_ids, created_at FROM metadata_cache_searches
WHERE source = ? AND entity_type = ? AND entity_id = ? WHERE source = ? AND search_type = ? AND query_normalized = ? AND search_limit = ?
""", (source, search_type, eid)) """, (source, search_type, normalized, limit))
erow = cursor.fetchone() row = cursor.fetchone()
if erow: if not row:
results.append(json.loads(erow['raw_json'])) return None
conn.close()
# Only return if we found all (or most) entries — partial results are unreliable # Check TTL (7 days for searches)
if len(results) >= len(result_ids) * 0.8: try:
return results created = datetime.fromisoformat(row['created_at'])
return None age_days = (datetime.now() - created).days
if age_days > 7:
# Expired — delete and return miss
cursor.execute("DELETE FROM metadata_cache_searches WHERE id = ?", (row['id'],))
conn.commit()
return None
except (ValueError, TypeError):
pass
# Touch search entry
cursor.execute("""
UPDATE metadata_cache_searches
SET last_accessed_at = CURRENT_TIMESTAMP, access_count = access_count + 1
WHERE id = ?
""", (row['id'],))
conn.commit()
# Resolve entity IDs to full data
result_ids = json.loads(row['result_ids'])
if not result_ids:
return []
results = []
for eid in result_ids:
cursor.execute("""
SELECT raw_json FROM metadata_cache_entities
WHERE source = ? AND entity_type = ? AND entity_id = ?
""", (source, search_type, eid))
erow = cursor.fetchone()
if erow:
results.append(json.loads(erow['raw_json']))
# Only return if we found all (or most) entries — partial results are unreliable
if len(results) >= len(result_ids) * 0.8:
return results
return None
finally:
conn.close()
except Exception as e: except Exception as e:
logger.debug(f"Search cache lookup error ({source}/{search_type}/{query}): {e}") logger.debug(f"Search cache lookup error ({source}/{search_type}/{query}): {e}")
return None return None
@ -291,18 +327,20 @@ class MetadataCache:
try: try:
db = self._get_db() db = self._get_db()
conn = db._get_connection() conn = db._get_connection()
cursor = conn.cursor() try:
cursor.execute(""" cursor = conn.cursor()
INSERT OR REPLACE INTO metadata_cache_searches cursor.execute("""
(source, search_type, query_normalized, query_original, result_ids, INSERT OR REPLACE INTO metadata_cache_searches
result_count, search_limit, last_accessed_at) (source, search_type, query_normalized, query_original, result_ids,
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) result_count, search_limit, last_accessed_at)
""", ( VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
source, search_type, normalized, query.strip(), """, (
json.dumps(entity_ids), len(entity_ids), limit, source, search_type, normalized, query.strip(),
)) json.dumps(entity_ids), len(entity_ids), limit,
conn.commit() ))
conn.close() conn.commit()
finally:
conn.close()
except Exception as e: except Exception as e:
logger.debug(f"Search cache store error ({source}/{search_type}/{query}): {e}") logger.debug(f"Search cache store error ({source}/{search_type}/{query}): {e}")
@ -315,63 +353,65 @@ class MetadataCache:
try: try:
db = self._get_db() db = self._get_db()
conn = db._get_connection() conn = db._get_connection()
cursor = conn.cursor() try:
cursor = conn.cursor()
where_clauses = ['entity_type = ?'] where_clauses = ['entity_type = ?']
params = [entity_type] params = [entity_type]
# Exclude pseudo-entities like album_id_tracks and track_id_features # Exclude pseudo-entities like album_id_tracks and track_id_features
where_clauses.append(r"entity_id NOT LIKE '%\_tracks' ESCAPE '\'") where_clauses.append(r"entity_id NOT LIKE '%\_tracks' ESCAPE '\'")
where_clauses.append(r"entity_id NOT LIKE '%\_features' ESCAPE '\'") where_clauses.append(r"entity_id NOT LIKE '%\_features' ESCAPE '\'")
if source: if source:
where_clauses.append('source = ?') where_clauses.append('source = ?')
params.append(source) params.append(source)
if search: if search:
search_term = f'%{search}%' search_term = f'%{search}%'
where_clauses.append('(name LIKE ? OR artist_name LIKE ? OR album_name LIKE ?)') where_clauses.append('(name LIKE ? OR artist_name LIKE ? OR album_name LIKE ?)')
params.extend([search_term, search_term, search_term]) params.extend([search_term, search_term, search_term])
where_sql = ' AND '.join(where_clauses) where_sql = ' AND '.join(where_clauses)
# Count total # Count total
cursor.execute(f"SELECT COUNT(*) as cnt FROM metadata_cache_entities WHERE {where_sql}", params) cursor.execute(f"SELECT COUNT(*) as cnt FROM metadata_cache_entities WHERE {where_sql}", params)
total = cursor.fetchone()['cnt'] total = cursor.fetchone()['cnt']
# Validate sort column # Validate sort column
valid_sorts = {'last_accessed_at', 'created_at', 'access_count', 'name', 'popularity', 'updated_at'} valid_sorts = {'last_accessed_at', 'created_at', 'access_count', 'name', 'popularity', 'updated_at'}
if sort not in valid_sorts: if sort not in valid_sorts:
sort = 'last_accessed_at' sort = 'last_accessed_at'
direction = 'ASC' if sort_dir == 'asc' else 'DESC' direction = 'ASC' if sort_dir == 'asc' else 'DESC'
cursor.execute(f""" cursor.execute(f"""
SELECT id, source, entity_type, entity_id, name, image_url, SELECT id, source, entity_type, entity_id, name, image_url,
genres, popularity, followers, genres, popularity, followers,
artist_name, artist_id, release_date, total_tracks, album_type, label, artist_name, artist_id, release_date, total_tracks, album_type, label,
album_name, album_id, duration_ms, track_number, disc_number, explicit, album_name, album_id, duration_ms, track_number, disc_number, explicit,
isrc, preview_url, external_urls, isrc, preview_url, external_urls,
created_at, updated_at, last_accessed_at, access_count created_at, updated_at, last_accessed_at, access_count
FROM metadata_cache_entities FROM metadata_cache_entities
WHERE {where_sql} WHERE {where_sql}
ORDER BY {sort} {direction} ORDER BY {sort} {direction}
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
""", params + [limit, offset]) """, params + [limit, offset])
items = [] items = []
for row in cursor.fetchall(): for row in cursor.fetchall():
item = dict(row) item = dict(row)
# Parse JSON fields for the UI # Parse JSON fields for the UI
for json_field in ('genres', 'external_urls'): for json_field in ('genres', 'external_urls'):
if item.get(json_field): if item.get(json_field):
try: try:
item[json_field] = json.loads(item[json_field]) item[json_field] = json.loads(item[json_field])
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
pass pass
items.append(item) items.append(item)
conn.close() return {'items': items, 'total': total, 'offset': offset, 'limit': limit}
return {'items': items, 'total': total, 'offset': offset, 'limit': limit} finally:
conn.close()
except Exception as e: except Exception as e:
logger.error(f"Cache browse error: {e}") logger.error(f"Cache browse error: {e}")
return {'items': [], 'total': 0, 'offset': offset, 'limit': limit} return {'items': [], 'total': 0, 'offset': offset, 'limit': limit}
@ -381,34 +421,35 @@ class MetadataCache:
try: try:
db = self._get_db() db = self._get_db()
conn = db._get_connection() conn = db._get_connection()
cursor = conn.cursor() try:
cursor.execute(""" cursor = conn.cursor()
SELECT * FROM metadata_cache_entities cursor.execute("""
WHERE source = ? AND entity_type = ? AND entity_id = ? SELECT * FROM metadata_cache_entities
""", (source, entity_type, entity_id)) WHERE source = ? AND entity_type = ? AND entity_id = ?
row = cursor.fetchone() """, (source, entity_type, entity_id))
if not row: row = cursor.fetchone()
if not row:
return None
# Touch
cursor.execute("""
UPDATE metadata_cache_entities
SET last_accessed_at = CURRENT_TIMESTAMP, access_count = access_count + 1
WHERE id = ?
""", (row['id'],))
conn.commit()
item = dict(row)
# Parse JSON fields
for json_field in ('genres', 'external_urls', 'raw_json'):
if item.get(json_field):
try:
item[json_field] = json.loads(item[json_field])
except (json.JSONDecodeError, TypeError):
pass
return item
finally:
conn.close() conn.close()
return None
# Touch
cursor.execute("""
UPDATE metadata_cache_entities
SET last_accessed_at = CURRENT_TIMESTAMP, access_count = access_count + 1
WHERE id = ?
""", (row['id'],))
conn.commit()
item = dict(row)
# Parse JSON fields
for json_field in ('genres', 'external_urls', 'raw_json'):
if item.get(json_field):
try:
item[json_field] = json.loads(item[json_field])
except (json.JSONDecodeError, TypeError):
pass
conn.close()
return item
except Exception as e: except Exception as e:
logger.error(f"Cache detail error: {e}") logger.error(f"Cache detail error: {e}")
return None return None
@ -420,49 +461,51 @@ class MetadataCache:
try: try:
db = self._get_db() db = self._get_db()
conn = db._get_connection() conn = db._get_connection()
cursor = conn.cursor() try:
cursor = conn.cursor()
stats = { stats = {
'artists': {'spotify': 0, 'itunes': 0}, 'artists': {'spotify': 0, 'itunes': 0},
'albums': {'spotify': 0, 'itunes': 0}, 'albums': {'spotify': 0, 'itunes': 0},
'tracks': {'spotify': 0, 'itunes': 0}, 'tracks': {'spotify': 0, 'itunes': 0},
'searches': 0, 'searches': 0,
'total_entries': 0, 'total_entries': 0,
'total_hits': 0, 'total_hits': 0,
'oldest': None, 'oldest': None,
'newest': None, 'newest': None,
} }
# Count by type and source (exclude pseudo-entities like _tracks, _features) # Count by type and source (exclude pseudo-entities like _tracks, _features)
cursor.execute(r""" cursor.execute(r"""
SELECT entity_type, source, COUNT(*) as cnt, SUM(access_count) as hits SELECT entity_type, source, COUNT(*) as cnt, SUM(access_count) as hits
FROM metadata_cache_entities FROM metadata_cache_entities
WHERE entity_id NOT LIKE '%\_tracks' ESCAPE '\' WHERE entity_id NOT LIKE '%\_tracks' ESCAPE '\'
AND entity_id NOT LIKE '%\_features' ESCAPE '\' AND entity_id NOT LIKE '%\_features' ESCAPE '\'
GROUP BY entity_type, source GROUP BY entity_type, source
""") """)
type_key_map = {'artist': 'artists', 'album': 'albums', 'track': 'tracks'} type_key_map = {'artist': 'artists', 'album': 'albums', 'track': 'tracks'}
for row in cursor.fetchall(): for row in cursor.fetchall():
et = type_key_map.get(row['entity_type']) et = type_key_map.get(row['entity_type'])
src = row['source'] src = row['source']
if et and et in stats and src in stats[et]: if et and et in stats and src in stats[et]:
stats[et][src] = row['cnt'] stats[et][src] = row['cnt']
stats['total_entries'] += row['cnt'] stats['total_entries'] += row['cnt']
stats['total_hits'] += (row['hits'] or 0) stats['total_hits'] += (row['hits'] or 0)
# Search count # Search count
cursor.execute("SELECT COUNT(*) as cnt FROM metadata_cache_searches") cursor.execute("SELECT COUNT(*) as cnt FROM metadata_cache_searches")
stats['searches'] = cursor.fetchone()['cnt'] stats['searches'] = cursor.fetchone()['cnt']
# Oldest and newest # Oldest and newest
cursor.execute("SELECT MIN(created_at) as oldest, MAX(created_at) as newest FROM metadata_cache_entities") cursor.execute("SELECT MIN(created_at) as oldest, MAX(created_at) as newest FROM metadata_cache_entities")
row = cursor.fetchone() row = cursor.fetchone()
if row: if row:
stats['oldest'] = row['oldest'] stats['oldest'] = row['oldest']
stats['newest'] = row['newest'] stats['newest'] = row['newest']
conn.close() return stats
return stats finally:
conn.close()
except Exception as e: except Exception as e:
logger.error(f"Cache stats error: {e}") logger.error(f"Cache stats error: {e}")
return { return {
@ -480,28 +523,30 @@ class MetadataCache:
try: try:
db = self._get_db() db = self._get_db()
conn = db._get_connection() conn = db._get_connection()
cursor = conn.cursor() try:
cursor = conn.cursor()
# Entities # Entities
cursor.execute(""" cursor.execute("""
DELETE FROM metadata_cache_entities DELETE FROM metadata_cache_entities
WHERE julianday('now') - julianday(updated_at) > ttl_days WHERE julianday('now') - julianday(updated_at) > ttl_days
""") """)
entity_count = cursor.rowcount entity_count = cursor.rowcount
# Searches # Searches
cursor.execute(""" cursor.execute("""
DELETE FROM metadata_cache_searches DELETE FROM metadata_cache_searches
WHERE julianday('now') - julianday(created_at) > ttl_days WHERE julianday('now') - julianday(created_at) > ttl_days
""") """)
search_count = cursor.rowcount search_count = cursor.rowcount
conn.commit() conn.commit()
conn.close() total = entity_count + search_count
total = entity_count + search_count if total > 0:
if total > 0: logger.info(f"Evicted {total} expired cache entries ({entity_count} entities, {search_count} searches)")
logger.info(f"Evicted {total} expired cache entries ({entity_count} entities, {search_count} searches)") return total
return total finally:
conn.close()
except Exception as e: except Exception as e:
logger.error(f"Cache eviction error: {e}") logger.error(f"Cache eviction error: {e}")
return 0 return 0
@ -511,46 +556,48 @@ class MetadataCache:
try: try:
db = self._get_db() db = self._get_db()
conn = db._get_connection() conn = db._get_connection()
cursor = conn.cursor() try:
cursor = conn.cursor()
# Clear entities # Clear entities
where_parts = [] where_parts = []
params = [] params = []
if source: if source:
where_parts.append('source = ?') where_parts.append('source = ?')
params.append(source) params.append(source)
if entity_type: if entity_type:
where_parts.append('entity_type = ?') where_parts.append('entity_type = ?')
params.append(entity_type) params.append(entity_type)
if where_parts: if where_parts:
where_sql = ' AND '.join(where_parts) where_sql = ' AND '.join(where_parts)
cursor.execute(f"DELETE FROM metadata_cache_entities WHERE {where_sql}", params) cursor.execute(f"DELETE FROM metadata_cache_entities WHERE {where_sql}", params)
else: else:
cursor.execute("DELETE FROM metadata_cache_entities") cursor.execute("DELETE FROM metadata_cache_entities")
entity_count = cursor.rowcount entity_count = cursor.rowcount
# Clear searches (match source and entity_type → search_type) # Clear searches (match source and entity_type → search_type)
search_where = [] search_where = []
search_params = [] search_params = []
if source: if source:
search_where.append('source = ?') search_where.append('source = ?')
search_params.append(source) search_params.append(source)
if entity_type: if entity_type:
search_where.append('search_type = ?') search_where.append('search_type = ?')
search_params.append(entity_type) search_params.append(entity_type)
if search_where: if search_where:
cursor.execute(f"DELETE FROM metadata_cache_searches WHERE {' AND '.join(search_where)}", search_params) cursor.execute(f"DELETE FROM metadata_cache_searches WHERE {' AND '.join(search_where)}", search_params)
else: else:
cursor.execute("DELETE FROM metadata_cache_searches") cursor.execute("DELETE FROM metadata_cache_searches")
search_count = cursor.rowcount search_count = cursor.rowcount
conn.commit() conn.commit()
conn.close() total = entity_count + search_count
total = entity_count + search_count logger.info(f"Cleared {total} cache entries (source={source}, type={entity_type})")
logger.info(f"Cleared {total} cache entries (source={source}, type={entity_type})") return total
return total finally:
conn.close()
except Exception as e: except Exception as e:
logger.error(f"Cache clear error: {e}") logger.error(f"Cache clear error: {e}")
return 0 return 0

View file

@ -1049,10 +1049,10 @@ class SpotifyClient:
album = Album.from_spotify_album(album_data) album = Album.from_spotify_album(album_data)
albums.append(album) albums.append(album)
# Cache individual albums + search mapping # Cache individual albums + search mapping (skip if full data already cached)
entries = [(ad.get('id'), ad) for ad in raw_items if ad.get('id')] entries = [(ad.get('id'), ad) for ad in raw_items if ad.get('id')]
if entries: if entries:
cache.store_entities_bulk('spotify', 'album', entries) cache.store_entities_bulk('spotify', 'album', entries, skip_if_exists=True)
cache.store_search_results('spotify', 'album', query, min(limit, 10), cache.store_search_results('spotify', 'album', query, min(limit, 10),
[ad.get('id') for ad in raw_items if ad.get('id')]) [ad.get('id') for ad in raw_items if ad.get('id')])
@ -1234,14 +1234,14 @@ class SpotifyClient:
# Cache the aggregated result # Cache the aggregated result
cache.store_entity('spotify', 'album', cache_key, result) cache.store_entity('spotify', 'album', cache_key, result)
# Also cache individual tracks opportunistically # Also cache individual tracks opportunistically (skip if full data already cached)
track_entries = [] track_entries = []
for track in all_tracks: for track in all_tracks:
tid = track.get('id') tid = track.get('id')
if tid: if tid:
track_entries.append((tid, track)) track_entries.append((tid, track))
if track_entries: if track_entries:
cache.store_entities_bulk('spotify', 'track', track_entries) cache.store_entities_bulk('spotify', 'track', track_entries, skip_if_exists=True)
return result return result
@ -1278,11 +1278,11 @@ class SpotifyClient:
logger.info(f"Retrieved {len(albums)} albums for artist {artist_id}") logger.info(f"Retrieved {len(albums)} albums for artist {artist_id}")
# Cache individual albums opportunistically # Cache individual albums opportunistically (skip if full data already cached)
cache = get_metadata_cache() cache = get_metadata_cache()
entries = [(ad.get('id'), ad) for ad in raw_items if ad.get('id')] entries = [(ad.get('id'), ad) for ad in raw_items if ad.get('id')]
if entries: if entries:
cache.store_entities_bulk('spotify', 'album', entries) cache.store_entities_bulk('spotify', 'album', entries, skip_if_exists=True)
return albums return albums