""" 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"