Able to be edited, other cleanup
This commit is contained in:
+109
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user