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 @@
+