mirror of
https://github.com/Dvorinka/SpotifyRecAlg.git
synced 2026-06-03 20:13:03 +00:00
335 lines
12 KiB
Python
335 lines
12 KiB
Python
"""
|
|
Spotify Cache Manager with DragonflyDB Integration
|
|
|
|
Provides intelligent caching for Spotify metadata to:
|
|
- Rate limit requests (protect against bans)
|
|
- Cache data for 12 hours
|
|
- Use DragonflyDB for fast caching
|
|
- Fall back to local SQLite if Dragonfly unavailable
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import sqlite3
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
# Import native DragonflyDB service
|
|
from swingmusic.db.dragonfly_client import get_spotify_cache
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SpotifyCacheManager:
|
|
"""
|
|
Intelligent cache manager for Spotify metadata with DragonflyDB support
|
|
"""
|
|
|
|
def __init__(self, cache_duration_hours: int = 12):
|
|
self.cache_duration = timedelta(hours=cache_duration_hours)
|
|
|
|
# Use native DragonflyDB service
|
|
self.dragonfly_cache = get_spotify_cache()
|
|
|
|
# Initialize SQLite as fallback
|
|
self.sqlite_conn = None
|
|
self._init_sqlite_fallback()
|
|
|
|
# Rate limiting (only for real Spotify API calls)
|
|
self.min_request_interval = 2.0 # 2 seconds between requests
|
|
self.last_request_time = 0
|
|
self.request_count = 0
|
|
self.max_requests_per_hour = 1000 # Conservative limit
|
|
|
|
logger.info(
|
|
f"Spotify cache manager initialized (cache: {cache_duration_hours}h, dragonfly: {self.dragonfly_cache.client.is_available()})"
|
|
)
|
|
|
|
def _init_sqlite_fallback(self):
|
|
"""Initialize SQLite fallback cache"""
|
|
try:
|
|
cache_dir = Path.home() / ".swingmusic" / "cache"
|
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
db_path = cache_dir / "spotify_cache.db"
|
|
self.sqlite_conn = sqlite3.connect(str(db_path))
|
|
self._init_sqlite_schema()
|
|
logger.info("✅ SQLite fallback initialized")
|
|
except Exception as e:
|
|
logger.error(f"Failed to initialize SQLite fallback: {e}")
|
|
|
|
def _init_sqlite_schema(self):
|
|
"""Initialize SQLite cache schema"""
|
|
if not self.sqlite_conn:
|
|
return
|
|
|
|
cursor = self.sqlite_conn.cursor()
|
|
cursor.execute("""
|
|
CREATE TABLE IF NOT EXISTS spotify_cache (
|
|
cache_key TEXT PRIMARY KEY,
|
|
data TEXT NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
request_count INTEGER DEFAULT 1
|
|
)
|
|
""")
|
|
|
|
cursor.execute("""
|
|
CREATE INDEX IF NOT EXISTS idx_expires_at ON spotify_cache(expires_at)
|
|
""")
|
|
|
|
self.sqlite_conn.commit()
|
|
|
|
def _rate_limit(self):
|
|
"""Apply rate limiting to prevent Spotify bans"""
|
|
now = time.time()
|
|
elapsed = now - self.last_request_time
|
|
|
|
if elapsed < self.min_request_interval:
|
|
wait_time = self.min_request_interval - elapsed
|
|
logger.debug(f"Rate limiting: waiting {wait_time:.1f}s")
|
|
time.sleep(wait_time)
|
|
|
|
self.last_request_time = time.time()
|
|
self.request_count += 1
|
|
|
|
# Check if we're approaching hourly limit
|
|
if self.request_count > self.max_requests_per_hour:
|
|
logger.warning(f"Approaching hourly request limit: {self.request_count}")
|
|
|
|
def _get_cache_key(self, item_type: str, item_id: str) -> str:
|
|
"""Generate cache key for item"""
|
|
return f"spotify:{item_type}:{item_id}"
|
|
|
|
def get_cached_data(self, item_type: str, item_id: str) -> dict[str, Any] | None:
|
|
"""Get cached data - NO rate limiting for cache access"""
|
|
cache_key = self._get_cache_key(item_type, item_id)
|
|
|
|
# Try DragonflyDB first (NO rate limiting)
|
|
if self.dragonfly_cache.client.is_available():
|
|
cached = self.dragonfly_cache.get(cache_key)
|
|
if cached:
|
|
logger.debug(f"Cache hit (DragonflyDB): {cache_key}")
|
|
return cached
|
|
|
|
# Fallback to SQLite (NO rate limiting)
|
|
if self.sqlite_conn:
|
|
try:
|
|
cursor = self.sqlite_conn.cursor()
|
|
cursor.execute(
|
|
"""
|
|
SELECT data FROM spotify_cache
|
|
WHERE cache_key = ? AND expires_at > datetime('now')
|
|
""",
|
|
(cache_key,),
|
|
)
|
|
|
|
row = cursor.fetchone()
|
|
if row:
|
|
data = json.loads(row[0])
|
|
logger.debug(f"Cache hit (SQLite): {cache_key}")
|
|
return data
|
|
except Exception as e:
|
|
logger.debug(f"SQLite cache miss: {e}")
|
|
|
|
logger.debug(f"Cache miss: {cache_key}")
|
|
return None
|
|
|
|
def cache_data(self, item_type: str, item_id: str, data: dict[str, Any]) -> bool:
|
|
"""Cache Spotify data with 12-hour expiration"""
|
|
cache_key = self._get_cache_key(item_type, item_id)
|
|
|
|
success = False
|
|
|
|
# Try DragonflyDB first (12-hour TTL)
|
|
if self.dragonfly_cache.client.is_available():
|
|
if self.dragonfly_cache.set(cache_key, data, ttl_hours=12):
|
|
logger.debug(f"Cached (DragonflyDB): {cache_key}")
|
|
success = True
|
|
|
|
# Fallback to SQLite
|
|
if self.sqlite_conn:
|
|
try:
|
|
cursor = self.sqlite_conn.cursor()
|
|
expires_at = datetime.now() + self.cache_duration
|
|
serialized_data = json.dumps(data)
|
|
|
|
cursor.execute(
|
|
"""
|
|
INSERT OR REPLACE INTO spotify_cache
|
|
(cache_key, data, expires_at) VALUES (?, ?, ?)
|
|
""",
|
|
(cache_key, serialized_data, expires_at.isoformat()),
|
|
)
|
|
|
|
self.sqlite_conn.commit()
|
|
logger.debug(f"Cached (SQLite): {cache_key}")
|
|
success = True
|
|
except Exception as e:
|
|
logger.debug(f"SQLite cache failed: {e}")
|
|
|
|
return success
|
|
|
|
def get_or_fetch_track(self, track_id: str, fetch_func) -> dict[str, Any] | None:
|
|
"""Get track from cache first, only rate limit real Spotify requests"""
|
|
# Check cache first (NO rate limiting for cache access)
|
|
cached = self.get_cached_data("track", track_id)
|
|
if cached:
|
|
return cached
|
|
|
|
# Only apply rate limiting for REAL Spotify API calls
|
|
self._rate_limit()
|
|
|
|
# Fetch fresh data
|
|
try:
|
|
data = fetch_func(track_id)
|
|
if data:
|
|
# Cache the result
|
|
self.cache_data("track", track_id, data)
|
|
logger.info(f"Fetched and cached track: {track_id}")
|
|
return data
|
|
except Exception as e:
|
|
logger.error(f"Failed to fetch track {track_id}: {e}")
|
|
|
|
return None
|
|
|
|
def get_or_fetch_album(self, album_id: str, fetch_func) -> dict[str, Any] | None:
|
|
"""Get album from cache first, only rate limit real Spotify requests"""
|
|
# Check cache first (NO rate limiting for cache access)
|
|
cached = self.get_cached_data("album", album_id)
|
|
if cached:
|
|
return cached
|
|
|
|
# Only apply rate limiting for REAL Spotify API calls
|
|
self._rate_limit()
|
|
|
|
# Fetch fresh data
|
|
try:
|
|
data = fetch_func(album_id)
|
|
if data:
|
|
# Cache the result
|
|
self.cache_data("album", album_id, data)
|
|
logger.info(f"Fetched and cached album: {album_id}")
|
|
return data
|
|
except Exception as e:
|
|
logger.error(f"Failed to fetch album {album_id}: {e}")
|
|
|
|
return None
|
|
|
|
def get_or_fetch_artist(self, artist_id: str, fetch_func) -> dict[str, Any] | None:
|
|
"""Get artist from cache first, only rate limit real Spotify requests"""
|
|
# Check cache first (NO rate limiting for cache access)
|
|
cached = self.get_cached_data("artist", artist_id)
|
|
if cached:
|
|
return cached
|
|
|
|
# Only apply rate limiting for REAL Spotify API calls
|
|
self._rate_limit()
|
|
|
|
# Fetch fresh data
|
|
try:
|
|
data = fetch_func(artist_id)
|
|
if data:
|
|
# Cache the result
|
|
self.cache_data("artist", artist_id, data)
|
|
logger.info(f"Fetched and cached artist: {artist_id}")
|
|
return data
|
|
except Exception as e:
|
|
logger.error(f"Failed to fetch artist {artist_id}: {e}")
|
|
|
|
return None
|
|
|
|
def cleanup_expired_cache(self):
|
|
"""Clean up expired cache entries"""
|
|
cleaned_count = 0
|
|
|
|
# Clean SQLite cache
|
|
if self.sqlite_conn:
|
|
try:
|
|
cursor = self.sqlite_conn.cursor()
|
|
cursor.execute("""
|
|
DELETE FROM spotify_cache
|
|
WHERE expires_at <= datetime('now')
|
|
""")
|
|
cleaned_count = cursor.rowcount
|
|
self.sqlite_conn.commit()
|
|
logger.info(f"Cleaned {cleaned_count} expired SQLite cache entries")
|
|
except Exception as e:
|
|
logger.error(f"Failed to clean SQLite cache: {e}")
|
|
|
|
# DragonflyDB handles expiration automatically
|
|
logger.debug("DragonflyDB handles expiration automatically")
|
|
|
|
return cleaned_count
|
|
|
|
def get_cache_stats(self) -> dict[str, Any]:
|
|
"""Get cache statistics"""
|
|
stats = {
|
|
"dragonfly_available": self.dragonfly_cache.client.is_available(),
|
|
"sqlite_available": self.sqlite_conn is not None,
|
|
"request_count": self.request_count,
|
|
"cache_duration_hours": self.cache_duration.total_seconds() / 3600,
|
|
"min_request_interval": self.min_request_interval,
|
|
}
|
|
|
|
# Get SQLite cache size
|
|
if self.sqlite_conn:
|
|
try:
|
|
cursor = self.sqlite_conn.cursor()
|
|
cursor.execute("SELECT COUNT(*) FROM spotify_cache")
|
|
stats["sqlite_cache_size"] = cursor.fetchone()[0]
|
|
|
|
cursor.execute("""
|
|
SELECT COUNT(*) FROM spotify_cache
|
|
WHERE expires_at > datetime('now')
|
|
""")
|
|
stats["sqlite_valid_cache_size"] = cursor.fetchone()[0]
|
|
except Exception as e:
|
|
logger.debug(f"Failed to get SQLite stats: {e}")
|
|
|
|
# Get DragonflyDB cache size
|
|
if self.dragonfly_cache.client.is_available():
|
|
try:
|
|
info = self.dragonfly_cache.client.info()
|
|
stats["dragonfly_used_memory"] = info.get(
|
|
"used_memory_human", "Unknown"
|
|
)
|
|
stats["dragonfly_connected_clients"] = info.get("connected_clients", 0)
|
|
stats["dragonfly_keys"] = len(
|
|
self.dragonfly_cache.client.keys("spotify:*")
|
|
)
|
|
except Exception as e:
|
|
logger.debug(f"Failed to get DragonflyDB stats: {e}")
|
|
|
|
return stats
|
|
|
|
def close(self):
|
|
"""Close cache connections"""
|
|
if self.dragonfly_cache.client:
|
|
try:
|
|
self.dragonfly_cache.client.close()
|
|
logger.info("DragonflyDB connection closed")
|
|
except Exception:
|
|
pass
|
|
|
|
if self.sqlite_conn:
|
|
try:
|
|
self.sqlite_conn.close()
|
|
logger.info("SQLite connection closed")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# Global cache manager instance
|
|
_cache_manager: SpotifyCacheManager | None = None
|
|
|
|
|
|
def get_spotify_cache_manager() -> SpotifyCacheManager:
|
|
"""Get or create the global Spotify cache manager"""
|
|
global _cache_manager
|
|
if _cache_manager is None:
|
|
_cache_manager = SpotifyCacheManager()
|
|
return _cache_manager
|