mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-04-06 05:07:16 +02:00
feat: Card Kingdom prices, shopping cart export, and hover panel fixes (#73)
* feat: add CK prices, shopping cart export, and hover panel fixes * fix: include commander in Buy This Deck cart export
This commit is contained in:
parent
dd996939e6
commit
69d84cc414
24 changed files with 899 additions and 146 deletions
|
|
@ -30,6 +30,8 @@ logger.addHandler(logging_util.stream_handler)
|
|||
_CACHE_TTL_SECONDS = 86400 # 24 hours
|
||||
_BULK_DATA_FILENAME = "scryfall_bulk_data.json"
|
||||
_PRICE_CACHE_FILENAME = "prices_cache.json"
|
||||
_CK_CACHE_FILENAME = "ck_prices_cache.json"
|
||||
_CK_API_URL = "https://api.cardkingdom.com/api/v2/pricelist"
|
||||
|
||||
|
||||
class PriceService(BaseService):
|
||||
|
|
@ -68,6 +70,14 @@ class PriceService(BaseService):
|
|||
self._miss_count = 0
|
||||
self._refresh_thread: Optional[threading.Thread] = None
|
||||
|
||||
# CK price cache: {normalized_card_name: float (cheapest non-foil retail)}
|
||||
self._ck_cache_path: str = os.path.join(card_files_dir(), _CK_CACHE_FILENAME)
|
||||
self._ck_cache: Dict[str, float] = {}
|
||||
self._ck_loaded: bool = False
|
||||
|
||||
# scryfall_id map built during _rebuild_cache: {name.lower(): scryfall_id}
|
||||
self._scryfall_id_map: Dict[str, str] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -157,6 +167,35 @@ class PriceService(BaseService):
|
|||
"bulk_data_available": os.path.exists(self._bulk_path),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CK Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_ck_price(self, card_name: str) -> Optional[float]:
|
||||
"""Return the Card Kingdom retail price for *card_name*, or None."""
|
||||
self._ensure_ck_loaded()
|
||||
return self._ck_cache.get(card_name.lower().strip())
|
||||
|
||||
def get_ck_prices_batch(self, card_names: List[str]) -> Dict[str, Optional[float]]:
|
||||
"""Return a mapping of card name → CK retail price for all requested cards."""
|
||||
self._ensure_ck_loaded()
|
||||
return {
|
||||
name: self._ck_cache.get(name.lower().strip())
|
||||
for name in card_names
|
||||
}
|
||||
|
||||
def get_ck_built_at(self) -> Optional[str]:
|
||||
"""Return a human-readable CK cache build date, or None if unavailable."""
|
||||
try:
|
||||
if os.path.exists(self._ck_cache_path):
|
||||
import datetime
|
||||
built = os.path.getmtime(self._ck_cache_path)
|
||||
dt = datetime.datetime.fromtimestamp(built, tz=datetime.timezone.utc)
|
||||
return dt.strftime("%B %d, %Y")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def refresh_cache_background(self) -> None:
|
||||
"""Spawn a daemon thread to rebuild the price cache asynchronously.
|
||||
|
||||
|
|
@ -412,6 +451,7 @@ class PriceService(BaseService):
|
|||
|
||||
logger.info("Building price cache from %s ...", self._bulk_path)
|
||||
new_cache: Dict[str, Dict[str, float]] = {}
|
||||
new_scryfall_id_map: Dict[str, str] = {}
|
||||
built_at = time.time()
|
||||
|
||||
try:
|
||||
|
|
@ -426,6 +466,7 @@ class PriceService(BaseService):
|
|||
continue
|
||||
|
||||
name: str = card.get("name", "")
|
||||
scryfall_id: str = card.get("id", "")
|
||||
prices: Dict[str, Any] = card.get("prices") or {}
|
||||
if not name:
|
||||
continue
|
||||
|
|
@ -446,6 +487,9 @@ class PriceService(BaseService):
|
|||
new_usd = entry.get("usd", 9999.0)
|
||||
if existing is None or new_usd < existing.get("usd", 9999.0):
|
||||
new_cache[key] = entry
|
||||
# Track the scryfall_id of the cheapest-priced printing
|
||||
if scryfall_id:
|
||||
new_scryfall_id_map[key] = scryfall_id
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Failed to parse bulk data: %s", exc)
|
||||
|
|
@ -466,6 +510,7 @@ class PriceService(BaseService):
|
|||
|
||||
with self._lock:
|
||||
self._cache = new_cache
|
||||
self._scryfall_id_map = new_scryfall_id_map
|
||||
self._last_refresh = built_at
|
||||
# Stamp all keys as fresh so get_stale_cards() reflects the rebuild.
|
||||
# _lazy_ts may not exist if start_lazy_refresh() was never called
|
||||
|
|
@ -476,9 +521,102 @@ class PriceService(BaseService):
|
|||
self._lazy_ts[key] = built_at
|
||||
self._save_lazy_ts()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CK internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _ensure_ck_loaded(self) -> None:
|
||||
"""Lazy-load the CK price cache on first access (double-checked lock)."""
|
||||
if self._ck_loaded:
|
||||
return
|
||||
with self._lock:
|
||||
if self._ck_loaded:
|
||||
return
|
||||
if os.path.exists(self._ck_cache_path):
|
||||
try:
|
||||
age = time.time() - os.path.getmtime(self._ck_cache_path)
|
||||
if age < self._ttl:
|
||||
self._load_ck_from_cache()
|
||||
logger.info("Loaded %d CK prices from cache (age %.1fh)", len(self._ck_cache), age / 3600)
|
||||
self._ck_loaded = True
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.warning("CK cache unreadable: %s", exc)
|
||||
# No fresh cache — set loaded flag anyway so we degrade gracefully
|
||||
# rather than blocking every request. CK rebuild happens via Setup page.
|
||||
self._ck_loaded = True
|
||||
|
||||
def _rebuild_ck_cache(self) -> None:
|
||||
"""Fetch the Card Kingdom price list and cache retail prices by card name.
|
||||
|
||||
Fetches https://api.cardkingdom.com/api/v2/pricelist, takes the cheapest
|
||||
non-foil price_retail per card name (across all printings), and writes
|
||||
ck_prices_cache.json atomically.
|
||||
"""
|
||||
import urllib.request as _urllib
|
||||
logger.info("Fetching CK price list from %s ...", _CK_API_URL)
|
||||
try:
|
||||
req = _urllib.Request(_CK_API_URL, headers={"User-Agent": "MTGPythonDeckbuilder/1.0"})
|
||||
with _urllib.urlopen(req, timeout=30) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
except Exception as exc:
|
||||
logger.warning("CK price fetch failed: %s", exc)
|
||||
return
|
||||
|
||||
items = data.get("data", [])
|
||||
meta_created_at = data.get("meta", {}).get("created_at", "")
|
||||
new_ck: Dict[str, float] = {}
|
||||
|
||||
for item in items:
|
||||
if item.get("is_foil") == "true":
|
||||
continue
|
||||
name = item.get("name", "")
|
||||
price_str = item.get("price_retail", "")
|
||||
if not name or not price_str:
|
||||
continue
|
||||
try:
|
||||
price = float(price_str)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if price <= 0:
|
||||
continue
|
||||
|
||||
# Index by full name and each face for split/DFC cards
|
||||
keys_to_index = [name.lower()]
|
||||
if " // " in name:
|
||||
keys_to_index += [part.strip().lower() for part in name.split(" // ")]
|
||||
|
||||
for key in keys_to_index:
|
||||
if key not in new_ck or price < new_ck[key]:
|
||||
new_ck[key] = price
|
||||
|
||||
# Write cache atomically
|
||||
try:
|
||||
cache_data = {"ck_prices": new_ck, "built_at": time.time(), "meta_created_at": meta_created_at}
|
||||
tmp = self._ck_cache_path + ".tmp"
|
||||
os.makedirs(os.path.dirname(self._ck_cache_path), exist_ok=True)
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump(cache_data, fh, separators=(",", ":"))
|
||||
os.replace(tmp, self._ck_cache_path)
|
||||
logger.info("CK price cache written: %d cards → %s", len(new_ck), self._ck_cache_path)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to write CK price cache: %s", exc)
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
self._ck_cache = new_ck
|
||||
self._ck_loaded = True
|
||||
|
||||
def _load_ck_from_cache(self) -> None:
|
||||
"""Deserialize the CK prices cache JSON into memory."""
|
||||
with open(self._ck_cache_path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
self._ck_cache = data.get("ck_prices", {})
|
||||
|
||||
@staticmethod
|
||||
def _extract_prices(prices: Dict[str, Any]) -> Dict[str, float]:
|
||||
"""Convert raw Scryfall prices dict to {region_key: float} entries."""
|
||||
|
||||
result: Dict[str, float] = {}
|
||||
for key in ("usd", "usd_foil", "eur", "eur_foil"):
|
||||
raw = prices.get(key)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue