Files
Commander-Deck-App-backup/backend/app/services/scryfall.py
T

188 lines
6.0 KiB
Python

"""
Scryfall API client with Redis caching.
Respects Scryfall's rate limit (10 req/s with a small safety margin).
Cards are cached for 24 hours by default (SCRYFALL_CACHE_TTL_SECONDS).
"""
import asyncio
import json
from typing import Optional
import httpx
from tenacity import retry, stop_after_attempt, wait_exponential
from app.core.config import settings
from app.core.redis import get_redis
SCRYFALL_BASE = "https://api.scryfall.com"
_semaphore: asyncio.Semaphore | None = None
def _get_semaphore() -> asyncio.Semaphore:
global _semaphore
if _semaphore is None:
_semaphore = asyncio.Semaphore(settings.SCRYFALL_RATE_LIMIT_RPS)
return _semaphore
def _cache_key(kind: str, value: str) -> str:
return f"scryfall:{kind}:{value.lower()}"
async def _cache_get(key: str) -> Optional[dict]:
redis = await get_redis()
raw = await redis.get(key)
return json.loads(raw) if raw else None
async def _cache_set(key: str, data: dict) -> None:
redis = await get_redis()
await redis.set(key, json.dumps(data), ex=settings.SCRYFALL_CACHE_TTL_SECONDS)
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=0.5, min=0.5, max=4))
async def _get(path: str, params: dict | None = None) -> dict:
async with _get_semaphore():
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{SCRYFALL_BASE}{path}",
params=params,
headers={"User-Agent": "MTGDeckBuilder/1.0"},
)
resp.raise_for_status()
return resp.json()
async def get_card_by_name(name: str, exact: bool = True) -> Optional[dict]:
"""Fetch a card by name. Uses fuzzy search if exact=False."""
key = _cache_key("name", name)
cached = await _cache_get(key)
if cached:
return cached
try:
if exact:
data = await _get("/cards/named", {"exact": name})
else:
data = await _get("/cards/named", {"fuzzy": name})
await _cache_set(key, data)
# Also cache by scryfall id
await _cache_set(_cache_key("id", data["id"]), data)
return data
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return None
raise
async def get_card_by_id(scryfall_id: str) -> Optional[dict]:
"""Fetch a card by its Scryfall UUID."""
key = _cache_key("id", scryfall_id)
cached = await _cache_get(key)
if cached:
return cached
try:
data = await _get(f"/cards/{scryfall_id}")
await _cache_set(key, data)
return data
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return None
raise
async def get_card_by_set_and_collector(set_code: str, collector_number: str) -> Optional[dict]:
"""Fetch a card by set + collector number (used during import enrichment)."""
key = _cache_key("setcol", f"{set_code}:{collector_number}")
cached = await _cache_get(key)
if cached:
return cached
try:
data = await _get(f"/cards/{set_code.lower()}/{collector_number}")
await _cache_set(key, data)
await _cache_set(_cache_key("id", data["id"]), data)
return data
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return None
raise
async def batch_enrich_by_name(names: list[str]) -> dict[str, Optional[dict]]:
"""
Resolve up to 75 card names at once via Scryfall's /cards/collection endpoint.
Returns a dict of {name_lower: scryfall_data}.
Falls back to None for cards not found.
"""
results: dict[str, Optional[dict]] = {}
uncached: list[str] = []
# Check cache first
for name in names:
key = _cache_key("name", name)
cached = await _cache_get(key)
if cached:
results[name.lower()] = cached
else:
uncached.append(name)
# Batch fetch uncached in chunks of 75 (Scryfall limit)
for chunk_start in range(0, len(uncached), 75):
chunk = uncached[chunk_start : chunk_start + 75]
identifiers = [{"name": n} for n in chunk]
async with _get_semaphore():
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
f"{SCRYFALL_BASE}/cards/collection",
json={"identifiers": identifiers},
headers={"User-Agent": "MTGDeckBuilder/1.0"},
)
resp.raise_for_status()
data = resp.json()
for card in data.get("data", []):
name_lower = card["name"].lower()
results[name_lower] = card
await _cache_set(_cache_key("name", card["name"]), card)
await _cache_set(_cache_key("id", card["id"]), card)
# Mark not-found as None
found_lower = {c["name"].lower() for c in data.get("data", [])}
for name in chunk:
if name.lower() not in found_lower:
results[name.lower()] = None
# Be polite between chunks
if chunk_start + 75 < len(uncached):
await asyncio.sleep(0.15)
return results
def extract_price_usd(scryfall_data: dict) -> Optional[float]:
"""Pull the cheapest non-foil USD price from a Scryfall card object."""
try:
price = scryfall_data.get("prices", {}).get("usd")
return float(price) if price else None
except (TypeError, ValueError):
return None
def card_image_url(scryfall_data: dict, size: str = "normal") -> Optional[str]:
"""Return an image URL. Handles split/transform/MDFCs gracefully."""
images = scryfall_data.get("image_uris")
if images:
return images.get(size)
# Double-faced cards store images per face
faces = scryfall_data.get("card_faces", [])
if faces:
return faces[0].get("image_uris", {}).get(size)
return None
def is_commander_legal(scryfall_data: dict) -> bool:
return scryfall_data.get("legalities", {}).get("commander") == "legal"