210 lines
7.8 KiB
Python
210 lines
7.8 KiB
Python
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}")
|