Able to be edited, other cleanup
This commit is contained in:
+2
-1
@@ -9,4 +9,5 @@ wheels/
|
|||||||
# Virtual environments
|
# Virtual environments
|
||||||
.venv
|
.venv
|
||||||
outputs/
|
outputs/
|
||||||
builds/
|
builds/
|
||||||
|
data/
|
||||||
+4
-3
@@ -11,11 +11,12 @@ RUN pip install --no-cache-dir \
|
|||||||
httpx \
|
httpx \
|
||||||
pyyaml
|
pyyaml
|
||||||
|
|
||||||
COPY server.py .
|
COPY main.py worker.py db.py config.py ./
|
||||||
|
COPY routes/ routes/
|
||||||
COPY static/ static/
|
COPY static/ static/
|
||||||
|
|
||||||
VOLUME /app/outputs
|
RUN mkdir -p /app/data /app/outputs
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["python", "server.py"]
|
CMD ["python", "main.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"
|
||||||
@@ -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))
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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)
|
|
||||||
+395
-193
@@ -2,52 +2,26 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Card Server</title>
|
<title>Card Server</title>
|
||||||
<style>
|
<style>
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
:root {
|
:root {
|
||||||
--bg: #0f0f0f;
|
--bg: #0f0f0f; --border: #252525; --text: #d8d8d8; --muted: #555;
|
||||||
--surface: #161616;
|
--green: #4caf50; --red: #e57373; --yellow: #ffd54f; --blue: #64b5f6; --gold: #ffc107;
|
||||||
--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;
|
|
||||||
}
|
}
|
||||||
|
body { background: var(--bg); color: var(--text); font-family: 'Courier New', monospace; font-size: 13px; display: flex; height: 100vh; overflow: hidden; }
|
||||||
|
|
||||||
/* ── Sidebar ── */
|
/* Sidebar */
|
||||||
.sidebar {
|
.sidebar { width: 280px; min-width: 280px; border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
|
||||||
width: 310px; min-width: 310px;
|
.section { padding: 12px; border-bottom: 1px solid var(--border); }
|
||||||
border-right: 1px solid var(--border);
|
.section-label { font-size: 10px; text-transform: uppercase; letter-spacing: 1.5px; color: var(--muted); margin-bottom: 8px; }
|
||||||
display: flex; flex-direction: column; overflow: hidden;
|
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; }
|
||||||
.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; }
|
|
||||||
input:focus, textarea:focus { border-color: #3a3a3a; }
|
input:focus, textarea:focus { border-color: #3a3a3a; }
|
||||||
textarea { min-height: 150px; resize: vertical; }
|
textarea { resize: vertical; }
|
||||||
|
#cardList { min-height: 120px; }
|
||||||
.btn {
|
.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; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
.btn:hover { background: #222; border-color: #3a3a3a; }
|
.btn:hover { background: #222; border-color: #3a3a3a; }
|
||||||
.btn:disabled { opacity: 0.4; cursor: default; }
|
.btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
.btn-green { color: var(--green); border-color: #1e3a1e; }
|
.btn-green { color: var(--green); border-color: #1e3a1e; }
|
||||||
@@ -56,53 +30,94 @@ textarea { min-height: 150px; resize: vertical; }
|
|||||||
.btn-red:hover { background: #1e1414; }
|
.btn-red:hover { background: #1e1414; }
|
||||||
.btn-blue { color: var(--blue); border-color: #1e2e3a; }
|
.btn-blue { color: var(--blue); border-color: #1e2e3a; }
|
||||||
.btn-blue:hover { background: #141c22; }
|
.btn-blue:hover { background: #141c22; }
|
||||||
|
.btn-gold { color: var(--gold); border-color: #3a2e00; }
|
||||||
.row { display: flex; gap: 6px; margin-top: 8px; }
|
.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-list { flex: 1; overflow-y: auto; }
|
||||||
.deck-row {
|
.deck-row { padding: 9px 12px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 8px; }
|
||||||
padding: 10px 14px; border-bottom: 1px solid var(--border);
|
|
||||||
cursor: pointer; display: flex; align-items: center; gap: 8px;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
.deck-row:hover { background: #181818; }
|
.deck-row:hover { background: #181818; }
|
||||||
.deck-row.active { background: #1a1a24; border-left: 2px solid #3a3a6a; }
|
.deck-row.active { background: #1a1a24; border-left: 2px solid #3a3a6a; }
|
||||||
.deck-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.deck-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 12px; }
|
||||||
.badge {
|
.badge { font-size: 10px; padding: 2px 5px; border-radius: 2px; white-space: nowrap; }
|
||||||
font-size: 10px; padding: 2px 6px; border-radius: 2px; white-space: nowrap;
|
|
||||||
}
|
|
||||||
.badge-complete { color: var(--green); background: #141e14; }
|
.badge-complete { color: var(--green); background: #141e14; }
|
||||||
.badge-running { color: var(--yellow); background: #1e1a0e; }
|
.badge-running { color: var(--yellow); background: #1e1a0e; }
|
||||||
.badge-queued { color: var(--blue); background: #101622; }
|
.badge-queued { color: var(--blue); background: #101622; }
|
||||||
.badge-failed { color: var(--red); background: #1e1010; }
|
.badge-failed { color: var(--red); background: #1e1010; }
|
||||||
|
|
||||||
/* ── Main panel ── */
|
|
||||||
.panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
|
||||||
|
|
||||||
|
/* 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-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 {
|
/* Card area */
|
||||||
padding: 11px 16px; border-bottom: 1px solid var(--border);
|
.card-area { flex: 1; overflow-y: auto; padding: 12px 14px; }
|
||||||
display: flex; align-items: center; gap: 8px; flex-shrink: 0;
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -111,57 +126,59 @@ textarea { min-height: 150px; resize: vertical; }
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-label">New Deck</div>
|
<div class="section-label">New Deck</div>
|
||||||
<input id="deckName" placeholder="Deck name" />
|
<input id="deckName" placeholder="Deck name" />
|
||||||
<textarea id="cardList" placeholder="Paste card list — one per line 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">
|
<div class="row">
|
||||||
<button class="btn btn-green" id="submitBtn" onclick="submitDeck()" style="flex:1">Submit</button>
|
<button class="btn btn-green" id="submitBtn" onclick="submitDeck()" style="flex:1">Submit</button>
|
||||||
<button class="btn" onclick="clearForm()">Clear</button>
|
<button class="btn" onclick="clearForm()">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="submitError" class="error-text" style="display:none"></div>
|
<div id="submitError" class="error-text" style="display:none"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section" style="padding-bottom:8px; border-bottom:none;">
|
<div class="section" style="padding-bottom:8px; border-bottom:none;">
|
||||||
<div class="section-label">Decks</div>
|
<div class="section-label">Decks</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="deck-list" id="deckList">
|
<div class="deck-list" id="deckList"><div class="placeholder">No decks yet.</div></div>
|
||||||
<div class="placeholder">No decks yet.</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">↓ 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">►</span>
|
||||||
|
<span id="logLabel">Log</span>
|
||||||
|
</div>
|
||||||
|
<div class="log-content" id="logContent"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
|
||||||
<div class="progress-bar"><div class="progress-fill" id="progressFill" style="width:0"></div></div>
|
<img id="lightboxImg" />
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let activeDeck = null;
|
let activeDeck = null, sse = null, cardPollTimer = null;
|
||||||
let sse = null;
|
let deckData = {}, currentCards = [];
|
||||||
let deckData = {};
|
let editMode = false, editRemovals = new Set(), editCommander = null;
|
||||||
|
let searchTimer = null;
|
||||||
|
|
||||||
async function api(method, path, body) {
|
async function api(method, path, body) {
|
||||||
const r = await fetch(path, {
|
const r = await fetch(path, {
|
||||||
method,
|
method,
|
||||||
headers: body ? { "Content-Type": "application/json" } : {},
|
headers: body ? {"Content-Type":"application/json"} : {},
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined
|
||||||
});
|
});
|
||||||
if (!r.ok) {
|
if (!r.ok) throw new Error(await r.text().catch(() => r.statusText));
|
||||||
const t = await r.text().catch(() => r.statusText);
|
|
||||||
throw new Error(t);
|
|
||||||
}
|
|
||||||
if (r.status === 204) return null;
|
if (r.status === 204) return null;
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
@@ -179,16 +196,12 @@ async function loadDecks() {
|
|||||||
function renderList() {
|
function renderList() {
|
||||||
const el = document.getElementById("deckList");
|
const el = document.getElementById("deckList");
|
||||||
const slugs = Object.keys(deckData);
|
const slugs = Object.keys(deckData);
|
||||||
if (!slugs.length) {
|
if (!slugs.length) { el.innerHTML = '<div class="placeholder">No decks yet.</div>'; return; }
|
||||||
el.innerHTML = '<div class="placeholder">No decks yet.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
el.innerHTML = slugs.map(slug => {
|
el.innerHTML = slugs.map(slug => {
|
||||||
const d = deckData[slug];
|
const d = deckData[slug];
|
||||||
const active = slug === activeDeck ? " active" : "";
|
|
||||||
const pct = d.total > 0 ? Math.round(d.done / d.total * 100) : 0;
|
const pct = d.total > 0 ? Math.round(d.done / d.total * 100) : 0;
|
||||||
const label = d.status === "running" ? `${pct}%` : d.status;
|
const label = d.status === "running" ? pct + "%" : d.status;
|
||||||
return `<div class="deck-row${active}" onclick="selectDeck('${slug}')">
|
return `<div class="deck-row${slug === activeDeck ? " active" : ""}" onclick="selectDeck('${slug}')">
|
||||||
<span class="deck-name">${esc(d.deck_name)}</span>
|
<span class="deck-name">${esc(d.deck_name)}</span>
|
||||||
<span class="badge badge-${d.status}">${label}</span>
|
<span class="badge badge-${d.status}">${label}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -196,65 +209,237 @@ function renderList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function selectDeck(slug) {
|
async function selectDeck(slug) {
|
||||||
|
if (editMode) exitEditMode(true);
|
||||||
if (sse) { sse.close(); sse = null; }
|
if (sse) { sse.close(); sse = null; }
|
||||||
|
if (cardPollTimer) { clearInterval(cardPollTimer); cardPollTimer = null; }
|
||||||
activeDeck = slug;
|
activeDeck = slug;
|
||||||
renderList();
|
renderList();
|
||||||
|
|
||||||
document.getElementById("logHeader").style.display = "flex";
|
|
||||||
const logBody = document.getElementById("logBody");
|
|
||||||
logBody.innerHTML = "";
|
|
||||||
|
|
||||||
const d = deckData[slug];
|
const d = deckData[slug];
|
||||||
if (!d) return;
|
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);
|
updateHeader(d);
|
||||||
|
document.getElementById("cardArea").innerHTML = "";
|
||||||
|
await refreshCards(slug);
|
||||||
if (d.status === "running" || d.status === "queued") {
|
if (d.status === "running" || d.status === "queued") {
|
||||||
startSSE(slug);
|
startSSE(slug);
|
||||||
|
cardPollTimer = setInterval(() => refreshCards(slug), 3000);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try { const det = await api("GET", `/decks/${slug}`); renderLog(det.log); } catch {}
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateHeader(d) {
|
function updateHeader(d) {
|
||||||
document.getElementById("logTitle").textContent = d.deck_name;
|
document.getElementById("headerTitle").textContent = d.deck_name;
|
||||||
const pct = d.total > 0 ? (d.done / d.total * 100) : (d.status === "complete" ? 100 : 0);
|
const pct = d.total > 0 ? d.done / d.total * 100 : (d.status === "complete" ? 100 : 0);
|
||||||
document.getElementById("progressFill").style.width = pct + "%";
|
document.getElementById("progressFill").style.width = pct + "%";
|
||||||
document.getElementById("dlBtn").style.display = d.status === "complete" ? "" : "none";
|
document.getElementById("headerMeta").textContent = d.total > 0 ? `${d.done}/${d.total}` : "";
|
||||||
document.getElementById("dlObjBtn").style.display = d.status === "complete" ? "" : "none";
|
document.getElementById("priceTag").textContent = d.price_usd > 0 ? `$${d.price_usd.toFixed(2)}` : "";
|
||||||
document.getElementById("delBtn").style.display = (d.status === "complete" || d.status === "failed") ? "" : "none";
|
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) {
|
function startSSE(slug) {
|
||||||
const es = new EventSource(`/decks/${slug}/stream`);
|
const es = new EventSource(`/decks/${slug}/stream`);
|
||||||
sse = es;
|
sse = es;
|
||||||
|
|
||||||
es.onmessage = async (e) => {
|
es.onmessage = async (e) => {
|
||||||
if (e.data === "__DONE__") {
|
if (e.data === "__DONE__") {
|
||||||
es.close(); sse = null;
|
es.close(); sse = null;
|
||||||
|
if (cardPollTimer) { clearInterval(cardPollTimer); cardPollTimer = null; }
|
||||||
await loadDecks();
|
await loadDecks();
|
||||||
const d = deckData[slug];
|
if (activeDeck === slug) {
|
||||||
if (activeDeck === slug && d) {
|
await refreshCards(slug);
|
||||||
updateHeader(d);
|
try { const det = await api("GET", `/decks/${slug}`); renderLog(det.log); } catch {}
|
||||||
if (d.status === "complete") {
|
if (deckData[slug]) updateHeader(deckData[slug]);
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (activeDeck === slug) appendLine(e.data);
|
if (!e.data.startsWith(" ")) appendLogLine(e.data);
|
||||||
// Refresh progress without a full reload
|
|
||||||
try {
|
try {
|
||||||
const list = await api("GET", "/decks");
|
const list = await api("GET", "/decks");
|
||||||
list.forEach(d => { deckData[d.slug] = d; });
|
list.forEach(d => { deckData[d.slug] = d; });
|
||||||
@@ -262,89 +447,106 @@ function startSSE(slug) {
|
|||||||
renderList();
|
renderList();
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
es.onerror = () => { es.close(); sse = null; };
|
es.onerror = () => { es.close(); sse = null; };
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendLine(line, cls = null) {
|
function lineClass(l) {
|
||||||
const el = document.getElementById("logBody");
|
return l.includes("— OK") ? "ok" : l.includes("— FAIL") ? "fail" : l.includes("— COPY") ? "copy" : l.includes("— SKIP") ? "skip" : "";
|
||||||
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 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() {
|
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 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";
|
err.style.display = "none";
|
||||||
|
if (!name || !cards) { err.textContent = "Need a name and card list."; err.style.display = ""; return; }
|
||||||
if (!name || !cards) {
|
|
||||||
err.textContent = "Deck name and card list are both required.";
|
|
||||||
err.style.display = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const btn = document.getElementById("submitBtn");
|
const btn = document.getElementById("submitBtn");
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
const r = await api("POST", "/decks", { deck_name: name, cards });
|
const r = await api("POST", "/decks", { deck_name: name, cards, commander });
|
||||||
clearForm();
|
clearForm();
|
||||||
await loadDecks();
|
await loadDecks();
|
||||||
selectDeck(r.slug);
|
selectDeck(r.slug);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
err.textContent = e.message;
|
err.textContent = e.message;
|
||||||
err.style.display = "";
|
err.style.display = "";
|
||||||
} finally {
|
} finally { btn.disabled = false; }
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearForm() {
|
function clearForm() {
|
||||||
document.getElementById("deckName").value = "";
|
["deckName","deckCommander","cardList"].forEach(id => document.getElementById(id).value = "");
|
||||||
document.getElementById("cardList").value = "";
|
|
||||||
document.getElementById("submitError").style.display = "none";
|
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() {
|
async function deleteDeck() {
|
||||||
if (!activeDeck) return;
|
if (!activeDeck) return;
|
||||||
const d = deckData[activeDeck];
|
if (!confirm(`Delete "${deckData[activeDeck]?.deck_name}"? This removes all downloaded images.`)) return;
|
||||||
if (!confirm(`Delete "${d?.deck_name}"?\nThis removes all downloaded card images.`)) return;
|
|
||||||
try {
|
try {
|
||||||
await api("DELETE", `/decks/${activeDeck}`);
|
await api("DELETE", `/decks/${activeDeck}`);
|
||||||
if (sse) { sse.close(); sse = null; }
|
if (sse) { sse.close(); sse = null; }
|
||||||
|
if (cardPollTimer) { clearInterval(cardPollTimer); cardPollTimer = null; }
|
||||||
activeDeck = null;
|
activeDeck = null;
|
||||||
document.getElementById("logHeader").style.display = "none";
|
["deckHeader","logFooter"].forEach(id => document.getElementById(id).style.display = "none");
|
||||||
document.getElementById("logBody").innerHTML = '<div class="placeholder">Deck deleted.</div>';
|
|
||||||
document.getElementById("progressFill").style.width = "0";
|
document.getElementById("progressFill").style.width = "0";
|
||||||
|
document.getElementById("cardArea").innerHTML = '<div class="placeholder">Deck deleted.</div>';
|
||||||
await loadDecks();
|
await loadDecks();
|
||||||
} catch (e) {
|
} catch (e) { alert(e.message); }
|
||||||
alert(e.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadTTS() {
|
function downloadTTS() { if (activeDeck) window.location.href = `/decks/${activeDeck}/tts`; }
|
||||||
if (activeDeck) window.location.href = `/decks/${activeDeck}/tts`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadObject() {
|
|
||||||
if (activeDeck) window.location.href = `/decks/${activeDeck}/object`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||||
}
|
}
|
||||||
|
|
||||||
setInterval(loadDecks, 5000);
|
setInterval(loadDecks, 5000);
|
||||||
loadDecks();
|
loadDecks();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ source = { virtual = "." }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "playwright" },
|
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
@@ -48,7 +47,6 @@ dependencies = [
|
|||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "fastapi", specifier = ">=0.136.1" },
|
{ name = "fastapi", specifier = ">=0.136.1" },
|
||||||
{ name = "httpx", specifier = ">=0.28.1" },
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
{ name = "playwright", specifier = ">=1.59.0" },
|
|
||||||
{ name = "pyyaml", specifier = ">=6.0.3" },
|
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.46.0" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "h11"
|
name = "h11"
|
||||||
version = "0.16.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.13.4"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
|
|||||||
@@ -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}")
|
||||||
Reference in New Issue
Block a user