Able to be edited, other cleanup

This commit is contained in:
2026-05-09 15:12:15 -05:00
parent e8b385c923
commit 486cb348bb
11 changed files with 1061 additions and 669 deletions
+109
View File
@@ -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")
+150
View File
@@ -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