mirror of
https://github.com/Dvorinka/SpotifyRecAlg.git
synced 2026-06-04 04:23:02 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user