95c1f9b105
- Theme Context ausgelagert (ThemeContext.tsx + useDarkMode.ts) - Config Validation mit TMDB-API-Key Pflicht (config_validation.py) - Cache Key Centralization (cache/keys.py) - CD-Ripping mit abcde implementiert (Worker) - Docker Compose mit Healthchecks & LOG_LEVEL - ROADMAP.md & SAVEPOINT.md aktualisiert
145 lines
3.7 KiB
Python
145 lines
3.7 KiB
Python
"""SQLite Cache für API-Rate-Limits (LRU, 10k Einträge, TTL)."""
|
|
|
|
import sqlite3
|
|
import time
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
|
|
from cache.keys import PRESCAN_AUDIO_PREFIX, PRESCAN_VIDEO_PREFIX, CONFIRMED_METADATA_PREFIX
|
|
|
|
CACHE_DIR = Path("/app/cache")
|
|
CACHE_FILE = CACHE_DIR / "api_cache.db"
|
|
MAX_ENTRIES = 10000
|
|
DEFAULT_TTL = 86400 # 24 Stunden
|
|
|
|
|
|
def is_prescan_key(key: str) -> bool:
|
|
"""Prüfe ob Key ein Pre-Scan-Key ist."""
|
|
return key.startswith(PRESCAN_AUDIO_PREFIX) or key.startswith(PRESCAN_VIDEO_PREFIX)
|
|
|
|
|
|
def is_confirmed_key(key: str) -> bool:
|
|
"""Prüfe ob Key ein bestätigter Metadaten-Key ist."""
|
|
return key.startswith(CONFIRMED_METADATA_PREFIX)
|
|
|
|
|
|
def get_cache_category(key: str) -> str:
|
|
"""Ermittle Kategorie eines Cache-Keys."""
|
|
if is_prescan_key(key):
|
|
return "prescan"
|
|
elif is_confirmed_key(key):
|
|
return "confirmed"
|
|
elif key.startswith("tmdb:"):
|
|
return "tmdb"
|
|
elif key.startswith("musicbrainz:"):
|
|
return "musicbrainz"
|
|
return "other"
|
|
|
|
|
|
|
|
def init_cache() -> None:
|
|
"""Initialisiere Cache-DB."""
|
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
conn = sqlite3.connect(CACHE_FILE)
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute("""
|
|
CREATE TABLE IF NOT EXISTS api_cache (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
key TEXT UNIQUE NOT NULL,
|
|
value TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
ttl INTEGER NOT NULL
|
|
)
|
|
""")
|
|
|
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_key ON api_cache(key)")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def _get_connection() -> sqlite3.Connection:
|
|
"""Hole DB-Verbindung mit LRU-Check."""
|
|
conn = sqlite3.connect(CACHE_FILE)
|
|
|
|
# LRU: entferne alte Einträge wenn MAX_ENTRIES überschritten
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT COUNT(*) FROM api_cache")
|
|
count = cursor.fetchone()[0]
|
|
|
|
if count >= MAX_ENTRIES:
|
|
cursor.execute("""
|
|
DELETE FROM api_cache
|
|
WHERE id IN (
|
|
SELECT id FROM api_cache
|
|
ORDER BY created_at ASC
|
|
LIMIT ?
|
|
)
|
|
""", (MAX_ENTRIES // 10,))
|
|
conn.commit()
|
|
|
|
return conn
|
|
|
|
|
|
def get(key: str) -> Optional[Dict[str, Any]]:
|
|
"""Hole Wert aus Cache."""
|
|
conn = _get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute(
|
|
"SELECT value, created_at, ttl FROM api_cache WHERE key = ?",
|
|
(key,)
|
|
)
|
|
row = cursor.fetchone()
|
|
|
|
if row is None:
|
|
conn.close()
|
|
return None
|
|
|
|
value, created_at, ttl = row
|
|
if time.time() > created_at + ttl:
|
|
# TTL abgelaufen
|
|
cursor.execute("DELETE FROM api_cache WHERE key = ?", (key,))
|
|
conn.commit()
|
|
conn.close()
|
|
return None
|
|
|
|
conn.close()
|
|
return json.loads(value)
|
|
|
|
|
|
def set(key: str, value: Dict[str, Any], ttl: int = DEFAULT_TTL) -> None:
|
|
"""Speichere Wert im Cache."""
|
|
conn = _get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute("""
|
|
INSERT OR REPLACE INTO api_cache (key, value, created_at, ttl)
|
|
VALUES (?, ?, ?, ?)
|
|
""", (key, json.dumps(value), int(time.time()), ttl))
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def delete(key: str) -> None:
|
|
"""Lösche Eintrag aus Cache."""
|
|
conn = _get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute("DELETE FROM api_cache WHERE key = ?", (key,))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def clear() -> None:
|
|
"""Lösche gesamten Cache."""
|
|
conn = _get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute("DELETE FROM api_cache")
|
|
conn.commit()
|
|
conn.close()
|