113 lines
3.5 KiB
Python
113 lines
3.5 KiB
Python
import asyncio
|
|
import re
|
|
from pathlib import Path
|
|
from urllib.parse import quote
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, HTTPException
|
|
from fastapi.responses import FileResponse, StreamingResponse
|
|
|
|
import db
|
|
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"],
|
|
"fetch_status": c["fetch_status"],
|
|
"price_usd": c["price_usd"],
|
|
}
|
|
for c in cards
|
|
]
|
|
|
|
|
|
@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") |