diff --git a/.gitignore b/.gitignore index 8b91140..48c9c9a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ wheels/ # Virtual environments .venv outputs/ -builds/ \ No newline at end of file +builds/ +data/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index d3d9796..298ef66 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,11 +11,12 @@ RUN pip install --no-cache-dir \ httpx \ pyyaml -COPY server.py . +COPY main.py worker.py db.py config.py ./ +COPY routes/ routes/ COPY static/ static/ -VOLUME /app/outputs +RUN mkdir -p /app/data /app/outputs EXPOSE 8000 -CMD ["python", "server.py"] \ No newline at end of file +CMD ["python", "main.py"] \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..360b34d --- /dev/null +++ b/config.py @@ -0,0 +1,10 @@ +import yaml +from pathlib import Path + +_cfg = yaml.safe_load(Path("config.yaml").read_text()) +USERNAME: str = _cfg["username"] +PASSWORD: str = _cfg["password"] +BASE_URL: str = _cfg["base_url"].rstrip("/") +PORT: int = _cfg.get("port", 8000) +OUTPUTS = Path("outputs") +MTG_BACK = "https://upload.wikimedia.org/wikipedia/en/a/aa/Magic_the_Gathering_card_back.jpg" diff --git a/db.py b/db.py new file mode 100644 index 0000000..c145203 --- /dev/null +++ b/db.py @@ -0,0 +1,90 @@ +import sqlite3 +from pathlib import Path + +DB_PATH = Path("data/cardpull.db") + + +def conn(): + c = sqlite3.connect(str(DB_PATH)) + c.row_factory = sqlite3.Row + c.execute("PRAGMA journal_mode=WAL") + c.execute("PRAGMA foreign_keys=ON") + return c + + +def init(): + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + with conn() as c: + c.executescript(""" + CREATE TABLE IF NOT EXISTS decks ( + slug TEXT PRIMARY KEY, + deck_name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + commander TEXT, + price_usd REAL DEFAULT 0, + done INTEGER DEFAULT 0, + total INTEGER DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS cards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + deck_slug TEXT NOT NULL REFERENCES decks(slug) ON DELETE CASCADE, + name TEXT NOT NULL, + position INTEGER NOT NULL, + filename TEXT, + price_usd REAL DEFAULT 0, + fetch_status TEXT DEFAULT 'pending' + ); + CREATE TABLE IF NOT EXISTS logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + deck_slug TEXT NOT NULL REFERENCES decks(slug) ON DELETE CASCADE, + line TEXT NOT NULL + ); + """) + + +def get_deck(slug: str): + with conn() as c: + return c.execute("SELECT * FROM decks WHERE slug=?", (slug,)).fetchone() + + +def get_decks(): + with conn() as c: + return c.execute("SELECT * FROM decks ORDER BY rowid").fetchall() + + +def get_cards(slug: str): + with conn() as c: + return c.execute( + "SELECT * FROM cards WHERE deck_slug=? ORDER BY position, id", (slug,) + ).fetchall() + + +def get_logs(slug: str): + with conn() as c: + rows = c.execute("SELECT line FROM logs WHERE deck_slug=? ORDER BY id", (slug,)).fetchall() + return [r["line"] for r in rows] + + +def add_log(slug: str, line: str): + with conn() as c: + c.execute("INSERT INTO logs (deck_slug, line) VALUES (?,?)", (slug, line)) + + +def update_deck(slug: str, **kw): + if not kw: + return + sets = ", ".join(f"{k}=?" for k in kw) + with conn() as c: + c.execute(f"UPDATE decks SET {sets} WHERE slug=?", [*kw.values(), slug]) + + +def recalc_done(slug: str): + with conn() as c: + done = c.execute( + "SELECT COUNT(*) FROM cards WHERE deck_slug=? AND fetch_status IN ('done','failed','skipped')", + (slug,) + ).fetchone()[0] + total = c.execute( + "SELECT COUNT(*) FROM cards WHERE deck_slug=?", (slug,) + ).fetchone()[0] + c.execute("UPDATE decks SET done=?, total=? WHERE slug=?", (done, total, slug)) \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..5aee352 --- /dev/null +++ b/main.py @@ -0,0 +1,92 @@ +import asyncio +import json +import secrets +from collections import Counter +from contextlib import asynccontextmanager + +from fastapi import Depends, FastAPI, HTTPException +from fastapi.responses import FileResponse +from fastapi.security import HTTPBasic, HTTPBasicCredentials + +import db +import worker +from config import OUTPUTS, USERNAME, PASSWORD, PORT +from routes.decks import router as decks_router +from routes.cards import router as cards_router, public_router +from worker import safe_filename + +security = HTTPBasic() + + +def auth(creds: HTTPBasicCredentials = Depends(security)): + ok = ( + secrets.compare_digest(creds.username.encode(), USERNAME.encode()) + and secrets.compare_digest(creds.password.encode(), PASSWORD.encode()) + ) + if not ok: + raise HTTPException(401, headers={"WWW-Authenticate": "Basic"}) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + db.init() + OUTPUTS.mkdir(exist_ok=True) + + for d in OUTPUTS.iterdir(): + if not d.is_dir() or not (d / "cards.txt").exists(): + continue + slug = d.name + if db.get_deck(slug): + continue + + card_names = [l for l in (d / "cards.txt").read_text().splitlines() if l] + status = "failed" + deck_name = slug.replace("_", " ").title() + if (d / "deck.tts.json").exists(): + status = "complete" + try: + deck_name = json.loads((d / "deck.tts.json").read_text()).get("SaveName", deck_name) + except Exception: + pass + + counts = Counter(card_names) + seen: Counter = Counter() + + with db.conn() as c: + c.execute( + "INSERT OR IGNORE INTO decks (slug, deck_name, status, commander, price_usd, done, total) VALUES (?,?,?,?,0,?,?)", + (slug, deck_name, status, card_names[0] if card_names else None, len(card_names), len(card_names)) + ) + for i, name in enumerate(card_names): + seen[name] += 1 + occ = seen[name] + total = counts[name] + base = safe_filename(name) + suffix = f"_{occ}" if total > 1 else "" + filename = f"{base}{suffix}.png" + exists = (d / filename).exists() + c.execute( + "INSERT INTO cards (deck_slug, name, position, filename, fetch_status) VALUES (?,?,?,?,?)", + (slug, name, i + 1, filename if exists else None, "done" if exists else "failed") + ) + c.execute("INSERT INTO logs (deck_slug, line) VALUES (?,?)", (slug, "Loaded from disk.")) + + asyncio.create_task(worker.run()) + yield + + +app = FastAPI(lifespan=lifespan) + +app.include_router(decks_router, dependencies=[Depends(auth)]) +app.include_router(cards_router, dependencies=[Depends(auth)]) +app.include_router(public_router) # no auth — TTS app fetches card images directly + + +@app.get("/") +async def index(_=Depends(auth)): + return FileResponse("static/index.html") + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=PORT) diff --git a/routes/cards.py b/routes/cards.py new file mode 100644 index 0000000..3c5ec60 --- /dev/null +++ b/routes/cards.py @@ -0,0 +1,109 @@ +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()), + "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, + }) + 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") diff --git a/routes/decks.py b/routes/decks.py new file mode 100644 index 0000000..ffca780 --- /dev/null +++ b/routes/decks.py @@ -0,0 +1,150 @@ +import re +import shutil + +from fastapi import APIRouter, HTTPException, Request + +import db +import worker +from config import OUTPUTS + +router = APIRouter() + + +def slugify(name: str) -> str: + return re.sub(r"[^a-z0-9]+", "_", name.lower().strip()).strip("_") + + +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") +async def list_decks(): + return [dict(d) for d in db.get_decks()] + + +@router.get("/decks/{slug}") +async def get_deck(slug: str): + slug = valid_slug(slug) + d = db.get_deck(slug) + if not d: + raise HTTPException(404) + return {**dict(d), "log": db.get_logs(slug)} + + +@router.post("/decks", status_code=201) +async def create_deck(request: Request): + body = await request.json() + name = body.get("deck_name", "").strip() + card_text = body.get("cards", "").strip() + commander = body.get("commander", "").strip() + if not name or not card_text: + raise HTTPException(400, "deck_name and cards required") + + slug = slugify(name) + card_names = [l.strip() for l in card_text.splitlines() if l.strip()] + if not card_names: + raise HTTPException(400, "no cards") + if not commander: + commander = card_names[0] + + # Commander always at position 0; remove any existing occurrence first + card_names = [n for n in card_names if n != commander] + card_names.insert(0, commander) + + existing = db.get_deck(slug) + if existing and existing["status"] in ("queued", "running"): + raise HTTPException(409, "deck already processing") + + with db.conn() as c: + c.execute( + "INSERT OR REPLACE INTO decks (slug, deck_name, status, commander, price_usd, done, total) VALUES (?,?,?,?,0,0,?)", + (slug, name, "queued", commander, len(card_names)) + ) + c.execute("DELETE FROM cards WHERE deck_slug=?", (slug,)) + c.execute("DELETE FROM logs WHERE deck_slug=?", (slug,)) + for i, card_name in enumerate(card_names): + c.execute( + "INSERT INTO cards (deck_slug, name, position, fetch_status) VALUES (?,?,?,'pending')", + (slug, card_name, i + 1) + ) + + await worker.queue.put(slug) + return {"slug": slug} + + +@router.patch("/decks/{slug}") +async def edit_deck(slug: str, request: Request): + slug = valid_slug(slug) + deck = db.get_deck(slug) + if not deck: + raise HTTPException(404) + if deck["status"] in ("queued", "running"): + raise HTTPException(409, "deck is processing") + + body = await request.json() + remove_ids: list[int] = body.get("remove", []) + add_names: list[str] = [n.strip() for n in body.get("add", []) if n.strip()] + new_name: str = body.get("deck_name", deck["deck_name"]).strip() or deck["deck_name"] + new_commander: str = body.get("commander", deck["commander"]).strip() or deck["commander"] + + with db.conn() as c: + for cid in remove_ids: + row = c.execute( + "SELECT filename FROM cards WHERE id=? AND deck_slug=?", (cid, slug) + ).fetchone() + if row and row["filename"]: + p = OUTPUTS / slug / row["filename"] + if p.exists(): + p.unlink() + c.execute("DELETE FROM cards WHERE id=? AND deck_slug=?", (cid, slug)) + + max_pos = c.execute( + "SELECT COALESCE(MAX(position), 0) FROM cards WHERE deck_slug=?", (slug,) + ).fetchone()[0] + + for i, card_name in enumerate(add_names): + c.execute( + "INSERT INTO cards (deck_slug, name, position, fetch_status) VALUES (?,?,?,'pending')", + (slug, card_name, max_pos + i + 1) + ) + + # Ensure commander has a card row; if not, insert at position 0 so it sorts first + existing_commander = c.execute( + "SELECT id FROM cards WHERE deck_slug=? AND name=? LIMIT 1", + (slug, new_commander) + ).fetchone() + if not existing_commander: + c.execute( + "INSERT INTO cards (deck_slug, name, position, fetch_status) VALUES (?,?,0,'pending')", + (slug, new_commander) + ) + + c.execute( + "UPDATE decks SET deck_name=?, commander=?, status='queued' WHERE slug=?", + (new_name, new_commander, slug) + ) + c.execute("INSERT INTO logs (deck_slug, line) VALUES (?,?)", (slug, "Deck edited — re-queued.")) + + db.recalc_done(slug) + await worker.queue.put(slug) + return {"slug": slug} + + +@router.delete("/decks/{slug}", status_code=204) +async def delete_deck(slug: str): + slug = valid_slug(slug) + deck = db.get_deck(slug) + if not deck: + raise HTTPException(404) + if deck["status"] in ("queued", "running"): + raise HTTPException(409, "can't delete while processing") + + deck_dir = OUTPUTS / slug + if deck_dir.exists(): + shutil.rmtree(deck_dir) + with db.conn() as c: + c.execute("DELETE FROM decks WHERE slug=?", (slug,)) + return None \ No newline at end of file diff --git a/server.py b/server.py deleted file mode 100644 index 22e0759..0000000 --- a/server.py +++ /dev/null @@ -1,412 +0,0 @@ -import asyncio -import json -import re -import shutil -import secrets -from collections import Counter -from contextlib import asynccontextmanager -from dataclasses import dataclass, field -from pathlib import Path -from typing import Literal -from urllib.parse import quote - -import httpx -import yaml -from fastapi import FastAPI, Depends, HTTPException, Request -from fastapi.responses import StreamingResponse, FileResponse -from fastapi.security import HTTPBasic, HTTPBasicCredentials - -cfg = yaml.safe_load(Path("config.yaml").read_text()) -USERNAME: str = cfg["username"] -PASSWORD: str = cfg["password"] -BASE_URL: str = cfg["base_url"].rstrip("/") -PORT: int = cfg.get("port", 8000) - -OUTPUTS = Path("outputs") -MTG_BACK = "https://upload.wikimedia.org/wikipedia/en/a/aa/Magic_the_Gathering_card_back.jpg" - -security = HTTPBasic() - - -def auth(creds: HTTPBasicCredentials = Depends(security)): - ok = ( - secrets.compare_digest(creds.username.encode(), USERNAME.encode()) - and secrets.compare_digest(creds.password.encode(), PASSWORD.encode()) - ) - if not ok: - raise HTTPException(401, headers={"WWW-Authenticate": "Basic"}) - - -@dataclass -class Job: - deck_name: str - slug: str - cards: list[str] - status: Literal["queued", "running", "complete", "failed"] = "queued" - log: list[str] = field(default_factory=list) - done: int = 0 - total: int = 0 - - -jobs: dict[str, Job] = {} -queue: asyncio.Queue[str] = asyncio.Queue() - - -def slugify(name: str) -> str: - return re.sub(r"[^a-z0-9]+", "_", name.lower().strip()).strip("_") - - -def valid_slug(slug: str) -> str: - if not re.match(r"^[a-z0-9_]+$", slug): - raise HTTPException(400, "invalid slug") - return slug - - -def safe_filename(name: str) -> str: - return re.sub(r"[^a-z0-9_]", "", name.lower().replace(" ", "_")) - - -def card_dest(deck_dir: Path, name: str, occ: int, total: int) -> Path: - base = safe_filename(name) - suffix = f"_{occ}" if total > 1 else "" - return deck_dir / f"{base}{suffix}.png" - - -async def scryfall_get(client: httpx.AsyncClient, url: str, job: "Job") -> 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)) - job.log.append(f" 429 — waiting {wait:.0f}s") - await asyncio.sleep(wait) - r.raise_for_status() - return r - - -async def fetch_image(client: httpx.AsyncClient, name: str, job: "Job") -> bytes: - for param in ("exact", "fuzzy"): - url = f"https://api.scryfall.com/cards/named?{param}={quote(name.strip())}" - r = await scryfall_get(client, url, job) - if r.status_code == 200: - break - if param == "fuzzy": - r.raise_for_status() - - data = r.json() - if "image_uris" in data: - img_url = data["image_uris"]["normal"] - elif "card_faces" in data and data["card_faces"][0].get("image_uris"): - img_url = data["card_faces"][0]["image_uris"]["normal"] - else: - raise ValueError("no image_uris in scryfall response") - - job.log.append(f" img: {img_url}") - img_r = await scryfall_get(client, img_url, job) - img_r.raise_for_status() - return img_r.content - - -def build_tts(job: Job) -> dict: - deck_dir = OUTPUTS / job.slug - counts = Counter(job.cards) - seen: Counter = Counter() - - all_entries: list[tuple[str, Path]] = [] - for card in job.cards: - seen[card] += 1 - all_entries.append((card, card_dest(deck_dir, card, seen[card], counts[card]))) - - commander_name, commander_path = all_entries[0] - rest = all_entries[1:] - - def card_url(p: Path) -> str: - return f"{BASE_URL}/cards/{job.slug}/{p.name}" - - def custom_entry(url: str) -> dict: - return { - "FaceURL": url, - "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, - } - - card_base = { - "Description": "", "Locked": False, "Grid": True, "Snap": True, - "Autoraise": True, "Sticky": True, "Tooltip": True, - "HideWhenFaceDown": True, "Hands": True, "SidewaysCard": False, - "LuaScript": "", "LuaScriptState": "", "XmlUI": "", - } - - # CustomDeck key 1 → CardID 100 → commander - commander_entry = custom_entry(card_url(commander_path)) - commander_obj = { - "Name": "Card", - "Transform": transform(x=-5.0, rz=0.0), - "Nickname": commander_name, - **card_base, - "CardID": 100, - "CustomDeck": {"1": commander_entry}, - } - - deck_ids = [] - custom_deck = {} - contained = [] - - for i, (name, path) in enumerate(rest): - key = i + 2 # keys 2..N, CardIDs 200..N*100 - cid = key * 100 - entry = custom_entry(card_url(path)) - custom_deck[str(key)] = entry - deck_ids.append(cid) - contained.append({ - "Name": "Card", - "Transform": transform(), - "Nickname": name, - **card_base, - "CardID": cid, - "CustomDeck": {str(key): entry}, - }) - - deck_obj = { - "Name": "Deck", - "Transform": transform(), - "Nickname": job.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": "", - } - - return { - "SaveName": job.deck_name, - "Date": "", "VersionNumber": "", - "GameMode": "Tabletop Simulator", - "Gravity": 0.5, "PlayArea": 0.5, - "Table": "", "Sky": "", "Note": "", - "TabStates": {}, - "LuaScript": "", "LuaScriptState": "", "XmlUI": "", - "ObjectStates": [commander_obj, deck_obj], - } - - -async def worker(): - async with httpx.AsyncClient(timeout=30, headers={"User-Agent": "card-server/1.0"}) as client: - while True: - slug = await queue.get() - job = jobs.get(slug) - if not job: - queue.task_done() - continue - - job.status = "running" - deck_dir = OUTPUTS / slug - deck_dir.mkdir(exist_ok=True) - - counts = Counter(job.cards) - seen: Counter = Counter() - job.total = len(job.cards) - - try: - for i, card in enumerate(job.cards, 1): - seen[card] += 1 - occ = seen[card] - total = counts[card] - dest = card_dest(deck_dir, card, occ, total) - label = f"[{i}/{job.total}] {card}" + (f" ({occ}/{total})" if total > 1 else "") - - if dest.exists(): - job.log.append(f"{label} — SKIP") - elif occ > 1: - src = card_dest(deck_dir, card, 1, total) - if src.exists(): - shutil.copy(src, dest) - job.log.append(f"{label} — COPY") - else: - job.log.append(f"{label} — FAIL: source missing") - else: - try: - data = await fetch_image(client, card, job) - dest.write_bytes(data) - job.log.append(f"{label} — OK") - except Exception as e: - job.log.append(f"{label} — FAIL: {e}") - - job.done = i - if i < job.total and occ == 1: - await asyncio.sleep(0.5) # stay well under scryfall rate limit - - tts = build_tts(job) - tts_json = json.dumps(tts, indent=2) - (deck_dir / "deck.tts.json").write_text(tts_json) - # Saved Object: same structure, TTS spawns it into a running game - # Place in: Saves/Saved Objects/ and use Objects → Saved Objects in-game - (deck_dir / "deck.tts.object.json").write_text(tts_json) - (deck_dir / "cards.txt").write_text("\n".join(job.cards)) - job.status = "complete" - job.log.append("Complete.") - except Exception as e: - job.status = "failed" - job.log.append(f"Worker error: {e}") - finally: - queue.task_done() - - -@asynccontextmanager -async def lifespan(app: FastAPI): - OUTPUTS.mkdir(exist_ok=True) - for d in OUTPUTS.iterdir(): - if not d.is_dir() or not (d / "cards.txt").exists(): - continue - slug = d.name - if slug in jobs: - continue - cards = [l for l in (d / "cards.txt").read_text().splitlines() if l] - status: Literal["complete", "failed"] = "failed" - deck_name = slug.replace("_", " ").title() - if (d / "deck.tts.json").exists(): - status = "complete" - try: - deck_name = json.loads((d / "deck.tts.json").read_text()).get("SaveName", deck_name) - except Exception: - pass - jobs[slug] = Job( - deck_name=deck_name, slug=slug, cards=cards, - status=status, done=len(cards), total=len(cards), - log=["Loaded from disk."], - ) - asyncio.create_task(worker()) - yield - - -app = FastAPI(lifespan=lifespan) - - -@app.get("/") -async def index(_=Depends(auth)): - return FileResponse("static/index.html") - - -@app.get("/decks") -async def list_decks(_=Depends(auth)): - return [ - {"slug": j.slug, "deck_name": j.deck_name, "status": j.status, "done": j.done, "total": j.total} - for j in jobs.values() - ] - - -@app.get("/decks/{slug}") -async def get_deck(slug: str, _=Depends(auth)): - slug = valid_slug(slug) - job = jobs.get(slug) - if not job: - raise HTTPException(404) - return { - "slug": job.slug, "deck_name": job.deck_name, - "status": job.status, "done": job.done, "total": job.total, - "log": job.log, - } - - -@app.post("/decks") -async def submit_deck(request: Request, _=Depends(auth)): - body = await request.json() - name = body.get("deck_name", "").strip() - card_text = body.get("cards", "").strip() - if not name or not card_text: - raise HTTPException(400, "deck_name and cards required") - slug = slugify(name) - cards = [l.strip() for l in card_text.splitlines() if l.strip()] - if not cards: - raise HTTPException(400, "no cards") - if slug in jobs and jobs[slug].status in ("queued", "running"): - raise HTTPException(409, "deck already processing") - jobs[slug] = Job(deck_name=name, slug=slug, cards=cards, total=len(cards)) - await queue.put(slug) - return {"slug": slug} - - -@app.delete("/decks/{slug}") -async def delete_deck(slug: str, _=Depends(auth)): - slug = valid_slug(slug) - job = jobs.get(slug) - if not job: - raise HTTPException(404) - if job.status in ("queued", "running"): - raise HTTPException(409, "can't delete while processing") - deck_dir = OUTPUTS / slug - if deck_dir.exists(): - shutil.rmtree(deck_dir) - del jobs[slug] - return {"deleted": slug} - - -@app.get("/decks/{slug}/stream") -async def stream(slug: str, _=Depends(auth)): - slug = valid_slug(slug) - - async def gen(): - sent = 0 - while True: - job = jobs.get(slug) - if not job: - yield "data: not found\n\n" - return - while sent < len(job.log): - yield f"data: {job.log[sent]}\n\n" - sent += 1 - if job.status in ("complete", "failed"): - yield "data: __DONE__\n\n" - return - await asyncio.sleep(0.5) - - return StreamingResponse(gen(), media_type="text/event-stream") - - -@app.get("/decks/{slug}/tts") -async def download_tts(slug: str, _=Depends(auth)): - 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") - - -@app.get("/decks/{slug}/object") -async def download_object(slug: str, _=Depends(auth)): - slug = valid_slug(slug) - p = OUTPUTS / slug / "deck.tts.object.json" - if not p.exists(): - raise HTTPException(404) - return FileResponse(p, filename=f"{slug}.object.json", media_type="application/json") - - -# No auth — TTS desktop app fetches these URLs directly -@app.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) - media_type = "image/png" if filename.endswith(".png") else "image/webp" - return FileResponse(p, media_type=media_type) - - -if __name__ == "__main__": - import uvicorn - uvicorn.run(app, host="0.0.0.0", port=PORT) \ No newline at end of file diff --git a/static/index.html b/static/index.html index 807eabe..4112ccd 100644 --- a/static/index.html +++ b/static/index.html @@ -2,52 +2,26 @@ + Card Server @@ -111,57 +126,59 @@ textarea { min-height: 150px; resize: vertical; }
New Deck
- + +
-
Decks
-
-
No decks yet.
+
No decks yet.
+
+ +
+
+ +
Select a deck or submit a new one.
+
-
-
- - - -
-
- Submit a new deck or select one from the list.
- - TTS save → Documents/My Games/Tabletop Simulator/Saves/ - -
-
+ - \ No newline at end of file + diff --git a/uv.lock b/uv.lock index cd609cf..63b5b52 100644 --- a/uv.lock +++ b/uv.lock @@ -39,7 +39,6 @@ source = { virtual = "." } dependencies = [ { name = "fastapi" }, { name = "httpx" }, - { name = "playwright" }, { name = "pyyaml" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -48,7 +47,6 @@ dependencies = [ requires-dist = [ { name = "fastapi", specifier = ">=0.136.1" }, { name = "httpx", specifier = ">=0.28.1" }, - { name = "playwright", specifier = ">=1.59.0" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.46.0" }, ] @@ -99,33 +97,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, ] -[[package]] -name = "greenlet" -version = "3.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" }, - { url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" }, - { url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" }, - { url = "https://files.pythonhosted.org/packages/fb/89/2dadb89793c37ee8b4c237857188293e9060dc085f19845c292e00f8e091/greenlet-3.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37", size = 668086, upload-time = "2026-04-27T13:02:42.314Z" }, - { url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" }, - { url = "https://files.pythonhosted.org/packages/82/35/75722be7e26a2af4cbd2dc35b0ed382dacf9394b7e75551f76ed1abe87f2/greenlet-3.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2", size = 470799, upload-time = "2026-04-27T13:05:17.094Z" }, - { url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" }, - { url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" }, - { url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" }, - { url = "https://files.pythonhosted.org/packages/4e/62/1c498375cee177b55d980c1db319f26470e5309e54698c8f8fc06c0fd539/greenlet-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988", size = 236862, upload-time = "2026-04-27T12:23:24.957Z" }, - { url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" }, - { url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" }, - { url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5c/0602239503b124b70e39355cbdb39361ecfe65b87a5f2f63752c32f5286f/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce", size = 657015, upload-time = "2026-04-27T13:02:43.973Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" }, - { url = "https://files.pythonhosted.org/packages/38/51/8699f865f125dc952384cb432b0f7138aa4d8f2969a7d12d0df5b94d054d/greenlet-3.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2", size = 488275, upload-time = "2026-04-27T13:05:18.28Z" }, - { url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" }, - { url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" }, - { url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" }, -] - [[package]] name = "h11" version = "0.16.0" @@ -187,25 +158,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, ] -[[package]] -name = "playwright" -version = "1.59.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "greenlet" }, - { name = "pyee" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/48/abab23f40643b4de8f2665816f0a1bf0994eeecda39d6d62f0f292b2ad01/playwright-1.59.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:bfc6940100b57423175c819ce2422ec5880d55fa2769987f62ab7a1f5fe6783e", size = 43156922, upload-time = "2026-04-29T08:11:08.921Z" }, - { url = "https://files.pythonhosted.org/packages/08/71/5e4d98b2ce3641b4343623c6450ff33b9de1c979d12a957505e392338b07/playwright-1.59.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:af068143a0c045ec11608b67d6c42e58db7e9cf65a742dd21fddedc1a9802c47", size = 41947177, upload-time = "2026-04-29T08:11:12.867Z" }, - { url = "https://files.pythonhosted.org/packages/80/91/fd219aa78ca03d37e93aaedaed4e224131e3090a9264f9bb773c8271d67e/playwright-1.59.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:4a4a2d4842b0e4120de3fa48636e4b69085a05b81d8a35ad4353f530ade72ed6", size = 43156922, upload-time = "2026-04-29T08:11:16.595Z" }, - { url = "https://files.pythonhosted.org/packages/73/0c/1e513d37c5be07d12829ebce93dbfe7baee230084cb66966c423432799c4/playwright-1.59.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c5792aad9e22b91a09264b9edbc18553cf05ea5a39404d65dc19a012c6b2e51d", size = 47151793, upload-time = "2026-04-29T08:11:19.979Z" }, - { url = "https://files.pythonhosted.org/packages/a3/2d/15f72288cb65d690134e18fefb9483cc4976f7579b580648c45e494481a7/playwright-1.59.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c881a19377d2b900af855fb525b5f22a27bf3cfbecba6d1edb36766d56cb100", size = 46877615, upload-time = "2026-04-29T08:11:23.863Z" }, - { url = "https://files.pythonhosted.org/packages/72/a1/717ac5bc99f387c0f60def91271ea4262125c0815d764a5d1776a272275c/playwright-1.59.0-py3-none-win32.whl", hash = "sha256:6989c476be2b9cd3e24a18cc9dcf202e266fb3d91e3e5395cd668c54ea54b119", size = 37713698, upload-time = "2026-04-29T08:11:27.251Z" }, - { url = "https://files.pythonhosted.org/packages/0f/a5/4e630ee05d8b46b840f943268e86d6063703e8dadb2d3eb405c7b9b2e48c/playwright-1.59.0-py3-none-win_amd64.whl", hash = "sha256:d5a5cc064b82ca92996080025710844e417f44df8fda9001102c28f44174171c", size = 37713704, upload-time = "2026-04-29T08:11:30.41Z" }, - { url = "https://files.pythonhosted.org/packages/eb/0c/3ece41761ba13c8321009aefcaec7a016eb42799c42eef5e03ace7f2de5b/playwright-1.59.0-py3-none-win_arm64.whl", hash = "sha256:93581ad515728cadc8af39b288a5633ba6d36e7d72048e79d890ce01ea2156f9", size = 33956745, upload-time = "2026-04-29T08:11:34.738Z" }, -] - [[package]] name = "pydantic" version = "2.13.4" @@ -262,18 +214,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, ] -[[package]] -name = "pyee" -version = "13.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, -] - [[package]] name = "python-dotenv" version = "1.2.2" diff --git a/worker.py b/worker.py new file mode 100644 index 0000000..13b9563 --- /dev/null +++ b/worker.py @@ -0,0 +1,209 @@ +import asyncio +import json +import re +import shutil +from urllib.parse import quote + +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(" ", "_")) + + +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_card_data(client: httpx.AsyncClient, name: str, slug: str) -> tuple[bytes, float]: + 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: + break + if param == "fuzzy": + r.raise_for_status() + + data = r.json() + price = float(data.get("prices", {}).get("usd") or 0) + + if "image_uris" in data: + img_url = data["image_uris"]["normal"] + elif data.get("card_faces") and data["card_faces"][0].get("image_uris"): + img_url = data["card_faces"][0]["image_uris"]["normal"] + else: + raise ValueError("no image_uris in response") + + img_r = await _scryfall_get(client, img_url, slug) + img_r.raise_for_status() + return img_r.content, price + + +def build_tts(slug: str) -> dict: + deck = db.get_deck(slug) + cards = db.get_cards(slug) + commander_name = deck["commander"] + + done_cards = [c for c in cards if c["fetch_status"] == "done" and c["filename"]] + if not done_cards: + raise ValueError("no successfully fetched cards") + + commander = next((c for c in done_cards if c["name"] == commander_name), done_cards[0]) + rest = [c for c in done_cards if c["id"] != commander["id"]] + + def card_url(filename: str) -> str: + return f"{BASE_URL}/cards/{slug}/{filename}" + + def custom_entry(url: str) -> dict: + return { + "FaceURL": url, "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": "", + } + + commander_obj = { + "Name": "Card", "Transform": transform(x=-5.0, rz=0.0), + "Nickname": commander["name"], **base, + "CardID": 100, + "CustomDeck": {"1": custom_entry(card_url(commander["filename"]))}, + } + + deck_ids, custom_deck, contained = [], {}, [] + for i, card in enumerate(rest): + key = str(i + 2) + cid = (i + 2) * 100 + entry = custom_entry(card_url(card["filename"])) + custom_deck[key] = entry + deck_ids.append(cid) + contained.append({ + "Name": "Card", "Transform": transform(), "Nickname": card["name"], + **base, "CardID": cid, "CustomDeck": {key: entry}, + }) + + deck_obj = { + "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": "", + } + + return { + "SaveName": deck["deck_name"], "Date": "", "VersionNumber": "", + "GameMode": "Tabletop Simulator", "Gravity": 0.5, "PlayArea": 0.5, + "Table": "", "Sky": "", "Note": "", "TabStates": {}, + "LuaScript": "", "LuaScriptState": "", "XmlUI": "", + "ObjectStates": [commander_obj, deck_obj], + } + + +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"] + filename = f"{safe_filename(name)}_{card_id}.png" + dest = deck_dir / filename + label = f"[{card['position']}] {name}" + + with db.conn() as c: + existing_row = c.execute( + "SELECT filename FROM cards WHERE deck_slug=? AND name=? AND fetch_status='done' AND id!=? LIMIT 1", + (slug, name, card_id) + ).fetchone() + existing_filename = existing_row["filename"] if existing_row else None + + if dest.exists(): + db.add_log(slug, f"{label} — SKIP") + with db.conn() as c: + c.execute("UPDATE cards SET fetch_status='done', filename=? WHERE id=?", (filename, card_id)) + elif existing_filename: + src = deck_dir / existing_filename + if src.exists(): + shutil.copy(src, dest) + with db.conn() as c: + price_row = c.execute( + "SELECT price_usd FROM cards WHERE deck_slug=? AND name=? AND fetch_status='done' LIMIT 1", + (slug, name) + ).fetchone() + price = price_row["price_usd"] if price_row else 0.0 + c.execute("UPDATE cards SET fetch_status='done', filename=?, price_usd=? WHERE id=?", + (filename, price, card_id)) + db.add_log(slug, f"{label} — COPY") + else: + db.add_log(slug, f"{label} — FAIL: source missing") + with db.conn() as c: + c.execute("UPDATE cards SET fetch_status='failed' WHERE id=?", (card_id,)) + else: + try: + data, price = await fetch_card_data(client, name, slug) + dest.write_bytes(data) + with db.conn() as c: + c.execute("UPDATE cards SET fetch_status='done', filename=?, price_usd=? WHERE id=?", + (filename, price, card_id)) + db.add_log(slug, f"{label} — OK (${price:.2f})") + 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) + + 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 db.get_cards(slug)) + 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}")