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
+1
View File
@@ -10,3 +10,4 @@ wheels/
.venv
outputs/
builds/
data/
+4 -3
View File
@@ -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"]
CMD ["python", "main.py"]
+10
View File
@@ -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"
+90
View File
@@ -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))
+92
View File
@@ -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)
+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
-412
View File
@@ -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)
+394 -192
View File
@@ -2,52 +2,26 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Card Server</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f0f0f;
--surface: #161616;
--border: #252525;
--text: #d8d8d8;
--muted: #555;
--green: #4caf50;
--red: #e57373;
--yellow: #ffd54f;
--blue: #64b5f6;
--purple: #ce93d8;
}
body {
background: var(--bg); color: var(--text);
font-family: 'Courier New', monospace; font-size: 13px;
display: flex; height: 100vh; overflow: hidden;
--bg: #0f0f0f; --border: #252525; --text: #d8d8d8; --muted: #555;
--green: #4caf50; --red: #e57373; --yellow: #ffd54f; --blue: #64b5f6; --gold: #ffc107;
}
body { background: var(--bg); color: var(--text); font-family: 'Courier New', monospace; font-size: 13px; display: flex; height: 100vh; overflow: hidden; }
/* ── Sidebar ── */
.sidebar {
width: 310px; min-width: 310px;
border-right: 1px solid var(--border);
display: flex; flex-direction: column; overflow: hidden;
}
.section { padding: 14px; border-bottom: 1px solid var(--border); }
.section-label {
font-size: 10px; text-transform: uppercase; letter-spacing: 1.5px;
color: var(--muted); margin-bottom: 10px;
}
input, textarea {
width: 100%; background: #1c1c1c; border: 1px solid var(--border);
color: var(--text); padding: 7px 9px; font-family: inherit; font-size: 13px;
border-radius: 2px; outline: none;
}
input { margin-bottom: 7px; }
/* Sidebar */
.sidebar { width: 280px; min-width: 280px; border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
.section { padding: 12px; border-bottom: 1px solid var(--border); }
.section-label { font-size: 10px; text-transform: uppercase; letter-spacing: 1.5px; color: var(--muted); margin-bottom: 8px; }
input, textarea { width: 100%; background: #1c1c1c; border: 1px solid var(--border); color: var(--text); padding: 6px 8px; font-family: inherit; font-size: 12px; border-radius: 2px; outline: none; }
input { margin-bottom: 6px; }
input:focus, textarea:focus { border-color: #3a3a3a; }
textarea { min-height: 150px; resize: vertical; }
.btn {
background: #1c1c1c; border: 1px solid var(--border); color: var(--text);
padding: 6px 11px; font-family: inherit; font-size: 12px;
cursor: pointer; border-radius: 2px; white-space: nowrap;
}
textarea { resize: vertical; }
#cardList { min-height: 120px; }
.btn { background: #1c1c1c; border: 1px solid var(--border); color: var(--text); padding: 5px 10px; font-family: inherit; font-size: 12px; cursor: pointer; border-radius: 2px; white-space: nowrap; }
.btn:hover { background: #222; border-color: #3a3a3a; }
.btn:disabled { opacity: 0.4; cursor: default; }
.btn-green { color: var(--green); border-color: #1e3a1e; }
@@ -56,53 +30,94 @@ textarea { min-height: 150px; resize: vertical; }
.btn-red:hover { background: #1e1414; }
.btn-blue { color: var(--blue); border-color: #1e2e3a; }
.btn-blue:hover { background: #141c22; }
.row { display: flex; gap: 6px; margin-top: 8px; }
.btn-gold { color: var(--gold); border-color: #3a2e00; }
.btn-gold:hover { background: #1e1800; }
.row { display: flex; gap: 6px; margin-top: 7px; flex-wrap: wrap; }
.error-text { color: var(--red); font-size: 11px; margin-top: 5px; }
.deck-list { flex: 1; overflow-y: auto; }
.deck-row {
padding: 10px 14px; border-bottom: 1px solid var(--border);
cursor: pointer; display: flex; align-items: center; gap: 8px;
transition: background 0.1s;
}
.deck-row { padding: 9px 12px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 8px; }
.deck-row:hover { background: #181818; }
.deck-row.active { background: #1a1a24; border-left: 2px solid #3a3a6a; }
.deck-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.badge {
font-size: 10px; padding: 2px 6px; border-radius: 2px; white-space: nowrap;
}
.deck-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 12px; }
.badge { font-size: 10px; padding: 2px 5px; border-radius: 2px; white-space: nowrap; }
.badge-complete { color: var(--green); background: #141e14; }
.badge-running { color: var(--yellow); background: #1e1a0e; }
.badge-queued { color: var(--blue); background: #101622; }
.badge-failed { color: var(--red); background: #1e1010; }
/* ── Main panel ── */
.panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.badge-queued { color: var(--blue); background: #101622; }
.badge-failed { color: var(--red); background: #1e1010; }
/* Main panel */
.panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
.progress-bar { height: 2px; background: var(--border); flex-shrink: 0; }
.progress-fill { height: 100%; background: var(--green); transition: width 0.5s ease; }
.progress-fill { height: 100%; background: var(--green); transition: width 0.4s ease; }
.deck-header { padding: 9px 14px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 7px; flex-shrink: 0; flex-wrap: wrap; }
.deck-header-title { flex: 1; font-weight: bold; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 80px; }
.deck-header-meta { font-size: 11px; color: var(--muted); }
.price-tag { font-size: 11px; color: var(--green); }
.log-header {
padding: 11px 16px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 8px; flex-shrink: 0;
/* Card area */
.card-area { flex: 1; overflow-y: auto; padding: 12px 14px; }
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 8px; }
.card-tile { position: relative; border-radius: 4px; overflow: hidden; background: #1a1a1a; border: 1px solid var(--border); aspect-ratio: 63/88; cursor: pointer; }
.card-tile img { width: 100%; height: 100%; object-fit: cover; display: block; }
.card-tile.missing { display: flex; align-items: center; justify-content: center; text-align: center; padding: 6px; font-size: 10px; color: var(--red); border-color: #3a1e1e; cursor: default; }
.card-tile.is-commander { border: 2px solid var(--gold); }
.card-tile.marked-remove { opacity: 0.35; border-color: var(--red); }
.card-label { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(0,0,0,0.82); color: #fff; font-size: 9px; padding: 3px 5px; opacity: 0; transition: opacity 0.15s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card-tile:hover .card-label { opacity: 1; }
.commander-crown { position: absolute; top: 3px; left: 3px; font-size: 13px; line-height: 1; pointer-events: none; text-shadow: 0 1px 3px rgba(0,0,0,0.9); }
.card-overlay-btn { position: absolute; display: none; border: none; cursor: pointer; font-size: 13px; line-height: 1; padding: 3px 5px; border-radius: 2px; }
.btn-remove-card { top: 3px; right: 3px; background: rgba(180,0,0,0.8); color: #fff; }
.btn-remove-card:hover { background: rgba(220,0,0,0.95); }
.btn-set-commander { top: 3px; left: 3px; background: rgba(0,0,0,0.75); color: var(--gold); }
.btn-set-commander:hover { background: rgba(40,30,0,0.95); }
.edit-mode .card-tile:not(.missing) .card-overlay-btn { display: block; }
.edit-mode .card-tile.is-commander .btn-set-commander { display: none; }
.placeholder { color: #333; padding: 40px 20px; text-align: center; line-height: 2; }
/* Edit add section */
.edit-add-section { margin-top: 16px; border-top: 1px solid var(--border); padding-top: 14px; }
.search-box { position: relative; }
.search-results { background: #141414; border: 1px solid var(--border); border-top: none; border-radius: 0 0 2px 2px; max-height: 280px; overflow-y: auto; display: none; }
.search-results.open { display: block; }
.search-result { display: flex; align-items: center; gap: 8px; padding: 6px 8px; cursor: pointer; border-bottom: 1px solid #1a1a1a; }
.search-result:hover { background: #1c1c1c; }
.search-result img { width: 32px; height: 45px; object-fit: cover; border-radius: 2px; flex-shrink: 0; }
.search-result-info { flex: 1; min-width: 0; }
.search-result-name { font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.search-result-meta { font-size: 10px; color: var(--muted); }
.search-result-add { font-size: 10px; color: var(--green); padding: 2px 6px; background: #141e14; border: 1px solid #1e3a1e; border-radius: 2px; white-space: nowrap; flex-shrink: 0; }
.search-result-add:hover { background: #1a2a1a; }
#bulkAddArea { min-height: 70px; margin-top: 6px; }
/* Log */
.log-footer { flex-shrink: 0; border-top: 1px solid var(--border); }
.log-toggle { padding: 6px 14px; cursor: pointer; display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--muted); user-select: none; }
.log-toggle:hover { background: #181818; color: var(--text); }
.log-arrow { font-size: 9px; transition: transform 0.2s; display: inline-block; }
.log-arrow.open { transform: rotate(90deg); }
.log-fail { color: var(--red); }
.log-content { display: none; max-height: 160px; overflow-y: auto; padding: 7px 14px; font-size: 11px; line-height: 1.7; border-top: 1px solid var(--border); background: #0c0c0c; }
.log-content.open { display: block; }
.ll { color: var(--muted); white-space: pre-wrap; word-break: break-all; }
.ll.ok { color: var(--green); }
.ll.fail { color: var(--red); }
.ll.copy { color: var(--blue); }
.ll.skip { color: #333; }
/* Lightbox */
.lightbox { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.92); z-index: 200; align-items: center; justify-content: center; cursor: zoom-out; }
.lightbox.open { display: flex; }
.lightbox img { max-height: 88vh; max-width: 92vw; border-radius: 6px; box-shadow: 0 8px 40px rgba(0,0,0,0.8); pointer-events: none; }
/* Mobile */
@media (max-width: 620px) {
body { flex-direction: column; height: auto; min-height: 100vh; overflow: auto; }
.sidebar { width: 100%; min-width: unset; border-right: none; border-bottom: 1px solid var(--border); max-height: none; }
.deck-list { max-height: 200px; }
.panel { min-height: 60vh; overflow: visible; }
.card-area { overflow: visible; }
.log-content { max-height: 120px; }
}
.log-title { flex: 1; font-weight: bold; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.log-body {
flex: 1; overflow-y: auto; padding: 14px 16px;
line-height: 1.75; font-size: 12px;
}
.log-line { color: var(--muted); white-space: pre-wrap; word-break: break-all; }
.log-line.ok { color: var(--green); }
.log-line.fail { color: var(--red); }
.log-line.copy { color: var(--blue); }
.log-line.skip { color: #3a3a3a; }
.log-line.done { color: var(--text); font-weight: bold; }
.placeholder { color: #333; padding: 32px; text-align: center; line-height: 2; }
.error-text { color: var(--red); font-size: 11px; margin-top: 6px; }
.hint { font-size: 11px; color: var(--muted); margin-top: 8px; line-height: 1.6; }
</style>
</head>
<body>
@@ -111,57 +126,59 @@ textarea { min-height: 150px; resize: vertical; }
<div class="section">
<div class="section-label">New Deck</div>
<input id="deckName" placeholder="Deck name" />
<textarea id="cardList" placeholder="Paste card list — one per line&#10;First card = commander"></textarea>
<input id="deckCommander" placeholder="Commander (defaults to first card)" />
<textarea id="cardList" placeholder="Paste card list — one per line"></textarea>
<div class="row">
<button class="btn btn-green" id="submitBtn" onclick="submitDeck()" style="flex:1">Submit</button>
<button class="btn" onclick="clearForm()">Clear</button>
</div>
<div id="submitError" class="error-text" style="display:none"></div>
</div>
<div class="section" style="padding-bottom:8px; border-bottom:none;">
<div class="section-label">Decks</div>
</div>
<div class="deck-list" id="deckList">
<div class="placeholder">No decks yet.</div>
<div class="deck-list" id="deckList"><div class="placeholder">No decks yet.</div></div>
</div>
<div class="panel" id="mainPanel">
<div class="progress-bar"><div class="progress-fill" id="progressFill" style="width:0"></div></div>
<div class="deck-header" id="deckHeader" style="display:none">
<span class="deck-header-title" id="headerTitle"></span>
<span class="deck-header-meta" id="headerMeta"></span>
<span class="price-tag" id="priceTag"></span>
<button class="btn btn-gold" id="editBtn" onclick="enterEditMode()" style="display:none">Edit</button>
<button class="btn btn-green" id="saveBtn" onclick="saveEdit()" style="display:none">Save</button>
<button class="btn" id="cancelBtn" onclick="exitEditMode(true)" style="display:none">Cancel</button>
<button class="btn btn-blue" id="dlBtn" onclick="downloadTTS()" style="display:none">&#8595; TTS</button>
<button class="btn btn-red" id="delBtn" onclick="deleteDeck()" style="display:none">Delete</button>
</div>
<div class="card-area" id="cardArea"><div class="placeholder">Select a deck or submit a new one.</div></div>
<div class="log-footer" id="logFooter" style="display:none">
<div class="log-toggle" onclick="toggleLog()">
<span class="log-arrow" id="logArrow">&#9658;</span>
<span id="logLabel">Log</span>
</div>
<div class="log-content" id="logContent"></div>
</div>
</div>
<div class="panel">
<div class="progress-bar"><div class="progress-fill" id="progressFill" style="width:0"></div></div>
<div class="log-header" id="logHeader" style="display:none">
<span class="log-title" id="logTitle"></span>
<button class="btn btn-blue" id="dlBtn" onclick="downloadTTS()" style="display:none">↓ Full Save</button>
<button class="btn btn-blue" id="dlObjBtn" onclick="downloadObject()" style="display:none">↓ Saved Object</button>
<button class="btn btn-red" id="delBtn" onclick="deleteDeck()" style="display:none">Delete</button>
</div>
<div class="log-body" id="logBody">
<div class="placeholder">
Submit a new deck or select one from the list.<br>
<span style="font-size:11px; color:#2a2a2a">
TTS save → Documents/My Games/Tabletop Simulator/Saves/
</span>
</div>
</div>
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
<img id="lightboxImg" />
</div>
<script>
let activeDeck = null;
let sse = null;
let deckData = {};
let activeDeck = null, sse = null, cardPollTimer = null;
let deckData = {}, currentCards = [];
let editMode = false, editRemovals = new Set(), editCommander = null;
let searchTimer = null;
async function api(method, path, body) {
const r = await fetch(path, {
method,
headers: body ? { "Content-Type": "application/json" } : {},
body: body ? JSON.stringify(body) : undefined,
headers: body ? {"Content-Type":"application/json"} : {},
body: body ? JSON.stringify(body) : undefined
});
if (!r.ok) {
const t = await r.text().catch(() => r.statusText);
throw new Error(t);
}
if (!r.ok) throw new Error(await r.text().catch(() => r.statusText));
if (r.status === 204) return null;
return r.json();
}
@@ -179,16 +196,12 @@ async function loadDecks() {
function renderList() {
const el = document.getElementById("deckList");
const slugs = Object.keys(deckData);
if (!slugs.length) {
el.innerHTML = '<div class="placeholder">No decks yet.</div>';
return;
}
if (!slugs.length) { el.innerHTML = '<div class="placeholder">No decks yet.</div>'; return; }
el.innerHTML = slugs.map(slug => {
const d = deckData[slug];
const active = slug === activeDeck ? " active" : "";
const pct = d.total > 0 ? Math.round(d.done / d.total * 100) : 0;
const label = d.status === "running" ? `${pct}%` : d.status;
return `<div class="deck-row${active}" onclick="selectDeck('${slug}')">
const label = d.status === "running" ? pct + "%" : d.status;
return `<div class="deck-row${slug === activeDeck ? " active" : ""}" onclick="selectDeck('${slug}')">
<span class="deck-name">${esc(d.deck_name)}</span>
<span class="badge badge-${d.status}">${label}</span>
</div>`;
@@ -196,65 +209,237 @@ function renderList() {
}
async function selectDeck(slug) {
if (editMode) exitEditMode(true);
if (sse) { sse.close(); sse = null; }
if (cardPollTimer) { clearInterval(cardPollTimer); cardPollTimer = null; }
activeDeck = slug;
renderList();
document.getElementById("logHeader").style.display = "flex";
const logBody = document.getElementById("logBody");
logBody.innerHTML = "";
const d = deckData[slug];
if (!d) return;
document.getElementById("deckHeader").style.display = "flex";
document.getElementById("logFooter").style.display = "";
document.getElementById("logContent").innerHTML = "";
document.getElementById("logContent").classList.remove("open");
document.getElementById("logArrow").classList.remove("open");
updateHeader(d);
document.getElementById("cardArea").innerHTML = "";
await refreshCards(slug);
if (d.status === "running" || d.status === "queued") {
startSSE(slug);
cardPollTimer = setInterval(() => refreshCards(slug), 3000);
} else {
try {
const detail = await api("GET", `/decks/${slug}`);
detail.log.forEach(line => appendLine(line));
if (d.status === "complete") {
appendLine("Ready to download:", "done");
appendLine(" Full Save → Saves/ (load as new game)", "done");
appendLine(" Saved Object → Saves/Saved Objects/ (spawn into running game via Objects → Saved Objects)", "done");
}
} catch (e) {
appendLine("Failed to load log: " + e.message, "fail");
}
try { const det = await api("GET", `/decks/${slug}`); renderLog(det.log); } catch {}
}
}
function updateHeader(d) {
document.getElementById("logTitle").textContent = d.deck_name;
const pct = d.total > 0 ? (d.done / d.total * 100) : (d.status === "complete" ? 100 : 0);
document.getElementById("headerTitle").textContent = d.deck_name;
const pct = d.total > 0 ? d.done / d.total * 100 : (d.status === "complete" ? 100 : 0);
document.getElementById("progressFill").style.width = pct + "%";
document.getElementById("dlBtn").style.display = d.status === "complete" ? "" : "none";
document.getElementById("dlObjBtn").style.display = d.status === "complete" ? "" : "none";
document.getElementById("delBtn").style.display = (d.status === "complete" || d.status === "failed") ? "" : "none";
document.getElementById("headerMeta").textContent = d.total > 0 ? `${d.done}/${d.total}` : "";
document.getElementById("priceTag").textContent = d.price_usd > 0 ? `$${d.price_usd.toFixed(2)}` : "";
const complete = d.status === "complete";
const terminal = complete || d.status === "failed";
document.getElementById("dlBtn").style.display = complete && !editMode ? "" : "none";
document.getElementById("delBtn").style.display = terminal && !editMode ? "" : "none";
document.getElementById("editBtn").style.display = terminal && !editMode ? "" : "none";
document.getElementById("saveBtn").style.display = editMode ? "" : "none";
document.getElementById("cancelBtn").style.display = editMode ? "" : "none";
}
async function refreshCards(slug) {
if (activeDeck !== slug) return;
try {
const cards = await api("GET", `/decks/${slug}/cards`);
currentCards = cards;
renderCards(slug, cards);
} catch {}
}
function renderCards(slug, cards) {
const area = document.getElementById("cardArea");
if (!cards.length) { area.innerHTML = '<div class="placeholder">No cards yet.</div>'; return; }
let grid = area.querySelector(".card-grid");
if (!grid) {
area.innerHTML = "";
grid = document.createElement("div");
grid.className = "card-grid" + (editMode ? " edit-mode" : "");
area.appendChild(grid);
} else {
grid.className = "card-grid" + (editMode ? " edit-mode" : "");
}
const commander = editCommander || (deckData[slug] && deckData[slug].commander) || "";
// Sync tiles
const existing = Array.from(grid.querySelectorAll(".card-tile, .card-tile.missing"));
cards.forEach((card, i) => {
let tile = existing[i];
const isCommander = card.name === commander;
const isRemoved = editRemovals.has(card.id);
const tileKey = card.filename + "|" + card.exists + "|" + isCommander + "|" + isRemoved + "|" + editMode;
if (!tile) { tile = document.createElement("div"); grid.appendChild(tile); tile.dataset.key = ""; }
if (tile.dataset.key === tileKey) return;
tile.dataset.key = tileKey;
if (card.exists) {
const imgUrl = `/cards/${slug}/${card.filename}`;
tile.className = "card-tile" + (isCommander ? " is-commander" : "") + (isRemoved ? " marked-remove" : "");
tile.innerHTML = `
<img src="${imgUrl}" loading="lazy" alt="${esc(card.name)}" onclick="showLightbox('${imgUrl}')">
<span class="card-label">${isCommander ? "♛ " : ""}${esc(card.name)}</span>
${isCommander ? `<span class="commander-crown">♛</span>` : ""}
<button class="card-overlay-btn btn-remove-card" onclick="toggleRemove(${card.id})">✕</button>
${!isCommander ? `<button class="card-overlay-btn btn-set-commander" onclick="setCommander('${esc(card.name)}')">♛</button>` : ""}
`;
} else {
tile.className = "card-tile missing" + (isRemoved ? " marked-remove" : "");
tile.innerHTML = `
${esc(card.name)}
<button class="card-overlay-btn btn-remove-card" onclick="toggleRemove(${card.id})">✕</button>
`;
}
});
while (grid.children.length > cards.length) grid.removeChild(grid.lastChild);
// Edit add section
let addSection = area.querySelector(".edit-add-section");
if (editMode) {
if (!addSection) {
addSection = document.createElement("div");
addSection.className = "edit-add-section";
addSection.innerHTML = `
<div class="section-label">Search to Add</div>
<div class="search-box">
<input id="searchInput" placeholder="Card name..." oninput="onSearchInput(this.value)" autocomplete="off" />
<div class="search-results" id="searchResults"></div>
</div>
<div class="section-label" style="margin-top:12px">Bulk Add (one per line)</div>
<textarea id="bulkAddArea" placeholder="Paste card names..."></textarea>
<div class="row"><button class="btn btn-green" onclick="saveEdit()" style="margin-top:4px">Save Changes</button></div>
`;
area.appendChild(addSection);
}
} else if (addSection) {
addSection.remove();
}
}
function toggleRemove(cardId) {
if (!editMode) return;
if (editRemovals.has(cardId)) {
editRemovals.delete(cardId);
} else {
editRemovals.add(cardId);
}
renderCards(activeDeck, currentCards);
}
function setCommander(name) {
if (!editMode) return;
editCommander = name;
renderCards(activeDeck, currentCards);
}
function enterEditMode() {
editMode = true;
editRemovals = new Set();
editCommander = null;
renderCards(activeDeck, currentCards);
if (activeDeck && deckData[activeDeck]) updateHeader(deckData[activeDeck]);
}
function exitEditMode(cancel = false) {
editMode = false;
editRemovals = new Set();
editCommander = null;
searchTimer && clearTimeout(searchTimer);
renderCards(activeDeck, currentCards);
if (activeDeck && deckData[activeDeck]) updateHeader(deckData[activeDeck]);
}
async function saveEdit() {
if (!activeDeck || !editMode) return;
const bulk = document.getElementById("bulkAddArea");
const addNames = bulk ? bulk.value.split("\n").map(s => s.trim()).filter(Boolean) : [];
const deck = deckData[activeDeck];
const body = {
deck_name: deck.deck_name,
commander: editCommander || deck.commander,
remove: [...editRemovals],
add: addNames,
};
try {
document.getElementById("saveBtn").disabled = true;
await api("PATCH", `/decks/${activeDeck}`, body);
exitEditMode(false);
await loadDecks();
await selectDeck(activeDeck);
} catch (e) {
alert(e.message);
} finally {
document.getElementById("saveBtn").disabled = false;
}
}
function onSearchInput(val) {
clearTimeout(searchTimer);
const res = document.getElementById("searchResults");
if (!res) return;
if (!val || val.length < 2) { res.classList.remove("open"); res.innerHTML = ""; return; }
searchTimer = setTimeout(() => doSearch(val), 350);
}
async function doSearch(q) {
const res = document.getElementById("searchResults");
if (!res) return;
try {
const results = await api("GET", `/search?q=${encodeURIComponent(q)}`);
if (!results.length) { res.innerHTML = `<div class="search-result" style="color:var(--muted)">No results</div>`; res.classList.add("open"); return; }
res.innerHTML = results.map(r => `
<div class="search-result">
${r.image ? `<img src="${esc(r.image)}" loading="lazy">` : `<div style="width:32px;height:45px;background:#222;border-radius:2px"></div>`}
<div class="search-result-info">
<div class="search-result-name">${esc(r.name)}</div>
<div class="search-result-meta">${esc(r.set_name)}${r.price ? ` · $${r.price}` : ""}</div>
</div>
<button class="search-result-add" onclick="addFromSearch('${esc(r.name).replace(/'/g,"\\'")}')">+ Add</button>
</div>
`).join("");
res.classList.add("open");
} catch {}
}
function addFromSearch(name) {
const bulk = document.getElementById("bulkAddArea");
if (!bulk) return;
bulk.value = (bulk.value ? bulk.value.trimEnd() + "\n" : "") + name;
const res = document.getElementById("searchResults");
if (res) { res.classList.remove("open"); res.innerHTML = ""; }
const inp = document.getElementById("searchInput");
if (inp) inp.value = "";
}
function startSSE(slug) {
const es = new EventSource(`/decks/${slug}/stream`);
sse = es;
es.onmessage = async (e) => {
if (e.data === "__DONE__") {
es.close(); sse = null;
if (cardPollTimer) { clearInterval(cardPollTimer); cardPollTimer = null; }
await loadDecks();
const d = deckData[slug];
if (activeDeck === slug && d) {
updateHeader(d);
if (d.status === "complete") {
appendLine("Ready to download:", "done");
appendLine(" Full Save → Saves/ (load as new game)", "done");
appendLine(" Saved Object → Saves/Saved Objects/ (spawn into running game via Objects → Saved Objects)", "done");
}
if (activeDeck === slug) {
await refreshCards(slug);
try { const det = await api("GET", `/decks/${slug}`); renderLog(det.log); } catch {}
if (deckData[slug]) updateHeader(deckData[slug]);
}
return;
}
if (activeDeck === slug) appendLine(e.data);
// Refresh progress without a full reload
if (!e.data.startsWith(" ")) appendLogLine(e.data);
try {
const list = await api("GET", "/decks");
list.forEach(d => { deckData[d.slug] = d; });
@@ -262,85 +447,102 @@ function startSSE(slug) {
renderList();
} catch {}
};
es.onerror = () => { es.close(); sse = null; };
}
function appendLine(line, cls = null) {
const el = document.getElementById("logBody");
const div = document.createElement("div");
div.className = "log-line " + (cls ?? (
line.includes("— OK") ? "ok" :
line.includes("— FAIL") ? "fail" :
line.includes("— COPY") ? "copy" :
line.includes("— SKIP") ? "skip" :
line.includes("Complete.") ? "done" : ""
));
div.textContent = line;
el.appendChild(div);
el.scrollTop = el.scrollHeight;
function lineClass(l) {
return l.includes("— OK") ? "ok" : l.includes("— FAIL") ? "fail" : l.includes("— COPY") ? "copy" : l.includes("— SKIP") ? "skip" : "";
}
function renderLog(lines) {
const el = document.getElementById("logContent");
el.innerHTML = lines.map(l => `<div class="ll ${lineClass(l)}">${esc(l)}</div>`).join("");
updateLogLabel(lines);
}
function appendLogLine(line) {
const el = document.getElementById("logContent");
const div = document.createElement("div");
div.className = "ll " + lineClass(line);
div.textContent = line;
el.appendChild(div);
if (el.classList.contains("open")) el.scrollTop = el.scrollHeight;
updateLogLabel(Array.from(el.querySelectorAll(".ll")).map(d => d.textContent));
}
function updateLogLabel(lines) {
const fails = lines.filter(l => l.includes("— FAIL")).length;
document.getElementById("logLabel").innerHTML = fails > 0
? `Log · <span class="log-fail">${fails} failed</span>` : "Log";
}
function toggleLog() {
const c = document.getElementById("logContent");
const a = document.getElementById("logArrow");
const open = c.classList.toggle("open");
a.classList.toggle("open", open);
if (open) c.scrollTop = c.scrollHeight;
}
function showLightbox(url) {
document.getElementById("lightboxImg").src = url;
document.getElementById("lightbox").classList.add("open");
}
function closeLightbox() { document.getElementById("lightbox").classList.remove("open"); }
document.addEventListener("keydown", e => { if (e.key === "Escape") closeLightbox(); });
async function submitDeck() {
const name = document.getElementById("deckName").value.trim();
const name = document.getElementById("deckName").value.trim();
const cards = document.getElementById("cardList").value.trim();
const err = document.getElementById("submitError");
const commander = document.getElementById("deckCommander").value.trim();
const err = document.getElementById("submitError");
err.style.display = "none";
if (!name || !cards) {
err.textContent = "Deck name and card list are both required.";
err.style.display = "";
return;
}
if (!name || !cards) { err.textContent = "Need a name and card list."; err.style.display = ""; return; }
const btn = document.getElementById("submitBtn");
btn.disabled = true;
try {
const r = await api("POST", "/decks", { deck_name: name, cards });
const r = await api("POST", "/decks", { deck_name: name, cards, commander });
clearForm();
await loadDecks();
selectDeck(r.slug);
} catch (e) {
err.textContent = e.message;
err.style.display = "";
} finally {
btn.disabled = false;
}
} finally { btn.disabled = false; }
}
function clearForm() {
document.getElementById("deckName").value = "";
document.getElementById("cardList").value = "";
["deckName","deckCommander","cardList"].forEach(id => document.getElementById(id).value = "");
document.getElementById("submitError").style.display = "none";
}
// Auto-fill commander from first card line
document.getElementById("cardList").addEventListener("input", function() {
const cmdField = document.getElementById("deckCommander");
if (cmdField.value) return;
const first = this.value.split("\n").map(s => s.trim()).find(Boolean);
if (first) cmdField.placeholder = `Commander (e.g. ${first})`;
});
async function deleteDeck() {
if (!activeDeck) return;
const d = deckData[activeDeck];
if (!confirm(`Delete "${d?.deck_name}"?\nThis removes all downloaded card images.`)) return;
if (!confirm(`Delete "${deckData[activeDeck]?.deck_name}"? This removes all downloaded images.`)) return;
try {
await api("DELETE", `/decks/${activeDeck}`);
if (sse) { sse.close(); sse = null; }
if (cardPollTimer) { clearInterval(cardPollTimer); cardPollTimer = null; }
activeDeck = null;
document.getElementById("logHeader").style.display = "none";
document.getElementById("logBody").innerHTML = '<div class="placeholder">Deck deleted.</div>';
["deckHeader","logFooter"].forEach(id => document.getElementById(id).style.display = "none");
document.getElementById("progressFill").style.width = "0";
document.getElementById("cardArea").innerHTML = '<div class="placeholder">Deck deleted.</div>';
await loadDecks();
} catch (e) {
alert(e.message);
}
} catch (e) { alert(e.message); }
}
function downloadTTS() {
if (activeDeck) window.location.href = `/decks/${activeDeck}/tts`;
}
function downloadObject() {
if (activeDeck) window.location.href = `/decks/${activeDeck}/object`;
}
function downloadTTS() { if (activeDeck) window.location.href = `/decks/${activeDeck}/tts`; }
function esc(s) {
return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
setInterval(loadDecks, 5000);
Generated
-60
View File
@@ -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"
+209
View File
@@ -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}")