import asyncio import io import re from pathlib import Path from urllib.parse import quote import httpx from fastapi import APIRouter, File, Form, HTTPException, UploadFile from fastapi.responses import FileResponse, StreamingResponse from PIL import Image import db from worker import safe_filename from config import OUTPUTS router = APIRouter() public_router = APIRouter() def valid_slug(slug: str) -> str: if not re.match(r"^[a-z0-9_]+$", slug): raise HTTPException(400, "invalid slug") return slug @router.get("/decks/{slug}/cards") async def deck_cards(slug: str): slug = valid_slug(slug) if not db.get_deck(slug): raise HTTPException(404) cards = db.get_cards(slug) return [ { "id": c["id"], "name": c["name"], "position": c["position"], "filename": c["filename"], "exists": bool(c["filename"] and (OUTPUTS / slug / c["filename"]).exists()), "back_filename": c["back_filename"], "back_exists": bool(c["back_filename"] and (OUTPUTS / slug / c["back_filename"]).exists()), "scry_url": c["scry_url"], "is_token": bool(c["is_token"]), "fetch_status": c["fetch_status"], "price_usd": c["price_usd"], } for c in cards ] @router.post("/decks/{slug}/upload-image", status_code=201) async def upload_image(slug: str, name: str = Form(...), file: UploadFile = File(...)): slug = valid_slug(slug) deck = db.get_deck(slug) if not deck: raise HTTPException(404) if deck["source"] == "moxfield": raise HTTPException(409, "Moxfield-linked decks are updated by syncing, not manual edits") name = name.strip() if not name: raise HTTPException(400, "name required") raw = await file.read() try: img = Image.open(io.BytesIO(raw)) img.load() except Exception: raise HTTPException(400, "unreadable image") if img.mode != "RGB": img = img.convert("RGB") deck_dir = OUTPUTS / slug deck_dir.mkdir(exist_ok=True) with db.conn() as c: max_pos = c.execute( "SELECT COALESCE(MAX(position), 0) FROM cards WHERE deck_slug=?", (slug,) ).fetchone()[0] cur = c.execute( "INSERT INTO cards (deck_slug, name, position, fetch_status, price_usd) VALUES (?,?,?,?,0)", (slug, name, max_pos + 1, "done") ) card_id = cur.lastrowid filename = f"{safe_filename(name)}_{card_id}.png" img.save(deck_dir / filename, "PNG") c.execute("UPDATE cards SET filename=? WHERE id=?", (filename, card_id)) c.execute("INSERT INTO logs (deck_slug, line) VALUES (?,?)", (slug, f"[{max_pos + 1}] {name} — UPLOADED")) db.recalc_done(slug) return {"id": card_id, "filename": filename} @router.get("/decks/{slug}/stream") async def stream(slug: str): slug = valid_slug(slug) async def gen(): sent = 0 while True: deck = db.get_deck(slug) if not deck: yield "data: not found\n\n" return logs = db.get_logs(slug) while sent < len(logs): yield f"data: {logs[sent]}\n\n" sent += 1 if deck["status"] in ("complete", "failed"): yield "data: __DONE__\n\n" return await asyncio.sleep(0.5) return StreamingResponse(gen(), media_type="text/event-stream") @router.get("/decks/{slug}/tts") async def download_tts(slug: str): slug = valid_slug(slug) p = OUTPUTS / slug / "deck.tts.json" if not p.exists(): raise HTTPException(404) return FileResponse(p, filename=f"{slug}.json", media_type="application/json") @router.get("/search") async def search_cards(q: str = ""): if len(q) < 2: raise HTTPException(400, "query too short") url = f"https://api.scryfall.com/cards/search?q=name:{quote(q)}&unique=cards&order=name" async with httpx.AsyncClient(timeout=10, headers={"User-Agent": "card-server/1.0"}) as client: r = await client.get(url) if r.status_code == 404: return [] r.raise_for_status() results = [] for card in r.json().get("data", [])[:12]: img = None if "image_uris" in card: img = card["image_uris"].get("normal") elif card.get("card_faces") and card["card_faces"][0].get("image_uris"): img = card["card_faces"][0]["image_uris"]["normal"] results.append({ "name": card["name"], "set_name": card.get("set_name", ""), "price": card.get("prices", {}).get("usd"), "image": img, "scry_url": card.get("scryfall_uri", "").split("?")[0], }) return results # No auth — TTS desktop app fetches these directly @public_router.get("/cards/{slug}/{filename}") async def serve_card(slug: str, filename: str): slug = Path(slug).name filename = Path(filename).name p = OUTPUTS / slug / filename if not p.exists(): raise HTTPException(404) return FileResponse(p, media_type="image/png")