Moxfield import

This commit is contained in:
2026-07-10 20:54:39 -05:00
parent f22df0e1d4
commit f18c0c6564
7 changed files with 416 additions and 61 deletions
+1 -1
View File
@@ -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/
+17 -7
View File
@@ -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):
+112
View File
@@ -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,
}
+3
View File
@@ -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")
+135
View File
@@ -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")
+72 -15
View File
@@ -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; }
<div class="sidebar">
<div class="section">
<div class="section-label">New Deck</div>
<input id="deckName" placeholder="Deck name" />
<label class="token-toggle"><input type="checkbox" id="isTokens" onchange="onTokenToggle()" /> Token bundle (no commander)</label>
<div id="commanderWrap"><input id="deckCommander" placeholder="Commander (defaults to first card)" /></div>
<textarea id="cardList" placeholder="One per line — card name or Scryfall card URL"></textarea>
<div class="row mode-toggle" style="margin-top:0">
<button class="btn active" id="modeManualBtn" onclick="setCreateMode('manual')" style="flex:1">Manual</button>
<button class="btn" id="modeMoxBtn" onclick="setCreateMode('moxfield')" style="flex:1">Moxfield</button>
</div>
<div id="manualCreateFields" style="margin-top:7px">
<input id="deckName" placeholder="Deck name" />
<label class="token-toggle"><input type="checkbox" id="isTokens" onchange="onTokenToggle()" /> Token bundle (no commander)</label>
<div id="commanderWrap"><input id="deckCommander" placeholder="Commander (defaults to first card)" /></div>
<textarea id="cardList" placeholder="One per line — card name or Scryfall card URL"></textarea>
</div>
<div id="moxfieldCreateFields" style="display:none; margin-top:7px">
<input id="moxUrl" placeholder="Moxfield deck URL or ID" />
<label class="token-toggle"><input type="checkbox" id="moxIncludeTokens" /> Pull tokens too (for TTS)</label>
</div>
<div class="row">
<button class="btn btn-green" id="submitBtn" onclick="submitDeck()" style="flex:1">Submit</button>
<button class="btn" onclick="clearForm()">Clear</button>
@@ -151,9 +165,11 @@ textarea { resize: vertical; }
<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="badge badge-source" id="sourceBadge" style="display:none">MOX</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-gold" id="syncBtn" onclick="syncMoxfieldDeck()" style="display:none">Sync</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" id="sortBtn" onclick="toggleSort()" style="display:none">Sort: Default</button>
@@ -261,20 +277,38 @@ async function selectDeck(slug) {
function updateHeader(d) {
document.getElementById("headerTitle").textContent = d.deck_name;
document.getElementById("sourceBadge").style.display = d.source === "moxfield" ? "" : "none";
const pct = d.total > 0 ? d.done / d.total * 100 : (d.status === "complete" ? 100 : 0);
document.getElementById("progressFill").style.width = pct + "%";
document.getElementById("headerMeta").textContent = d.total > 0 ? `${d.done}/${d.total}` : "";
document.getElementById("priceTag").textContent = d.price_usd > 0 ? `$${d.price_usd.toFixed(2)}` : "";
const complete = d.status === "complete";
const terminal = complete || d.status === "failed";
const isMox = d.source === "moxfield";
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("editBtn").style.display = terminal && !editMode && !isMox ? "" : "none";
document.getElementById("syncBtn").style.display = terminal && !editMode && isMox ? "" : "none";
document.getElementById("saveBtn").style.display = editMode ? "" : "none";
document.getElementById("cancelBtn").style.display = editMode ? "" : "none";
document.getElementById("sortBtn").style.display = !editMode ? "" : "none";
}
async function syncMoxfieldDeck() {
if (!activeDeck) return;
const btn = document.getElementById("syncBtn");
btn.disabled = true;
try {
await api("POST", `/decks/${activeDeck}/moxfield-sync`);
await loadDecks();
await selectDeck(activeDeck);
} catch (e) {
alert(e.message);
} finally {
btn.disabled = false;
}
}
async function refreshCards(slug) {
if (activeDeck !== slug) return;
try {
@@ -310,7 +344,7 @@ function renderCards(slug, cards) {
const isRemoved = editRemovals.has(card.id);
const label = cardLabel(card);
const priceStr = card.price_usd > 0 ? `$${card.price_usd.toFixed(2)}` : null;
const tileKey = [card.filename, card.exists, card.back_exists, isCommander, isRemoved, editMode, card.fetch_status, label, (card.price_usd || 0)].join("|");
const tileKey = [card.filename, card.exists, card.back_exists, isCommander, isRemoved, editMode, card.fetch_status, label, (card.price_usd || 0), card.is_token].join("|");
if (!tile) { tile = document.createElement("div"); grid.appendChild(tile); tile.dataset.key = ""; }
@@ -324,6 +358,7 @@ function renderCards(slug, cards) {
<img src="${imgUrl}" loading="lazy" alt="${esc(label)}" onclick="showLightbox('${imgUrl}')">
<span class="card-label">${isCommander ? "♛ " : ""}${esc(label)}</span>
${isCommander ? `<span class="commander-crown">♛</span>` : ""}
${card.is_token ? `<span class="token-badge">TOKEN</span>` : ""}
${card.back_exists ? `<div class="dfc-badge" title="flip side" onclick="event.stopPropagation();showLightbox('/cards/${slug}/${esc(card.back_filename)}')">⇄</div>` : ""}
${priceStr ? `<span class="card-price">${priceStr}</span>` : `<span class="card-price free">$0</span>`}
<button class="card-overlay-btn btn-remove-card" onclick="toggleRemove(${card.id})">✕</button>
@@ -561,19 +596,40 @@ function showLightbox(url) {
function closeLightbox() { document.getElementById("lightbox").classList.remove("open"); }
document.addEventListener("keydown", e => { if (e.key === "Escape") closeLightbox(); });
let createMode = "manual";
function setCreateMode(mode) {
createMode = mode;
document.getElementById("manualCreateFields").style.display = mode === "manual" ? "" : "none";
document.getElementById("moxfieldCreateFields").style.display = mode === "moxfield" ? "" : "none";
document.getElementById("modeManualBtn").classList.toggle("active", mode === "manual");
document.getElementById("modeMoxBtn").classList.toggle("active", mode === "moxfield");
document.getElementById("submitError").style.display = "none";
}
async function submitDeck() {
const name = document.getElementById("deckName").value.trim();
const cards = document.getElementById("cardList").value.trim();
const isTokens = document.getElementById("isTokens").checked;
const commander = document.getElementById("deckCommander").value.trim();
const err = document.getElementById("submitError");
err.style.display = "none";
if (!name) { err.textContent = "Need a name."; err.style.display = ""; return; }
if (!isTokens && !cards) { err.textContent = "Need a card list (or check Token bundle)."; err.style.display = ""; return; }
const btn = document.getElementById("submitBtn");
btn.disabled = true;
let r;
try {
const r = await api("POST", "/decks", { deck_name: name, cards, commander, is_tokens: isTokens });
if (createMode === "moxfield") {
const url = document.getElementById("moxUrl").value.trim();
const includeTokens = document.getElementById("moxIncludeTokens").checked;
if (!url) { err.textContent = "Need a Moxfield deck URL."; err.style.display = ""; return; }
btn.disabled = true;
r = await api("POST", "/decks/moxfield", { url, include_tokens: includeTokens });
} else {
const name = document.getElementById("deckName").value.trim();
const cards = document.getElementById("cardList").value.trim();
const isTokens = document.getElementById("isTokens").checked;
const commander = document.getElementById("deckCommander").value.trim();
if (!name) { err.textContent = "Need a name."; err.style.display = ""; return; }
if (!isTokens && !cards) { err.textContent = "Need a card list (or check Token bundle)."; err.style.display = ""; return; }
btn.disabled = true;
r = await api("POST", "/decks", { deck_name: name, cards, commander, is_tokens: isTokens });
}
clearForm();
await loadDecks();
selectDeck(r.slug);
@@ -589,8 +645,9 @@ function onTokenToggle() {
}
function clearForm() {
["deckName","deckCommander","cardList"].forEach(id => document.getElementById(id).value = "");
["deckName","deckCommander","cardList","moxUrl"].forEach(id => document.getElementById(id).value = "");
document.getElementById("isTokens").checked = false;
document.getElementById("moxIncludeTokens").checked = false;
onTokenToggle();
document.getElementById("submitError").style.display = "none";
}
+76 -38
View File
@@ -47,7 +47,15 @@ async def _fetch_bytes(client: httpx.AsyncClient, url: str, slug: str) -> bytes:
return r.content
async def resolve_card(client: httpx.AsyncClient, slug: str, name: str, scry_url: str | None) -> dict:
async def resolve_card(
client: httpx.AsyncClient, slug: str, name: str, scry_url: str | None, scry_id: str | None = None
) -> dict:
if scry_id:
url = f"https://api.scryfall.com/cards/{quote(scry_id)}"
r = await _scryfall_get(client, url, slug)
r.raise_for_status()
return r.json()
if scry_url:
parsed = parse_scry_url(scry_url)
if not parsed:
@@ -85,7 +93,7 @@ def extract_images(data: dict) -> tuple[str, str | None]:
def build_tts(slug: str) -> dict:
deck = db.get_deck(slug)
cards = db.get_cards(slug)
commander_name = (deck["commander"] or "").strip()
commander_names = [n for n in (deck["commander"] or "").splitlines() if n.strip()]
done = [c for c in cards if c["fetch_status"] == "done" and c["filename"]]
if not done:
@@ -133,34 +141,53 @@ def build_tts(slug: str) -> dict:
"CustomDeck": {str(n): entry(c)},
}
commander = next((c for c in done if c["name"] == commander_name), None) if commander_name else None
rest = [c for c in done if commander is None or c["id"] != commander["id"]]
def build_pile(pile_cards: list, nickname: str, x: float) -> dict | None:
if len(pile_cards) >= 2:
deck_ids, custom_deck, contained = [], {}, []
for c in pile_cards:
n = next_k()
e = entry(c)
custom_deck[str(n)] = e
deck_ids.append(n * 100)
contained.append({
"Name": "Card", "Transform": transform(), "Nickname": c["name"],
**base, "CardID": n * 100, "CustomDeck": {str(n): e},
})
return {
"Name": "Deck", "Transform": transform(x=x), "Nickname": nickname,
"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": "",
}
if pile_cards:
return card_object(pile_cards[0], x=x, rz=0.0)
return None
token_cards = [c for c in done if c["is_token"]]
playable = [c for c in done if not c["is_token"]]
commanders = []
used_ids = set()
for cname in commander_names:
match = next((c for c in playable if c["name"] == cname and c["id"] not in used_ids), None)
if match:
commanders.append(match)
used_ids.add(match["id"])
rest = [c for c in playable if c["id"] not in used_ids]
objects = []
if commander:
objects.append(card_object(commander, x=-5.0, rz=0.0))
for i, cmd in enumerate(commanders):
objects.append(card_object(cmd, x=-5.0 - i * 2.5, rz=0.0))
if len(rest) >= 2:
deck_ids, custom_deck, contained = [], {}, []
for c in rest:
n = next_k()
e = entry(c)
custom_deck[str(n)] = e
deck_ids.append(n * 100)
contained.append({
"Name": "Card", "Transform": transform(), "Nickname": c["name"],
**base, "CardID": n * 100, "CustomDeck": {str(n): e},
})
objects.append({
"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": "",
})
elif rest:
objects.append(card_object(rest[0], rz=0.0))
main_pile = build_pile(rest, deck["deck_name"], x=0.0)
if main_pile:
objects.append(main_pile)
token_pile = build_pile(token_cards, f"{deck['deck_name']} — Tokens", x=6.0)
if token_pile:
objects.append(token_pile)
return {
"SaveName": deck["deck_name"], "Date": "", "VersionNumber": "",
@@ -200,19 +227,31 @@ async def _process(client: httpx.AsyncClient, slug: str):
name = card["name"]
card_id = card["id"]
scry_url = card["scry_url"]
scry_id = card["scry_id"]
base = safe_filename(name) if name else f"card_{card_id}"
filename = f"{base}_{card_id}.png"
back_filename = f"{base}_{card_id}_back.png"
dest = deck_dir / filename
label = f"[{card['position']}] {name or scry_url}"
label = f"[{card['position']}] {name or scry_url or scry_id}"
# Dedup key: an exact scryfall id is the most precise match (safe to
# alias across cards even without a name yet); a scry_url is a precise
# print and must never be aliased; otherwise fall back to name.
dedup_col, dedup_val = (None, None)
if scry_id:
dedup_col, dedup_val = "scry_id", scry_id
elif not scry_url:
dedup_col, dedup_val = "name", name
if dest.exists():
bf = back_filename if (deck_dir / back_filename).exists() else None
with db.conn() as c:
price_row = c.execute(
"SELECT price_usd FROM cards WHERE deck_slug=? AND name=? AND fetch_status='done' AND id!=? LIMIT 1",
(slug, name, card_id)
).fetchone()
price_row = None
if dedup_col:
price_row = c.execute(
f"SELECT price_usd FROM cards WHERE deck_slug=? AND {dedup_col}=? AND fetch_status='done' AND id!=? LIMIT 1",
(slug, dedup_val, card_id)
).fetchone()
price = price_row["price_usd"] if price_row else None
if price is not None:
c.execute("UPDATE cards SET fetch_status='done', filename=?, back_filename=?, price_usd=? WHERE id=?",
@@ -224,13 +263,12 @@ async def _process(client: httpx.AsyncClient, slug: str):
db.recalc_done(slug)
continue
# Name-based dedup only — URL adds are precise prints and must never be aliased
if not scry_url:
if dedup_col:
with db.conn() as c:
row = c.execute(
"SELECT filename, back_filename, price_usd FROM cards "
"WHERE deck_slug=? AND name=? AND fetch_status='done' AND id!=? LIMIT 1",
(slug, name, card_id)
f"SELECT filename, back_filename, price_usd FROM cards "
f"WHERE deck_slug=? AND {dedup_col}=? AND fetch_status='done' AND id!=? LIMIT 1",
(slug, dedup_val, card_id)
).fetchone()
if row and row["filename"] and (deck_dir / row["filename"]).exists():
shutil.copy(deck_dir / row["filename"], dest)
@@ -246,7 +284,7 @@ async def _process(client: httpx.AsyncClient, slug: str):
continue
try:
data = await resolve_card(client, slug, name, scry_url)
data = await resolve_card(client, slug, name, scry_url, scry_id)
front_url, back_url = extract_images(data)
price = float(data.get("prices", {}).get("usd") or 0)
resolved_name = name or data.get("name", "")