import asyncio import json import re import shutil from urllib.parse import quote, unquote, urlparse import httpx import db from config import OUTPUTS, MTG_BACK, BASE_URL queue: asyncio.Queue[str] = asyncio.Queue() def safe_filename(name: str) -> str: return re.sub(r"[^a-z0-9_]", "", name.lower().replace(" ", "_")) def is_scry_url(s: str) -> bool: s = s.strip().lower() return s.startswith("http") and "scryfall.com/card/" in s def parse_scry_url(url: str) -> tuple[str, str] | None: # https://scryfall.com/card/{set}/{number}[/{lang}]/{name-slug} parts = [p for p in urlparse(url.strip()).path.split("/") if p] if len(parts) >= 3 and parts[0] == "card": return parts[1], unquote(parts[2]) return None async def _scryfall_get(client: httpx.AsyncClient, url: str, slug: str) -> httpx.Response: for attempt in range(4): r = await client.get(url) if r.status_code != 429: return r wait = float(r.headers.get("Retry-After", 2 ** attempt)) db.add_log(slug, f" 429 — waiting {wait:.0f}s") await asyncio.sleep(wait) r.raise_for_status() return r async def _fetch_bytes(client: httpx.AsyncClient, url: str, slug: str) -> bytes: r = await _scryfall_get(client, url, slug) r.raise_for_status() return r.content async def resolve_card(client: httpx.AsyncClient, slug: str, name: str, scry_url: str | None) -> dict: if scry_url: parsed = parse_scry_url(scry_url) if not parsed: raise ValueError("unrecognized scryfall url") setcode, num = parsed url = f"https://api.scryfall.com/cards/{quote(setcode)}/{quote(num)}" r = await _scryfall_get(client, url, slug) r.raise_for_status() return r.json() for param in ("exact", "fuzzy"): url = f"https://api.scryfall.com/cards/named?{param}={quote(name.strip())}" r = await _scryfall_get(client, url, slug) if r.status_code == 200: return r.json() if param == "fuzzy": r.raise_for_status() raise ValueError("not found") def extract_images(data: dict) -> tuple[str, str | None]: # Single image when the root carries image_uris (normal, split, flip, meld parts). # Two distinct faces (transform / modal_dfc / double_faced_token / reversible) # carry per-face image_uris and no root image_uris. if "image_uris" in data: return data["image_uris"]["normal"], None faces = data.get("card_faces") or [] front = faces[0]["image_uris"]["normal"] if faces and faces[0].get("image_uris") else None back = faces[1]["image_uris"]["normal"] if len(faces) >= 2 and faces[1].get("image_uris") else None if not front: raise ValueError("no image_uris in response") return front, back def build_tts(slug: str) -> dict: deck = db.get_deck(slug) cards = db.get_cards(slug) commander_name = (deck["commander"] or "").strip() done = [c for c in cards if c["fetch_status"] == "done" and c["filename"]] if not done: raise ValueError("no successfully fetched cards") def card_url(fn: str) -> str: return f"{BASE_URL}/cards/{slug}/{fn}" def entry(c) -> dict: if c["back_filename"]: return { "FaceURL": card_url(c["filename"]), "BackURL": card_url(c["back_filename"]), "NumWidth": 1, "NumHeight": 1, "BackIsHidden": False, "UniqueBack": True, "Type": 0, } return { "FaceURL": card_url(c["filename"]), "BackURL": MTG_BACK, "NumWidth": 1, "NumHeight": 1, "BackIsHidden": True, "UniqueBack": False, "Type": 0, } def transform(x=0.0, y=1.0, z=0.0, rx=0.0, ry=180.0, rz=180.0) -> dict: return {"posX": x, "posY": y, "posZ": z, "rotX": rx, "rotY": ry, "rotZ": rz, "scaleX": 1.0, "scaleY": 1.0, "scaleZ": 1.0} base = { "Description": "", "Locked": False, "Grid": True, "Snap": True, "Autoraise": True, "Sticky": True, "Tooltip": True, "HideWhenFaceDown": True, "Hands": True, "SidewaysCard": False, "LuaScript": "", "LuaScriptState": "", "XmlUI": "", } k = 0 def next_k() -> int: nonlocal k k += 1 return k def card_object(c, x=0.0, rz=180.0) -> dict: n = next_k() return { "Name": "Card", "Transform": transform(x=x, rz=rz), "Nickname": c["name"], **base, "CardID": n * 100, "CustomDeck": {str(n): entry(c)}, } commander = next((c for c in done if c["name"] == commander_name), None) if commander_name else None rest = [c for c in done if commander is None or c["id"] != commander["id"]] objects = [] if commander: objects.append(card_object(commander, x=-5.0, rz=0.0)) if len(rest) >= 2: deck_ids, custom_deck, contained = [], {}, [] for c in rest: n = next_k() e = entry(c) custom_deck[str(n)] = e deck_ids.append(n * 100) contained.append({ "Name": "Card", "Transform": transform(), "Nickname": c["name"], **base, "CardID": n * 100, "CustomDeck": {str(n): e}, }) objects.append({ "Name": "Deck", "Transform": transform(), "Nickname": deck["deck_name"], "Description": "", "Locked": False, "Grid": True, "Snap": True, "Autoraise": True, "Sticky": True, "Tooltip": True, "GridProjection": False, "HideWhenFaceDown": True, "Hands": False, "SidewaysCard": False, "DeckIDs": deck_ids, "CustomDeck": custom_deck, "ContainedObjects": contained, "LuaScript": "", "LuaScriptState": "", "XmlUI": "", }) elif rest: objects.append(card_object(rest[0], rz=0.0)) return { "SaveName": deck["deck_name"], "Date": "", "VersionNumber": "", "GameMode": "Tabletop Simulator", "Gravity": 0.5, "PlayArea": 0.5, "Table": "", "Sky": "", "Note": "", "TabStates": {}, "LuaScript": "", "LuaScriptState": "", "XmlUI": "", "ObjectStates": objects, } async def run(): async with httpx.AsyncClient(timeout=30, headers={"User-Agent": "card-server/1.0"}) as client: while True: slug = await queue.get() try: await _process(client, slug) except Exception as e: db.update_deck(slug, status="failed") db.add_log(slug, f"Worker error: {e}") finally: queue.task_done() async def _process(client: httpx.AsyncClient, slug: str): deck = db.get_deck(slug) if not deck: return db.update_deck(slug, status="running") deck_dir = OUTPUTS / slug deck_dir.mkdir(exist_ok=True) cards = db.get_cards(slug) pending = [c for c in cards if c["fetch_status"] == "pending"] for card in pending: name = card["name"] card_id = card["id"] scry_url = card["scry_url"] base = safe_filename(name) if name else f"card_{card_id}" filename = f"{base}_{card_id}.png" back_filename = f"{base}_{card_id}_back.png" dest = deck_dir / filename label = f"[{card['position']}] {name or scry_url}" if dest.exists(): bf = back_filename if (deck_dir / back_filename).exists() else None with db.conn() as c: price_row = c.execute( "SELECT price_usd FROM cards WHERE deck_slug=? AND name=? AND fetch_status='done' AND id!=? LIMIT 1", (slug, name, card_id) ).fetchone() price = price_row["price_usd"] if price_row else None if price is not None: c.execute("UPDATE cards SET fetch_status='done', filename=?, back_filename=?, price_usd=? WHERE id=?", (filename, bf, price, card_id)) else: c.execute("UPDATE cards SET fetch_status='done', filename=?, back_filename=? WHERE id=?", (filename, bf, card_id)) db.add_log(slug, f"{label} — SKIP") db.recalc_done(slug) continue # Name-based dedup only — URL adds are precise prints and must never be aliased if not scry_url: with db.conn() as c: row = c.execute( "SELECT filename, back_filename, price_usd FROM cards " "WHERE deck_slug=? AND name=? AND fetch_status='done' AND id!=? LIMIT 1", (slug, name, card_id) ).fetchone() if row and row["filename"] and (deck_dir / row["filename"]).exists(): shutil.copy(deck_dir / row["filename"], dest) bf = None if row["back_filename"] and (deck_dir / row["back_filename"]).exists(): shutil.copy(deck_dir / row["back_filename"], deck_dir / back_filename) bf = back_filename with db.conn() as c: c.execute("UPDATE cards SET fetch_status='done', filename=?, back_filename=?, price_usd=? WHERE id=?", (filename, bf, row["price_usd"], card_id)) db.add_log(slug, f"{label} — COPY") db.recalc_done(slug) continue try: data = await resolve_card(client, slug, name, scry_url) front_url, back_url = extract_images(data) price = float(data.get("prices", {}).get("usd") or 0) resolved_name = name or data.get("name", "") dest.write_bytes(await _fetch_bytes(client, front_url, slug)) bf = None if back_url: (deck_dir / back_filename).write_bytes(await _fetch_bytes(client, back_url, slug)) bf = back_filename with db.conn() as c: c.execute("UPDATE cards SET fetch_status='done', name=?, filename=?, back_filename=?, price_usd=? WHERE id=?", (resolved_name, filename, bf, price, card_id)) db.add_log(slug, f"[{card['position']}] {resolved_name} — OK (${price:.2f}){' 2-sided' if back_url else ''}") await asyncio.sleep(0.5) except Exception as e: with db.conn() as c: c.execute("UPDATE cards SET fetch_status='failed' WHERE id=?", (card_id,)) db.add_log(slug, f"{label} — FAIL: {e}") db.recalc_done(slug) all_cards = db.get_cards(slug) if not all_cards: db.update_deck(slug, status="complete", price_usd=0) db.add_log(slug, "Empty bundle — add cards to export.") return try: tts = build_tts(slug) (deck_dir / "deck.tts.json").write_text(json.dumps(tts, indent=2)) total_price = sum(c["price_usd"] for c in all_cards) db.update_deck(slug, status="complete", price_usd=round(total_price, 2)) db.add_log(slug, f"Complete. Deck value: ${total_price:.2f}") except Exception as e: db.update_deck(slug, status="failed") db.add_log(slug, f"TTS build failed: {e}")