From f18c0c6564d6de9323aa975fd8b0d0fcce162a42 Mon Sep 17 00:00:00 2001 From: robviren Date: Fri, 10 Jul 2026 20:54:39 -0500 Subject: [PATCH] Moxfield import --- Dockerfile | 2 +- db.py | 24 ++++++--- moxfield.py | 112 ++++++++++++++++++++++++++++++++++++++ routes/cards.py | 3 ++ routes/decks.py | 135 ++++++++++++++++++++++++++++++++++++++++++++++ static/index.html | 87 ++++++++++++++++++++++++------ worker.py | 114 ++++++++++++++++++++++++++------------- 7 files changed, 416 insertions(+), 61 deletions(-) create mode 100644 moxfield.py diff --git a/Dockerfile b/Dockerfile index df20b67..b20b89e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ RUN pip install --no-cache-dir \ pillow \ python-multipart -COPY main.py worker.py db.py config.py ./ +COPY main.py worker.py db.py config.py moxfield.py ./ COPY routes/ routes/ COPY static/ static/ diff --git a/db.py b/db.py index 1018cdb..13a70eb 100644 --- a/db.py +++ b/db.py @@ -23,13 +23,16 @@ def init(): 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 + 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, + source TEXT NOT NULL DEFAULT 'manual', + moxfield_id TEXT, + include_tokens INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS cards ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -39,6 +42,8 @@ def init(): filename TEXT, back_filename TEXT, scry_url TEXT, + scry_id TEXT, + is_token INTEGER DEFAULT 0, price_usd REAL DEFAULT 0, fetch_status TEXT DEFAULT 'pending' ); @@ -50,6 +55,11 @@ def init(): """) _add_column(c, "cards", "back_filename", "TEXT") _add_column(c, "cards", "scry_url", "TEXT") + _add_column(c, "cards", "scry_id", "TEXT") + _add_column(c, "cards", "is_token", "INTEGER DEFAULT 0") + _add_column(c, "decks", "source", "TEXT NOT NULL DEFAULT 'manual'") + _add_column(c, "decks", "moxfield_id", "TEXT") + _add_column(c, "decks", "include_tokens", "INTEGER DEFAULT 0") def get_deck(slug: str): diff --git a/moxfield.py b/moxfield.py new file mode 100644 index 0000000..e92b761 --- /dev/null +++ b/moxfield.py @@ -0,0 +1,112 @@ +import asyncio +import re +from urllib.parse import urlparse + +import httpx + +API_BASE = "https://api2.moxfield.com/v3/decks/all" + +# A plain "python-httpx/…" UA gets blocked by Moxfield's edge; a normal +# browser UA does not. There's no server-rendered deck data in the page +# HTML to fall back to (it's a client-rendered SPA), so this API call is +# the only viable path — verified by hand against api2.moxfield.com. +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" + ), + "Accept": "application/json", +} + +# Boards that represent physical cards belonging to the deck. Sideboard and +# maybeboard are staging areas, not part of the actual decklist. +DECK_BOARDS = [ + "mainboard", "commanders", "companions", "signatureSpells", + "attractions", "contraptions", "planes", "schemes", "stickers", +] + +_ID_RE = re.compile(r"^[A-Za-z0-9_-]{6,}$") + + +def parse_deck_id(text: str) -> str: + text = text.strip() + if not text: + raise ValueError("empty Moxfield URL") + if "moxfield.com" not in text.lower() and _ID_RE.match(text): + return text + parts = [p for p in urlparse(text).path.split("/") if p] + if len(parts) >= 2 and parts[0] == "decks": + return parts[1] + raise ValueError("could not parse a deck id from that Moxfield URL") + + +async def fetch_deck(client: httpx.AsyncClient, deck_id: str) -> dict: + url = f"{API_BASE}/{deck_id}" + r = None + for attempt in range(4): + r = await client.get(url, headers=HEADERS) + if r.status_code == 200: + return r.json() + if r.status_code == 404: + raise ValueError("deck not found on Moxfield (private, deleted, or wrong id?)") + if r.status_code == 403: + raise ValueError("Moxfield blocked the request (403) — try again in a bit") + if r.status_code in (429, 500, 502, 503, 504): + wait = float(r.headers.get("Retry-After", 2 ** attempt)) + await asyncio.sleep(wait) + continue + r.raise_for_status() + r.raise_for_status() + raise ValueError("failed to fetch deck from Moxfield") + + +def _is_token(card: dict) -> bool: + return card.get("layout") == "token" or (card.get("type_line") or "").startswith("Token") + + +def extract(deck_json: dict, include_tokens: bool) -> dict: + """Flatten a Moxfield deck payload into (name, commanders, entries). + + Each entry carries a scryfall_id when Moxfield has one on file, which lets + the fetch pipeline pull the exact printing (and both faces of a DFC/MDFC) + straight from Scryfall's /cards/{id} instead of a fuzzy name search. + """ + boards = deck_json.get("boards", {}) + commanders: list[str] = [] + entries: list[dict] = [] + + for board_name in DECK_BOARDS: + board = boards.get(board_name) or {} + for row in board.get("cards", {}).values(): + card = row.get("card") or {} + name = card.get("name") or "" + scry_id = card.get("scryfall_id") + qty = max(1, int(row.get("quantity") or 1)) + if board_name == "commanders" and name: + commanders.append(name) + for _ in range(qty): + entries.append({"name": name, "scry_id": scry_id, "is_token": False}) + + if include_tokens: + seen_ids = {e["scry_id"] for e in entries if e["scry_id"]} + token_cards: dict[str, dict] = {} + + for row in (boards.get("tokens") or {}).get("cards", {}).values(): + c = row.get("card") or {} + if c.get("scryfall_id"): + token_cards[c["scryfall_id"]] = c + + for c in (deck_json.get("tokenMappings") or {}).values(): + if c.get("scryfall_id") and _is_token(c): + token_cards[c["scryfall_id"]] = c + + for scry_id, c in token_cards.items(): + if scry_id in seen_ids: + continue + entries.append({"name": c.get("name") or "", "scry_id": scry_id, "is_token": True}) + + return { + "name": deck_json.get("name") or "Moxfield Deck", + "commanders": commanders, + "entries": entries, + } diff --git a/routes/cards.py b/routes/cards.py index bf63c80..2e3ff1e 100644 --- a/routes/cards.py +++ b/routes/cards.py @@ -39,6 +39,7 @@ async def deck_cards(slug: str): "back_filename": c["back_filename"], "back_exists": bool(c["back_filename"] and (OUTPUTS / slug / c["back_filename"]).exists()), "scry_url": c["scry_url"], + "is_token": bool(c["is_token"]), "fetch_status": c["fetch_status"], "price_usd": c["price_usd"], } @@ -52,6 +53,8 @@ async def upload_image(slug: str, name: str = Form(...), file: UploadFile = File deck = db.get_deck(slug) if not deck: raise HTTPException(404) + if deck["source"] == "moxfield": + raise HTTPException(409, "Moxfield-linked decks are updated by syncing, not manual edits") name = name.strip() if not name: raise HTTPException(400, "name required") diff --git a/routes/decks.py b/routes/decks.py index a290531..cd35884 100644 --- a/routes/decks.py +++ b/routes/decks.py @@ -1,9 +1,11 @@ import re import shutil +import httpx from fastapi import APIRouter, HTTPException, Request import db +import moxfield import worker from config import OUTPUTS from worker import is_scry_url @@ -42,6 +44,135 @@ async def get_deck(slug: str): return {**dict(d), "log": db.get_logs(slug)} +@router.post("/decks/moxfield", status_code=201) +async def create_deck_from_moxfield(request: Request): + body = await request.json() + url = (body.get("url") or "").strip() + include_tokens = bool(body.get("include_tokens")) + if not url: + raise HTTPException(400, "Moxfield URL required") + + try: + deck_id = moxfield.parse_deck_id(url) + except ValueError as e: + raise HTTPException(400, str(e)) + + async with httpx.AsyncClient(timeout=20) as client: + try: + data = await moxfield.fetch_deck(client, deck_id) + except ValueError as e: + raise HTTPException(502, str(e)) + + parsed = moxfield.extract(data, include_tokens) + if not parsed["entries"]: + raise HTTPException(400, "Moxfield deck has no cards") + + name = parsed["name"] + slug = slugify(name) + existing = db.get_deck(slug) + if existing and existing["status"] in ("queued", "running"): + raise HTTPException(409, "deck already processing") + if existing and existing["source"] != "moxfield": + raise HTTPException(409, "a manually-created deck with this name already exists — rename or delete it first") + + commander_field = "\n".join(parsed["commanders"]) or None + + with db.conn() as c: + c.execute( + "INSERT OR REPLACE INTO decks " + "(slug, deck_name, status, commander, price_usd, done, total, source, moxfield_id, include_tokens) " + "VALUES (?,?,?,?,0,0,?,?,?,?)", + (slug, name, "queued", commander_field, len(parsed["entries"]), "moxfield", deck_id, int(include_tokens)) + ) + c.execute("DELETE FROM cards WHERE deck_slug=?", (slug,)) + c.execute("DELETE FROM logs WHERE deck_slug=?", (slug,)) + for i, e in enumerate(parsed["entries"]): + c.execute( + "INSERT INTO cards (deck_slug, name, position, scry_id, is_token, fetch_status) VALUES (?,?,?,?,?,'pending')", + (slug, e["name"], i + 1, e["scry_id"], int(e["is_token"])) + ) + c.execute("INSERT INTO logs (deck_slug, line) VALUES (?,?)", (slug, f"Imported from Moxfield ({deck_id}).")) + + await worker.queue.put(slug) + return {"slug": slug} + + +@router.post("/decks/{slug}/moxfield-sync") +async def sync_moxfield_deck(slug: str): + slug = valid_slug(slug) + deck = db.get_deck(slug) + if not deck: + raise HTTPException(404) + if deck["source"] != "moxfield" or not deck["moxfield_id"]: + raise HTTPException(400, "not a Moxfield-linked deck") + if deck["status"] in ("queued", "running"): + raise HTTPException(409, "deck is processing") + + async with httpx.AsyncClient(timeout=20) as client: + try: + data = await moxfield.fetch_deck(client, deck["moxfield_id"]) + except ValueError as e: + raise HTTPException(502, str(e)) + + parsed = moxfield.extract(data, bool(deck["include_tokens"])) + if not parsed["entries"]: + raise HTTPException(400, "Moxfield deck has no cards") + + commander_field = "\n".join(parsed["commanders"]) or None + + with db.conn() as c: + existing_rows = c.execute( + "SELECT id, scry_id, filename, back_filename FROM cards WHERE deck_slug=?", (slug,) + ).fetchall() + + by_scry: dict[str, list] = {} + remove_ids = [row["id"] for row in existing_rows if not row["scry_id"]] + for row in existing_rows: + if row["scry_id"]: + by_scry.setdefault(row["scry_id"], []).append(row) + + to_insert = [] + for e in parsed["entries"]: + pool = by_scry.get(e["scry_id"]) if e["scry_id"] else None + if pool: + pool.pop(0) + else: + to_insert.append(e) + + remove_ids += [row["id"] for rows in by_scry.values() for row in rows] + + for rid in remove_ids: + row = c.execute("SELECT filename, back_filename FROM cards WHERE id=?", (rid,)).fetchone() + if row: + for fn in (row["filename"], row["back_filename"]): + if fn and (OUTPUTS / slug / fn).exists(): + (OUTPUTS / slug / fn).unlink() + c.execute("DELETE FROM cards WHERE id=?", (rid,)) + + max_pos = c.execute( + "SELECT COALESCE(MAX(position), 0) FROM cards WHERE deck_slug=?", (slug,) + ).fetchone()[0] + for i, e in enumerate(to_insert): + c.execute( + "INSERT INTO cards (deck_slug, name, position, scry_id, is_token, fetch_status) VALUES (?,?,?,?,?,'pending')", + (slug, e["name"], max_pos + i + 1, e["scry_id"], int(e["is_token"])) + ) + + c.execute( + "UPDATE decks SET deck_name=?, commander=?, status='queued' WHERE slug=?", + (parsed["name"], commander_field, slug) + ) + added, removed = len(to_insert), len(remove_ids) + c.execute( + "INSERT INTO logs (deck_slug, line) VALUES (?,?)", + (slug, f"Synced from Moxfield — +{added} / -{removed}.") + ) + + db.recalc_done(slug) + await worker.queue.put(slug) + return {"slug": slug, "added": added, "removed": removed} + + @router.post("/decks", status_code=201) async def create_deck(request: Request): body = await request.json() @@ -73,6 +204,8 @@ async def create_deck(request: Request): existing = db.get_deck(slug) if existing and existing["status"] in ("queued", "running"): raise HTTPException(409, "deck already processing") + if existing and existing["source"] == "moxfield": + raise HTTPException(409, "a Moxfield-linked deck with this name already exists — sync or delete it first") with db.conn() as c: c.execute( @@ -97,6 +230,8 @@ async def edit_deck(slug: str, request: Request): deck = db.get_deck(slug) if not deck: raise HTTPException(404) + if deck["source"] == "moxfield": + raise HTTPException(409, "Moxfield-linked decks are updated by syncing, not manual edits") if deck["status"] in ("queued", "running"): raise HTTPException(409, "deck is processing") diff --git a/static/index.html b/static/index.html index 822ba06..72e0ec7 100644 --- a/static/index.html +++ b/static/index.html @@ -44,6 +44,10 @@ textarea { resize: vertical; } .badge-running { color: var(--yellow); background: #1e1a0e; } .badge-queued { color: var(--blue); background: #101622; } .badge-failed { color: var(--red); background: #1e1010; } +.badge-source { color: var(--blue); background: #101622; border: 1px solid #1e2e3a; } +.mode-toggle .btn.active { color: var(--green); border-color: #1e3a1e; background: #141e14; } +.token-badge { position: absolute; top: 3px; right: 3px; font-size: 9px; letter-spacing: 0.5px; color: var(--blue); background: rgba(0,0,0,0.78); padding: 2px 5px; border-radius: 2px; pointer-events: none; } +.edit-mode .token-badge { display: none; } /* Main panel */ .panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; } @@ -131,10 +135,20 @@ textarea { resize: vertical; }