fix: add missing Path, os, threading imports to routes/collection.py
This commit is contained in:
1 parent
c60719a0a9
commit
ac3b76793b
1 file changed
+4
-613
+4
-613
@@ -18,11 +18,15 @@ from __future__ import annotations
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
@@ -257,7 +261,6 @@ async def list_collection(
|
||||
|
||||
# If deck_id provided, annotate each item with how many copies are already in that deck
|
||||
if deck_id is not None:
|
||||
# Build a map of scryfall_id -> quantity in deck
|
||||
deck_card_rows = conn.execute(
|
||||
"SELECT scryfall_id, SUM(quantity) as qty FROM deck_cards WHERE deck_id = ? GROUP BY scryfall_id",
|
||||
(deck_id,)
|
||||
@@ -317,617 +320,6 @@ async def update_collection_card(cardId: int, body: dict):
|
||||
return _add_image_url(_row_to_dict(updated_row))
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Collection Import Helpers
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
# ── Scryfall Response Cache ─────────────────────────────────
|
||||
# SQLite-backed cache for Scryfall API responses.
|
||||
# Keyed by (name, set_code, collector_number). Default TTL: 7 days.
|
||||
# Scryfall data changes infrequently (set releases ~every 3 months).
|
||||
# Eliminates redundant HTTP calls across imports and re-enrichments.
|
||||
|
||||
SCRYFALL_CACHE_DB_PATH = Path(__file__).parent / "scryfall_cache.db"
|
||||
SCRYFALL_CACHE_TTL_SECONDS = int(os.environ.get("SCRYFALL_CACHE_TTL", 7 * 24 * 3600)) # 7 days
|
||||
|
||||
|
||||
class _ScryfallCache:
|
||||
"""
|
||||
Thread-safe SQLite cache for raw Scryfall JSON responses.
|
||||
|
||||
Schema:
|
||||
cache_key TEXT PRIMARY KEY — "name|set|cn" normalised lowercase
|
||||
json_blob TEXT — raw Scryfall API JSON response
|
||||
fetched_at TEXT — ISO-8601 UTC timestamp
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path = None, ttl_seconds: int = None):
|
||||
self._db_path = db_path or SCRYFALL_CACHE_DB_PATH
|
||||
self._ttl = ttl_seconds or SCRYFALL_CACHE_TTL_SECONDS
|
||||
self._lock = threading.Lock()
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
self._init_db()
|
||||
|
||||
# ── internal ────────────────────────────────────────────
|
||||
|
||||
def _init_db(self):
|
||||
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS scryfall_cache (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
json_blob TEXT NOT NULL,
|
||||
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
self._conn.commit()
|
||||
|
||||
@staticmethod
|
||||
def _make_key(name: str, set_code: str, collector_number: str) -> str:
|
||||
return f"{name.strip().lower()}|{(set_code or '').strip().lower()}|{(collector_number or '').strip()}"
|
||||
|
||||
# ── public API ──────────────────────────────────────────
|
||||
|
||||
def get(self, name: str, set_code: str = "", collector_number: str = "") -> Optional[dict]:
|
||||
"""Return cached Scryfall JSON dict, or None if missing / expired."""
|
||||
key = self._make_key(name, set_code, collector_number)
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT json_blob, fetched_at FROM scryfall_cache WHERE cache_key = ?",
|
||||
(key,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
self._misses += 1
|
||||
return None
|
||||
# TTL check
|
||||
try:
|
||||
fetched = datetime.fromisoformat(row[1])
|
||||
age = (datetime.utcnow() - fetched).total_seconds()
|
||||
if age > self._ttl:
|
||||
self._misses += 1
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
self._misses += 1
|
||||
return None
|
||||
self._hits += 1
|
||||
try:
|
||||
return json.loads(row[0])
|
||||
except json.JSONDecodeError:
|
||||
self._misses += 1
|
||||
return None
|
||||
|
||||
def put(self, name: str, set_code: str, collector_number: str, card_data: dict):
|
||||
"""Store a Scryfall response in the cache."""
|
||||
key = self._make_key(name, set_code, collector_number)
|
||||
blob = json.dumps(card_data, separators=(",", ":"))
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"INSERT OR REPLACE INTO scryfall_cache (cache_key, json_blob, fetched_at) "
|
||||
"VALUES (?, ?, datetime('now'))",
|
||||
(key, blob),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def stats(self) -> dict:
|
||||
"""Return cache statistics."""
|
||||
with self._lock:
|
||||
total = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
|
||||
oldest_row = self._conn.execute(
|
||||
"SELECT MIN(fetched_at) FROM scryfall_cache"
|
||||
).fetchone()
|
||||
newest_row = self._conn.execute(
|
||||
"SELECT MAX(fetched_at) FROM scryfall_cache"
|
||||
).fetchone()
|
||||
db_size_bytes = os.path.getsize(str(self._db_path)) if self._db_path.exists() else 0
|
||||
return {
|
||||
"total_entries": total,
|
||||
"hits": self._hits,
|
||||
"misses": self._misses,
|
||||
"hit_rate": round(self._hits / max(self._hits + self._misses, 1) * 100, 1),
|
||||
"ttl_seconds": self._ttl,
|
||||
"ttl_days": round(self._ttl / 86400, 1),
|
||||
"oldest_entry": oldest_row[0] if oldest_row else None,
|
||||
"newest_entry": newest_row[0] if newest_row else None,
|
||||
"db_size_kb": round(db_size_bytes / 1024, 1),
|
||||
"db_path": str(self._db_path),
|
||||
}
|
||||
|
||||
def clear(self) -> int:
|
||||
"""Delete all cached entries. Returns count of deleted rows."""
|
||||
with self._lock:
|
||||
count = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
|
||||
self._conn.execute("DELETE FROM scryfall_cache")
|
||||
self._conn.commit()
|
||||
self._conn.execute("VACUUM") # must run outside transaction
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
return count
|
||||
|
||||
def evict_expired(self) -> int:
|
||||
"""Remove entries older than TTL. Returns count of evicted rows."""
|
||||
cutoff = datetime.utcnow().isoformat()
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"DELETE FROM scryfall_cache WHERE "
|
||||
"(julianday(?) - julianday(fetched_at)) * 86400 > ?",
|
||||
(cutoff, self._ttl),
|
||||
)
|
||||
deleted = self._conn.total_changes
|
||||
self._conn.commit()
|
||||
return deleted
|
||||
|
||||
|
||||
_scryfall_cache = _ScryfallCache()
|
||||
|
||||
|
||||
# Scryfall rate-limit semaphore (max 10 req/sec)
|
||||
_scryfall_lock = threading.Lock()
|
||||
_scryfall_last_call = 0.0
|
||||
|
||||
|
||||
def _scryfall_rate_limit():
|
||||
"""Enforce at most 10 Scryfall requests per second via simple sleep."""
|
||||
global _scryfall_last_call
|
||||
with _scryfall_lock:
|
||||
now = time.monotonic()
|
||||
elapsed = now - _scryfall_last_call
|
||||
if elapsed < 0.1:
|
||||
time.sleep(0.1 - elapsed)
|
||||
_scryfall_last_call = time.monotonic()
|
||||
|
||||
|
||||
def _parse_finish(raw: str) -> str:
|
||||
"""Normalize finish string to NORMAL|FOIL|ETCHED."""
|
||||
if not raw:
|
||||
return "NORMAL"
|
||||
r = raw.strip().lower()
|
||||
if r in ("etched", "foil etched", "foil_etched", "etched foil"):
|
||||
return "ETCHED"
|
||||
if r in ("yes", "foil", "true", "1"):
|
||||
return "FOIL"
|
||||
return "NORMAL"
|
||||
|
||||
|
||||
def _parse_text_line(line: str) -> dict:
|
||||
"""
|
||||
Parse a text import line into {name, quantity, set_code, collector_number}.
|
||||
Supports formats:
|
||||
- "3 Sol Ring (CMR) 123"
|
||||
- "1x Sol Ring (CMR)"
|
||||
- "Sol Ring [CMR:123]"
|
||||
- "1 Sol Ring"
|
||||
"""
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or line.startswith("//"):
|
||||
return {}
|
||||
|
||||
result = {"name": "", "quantity": 1, "set_code": "", "collector_number": ""}
|
||||
|
||||
# Strip quantity prefix
|
||||
qty_match = re.match(r"^(\d+)x?\s+", line)
|
||||
if qty_match:
|
||||
result["quantity"] = int(qty_match.group(1))
|
||||
line = line[qty_match.end():]
|
||||
|
||||
# Format: "Name [SET:CollNum]"
|
||||
bracket_match = re.search(r"\[([A-Za-z0-9]+):(\S+)\]\s*$", line)
|
||||
if bracket_match:
|
||||
result["set_code"] = bracket_match.group(1).lower()
|
||||
result["collector_number"] = bracket_match.group(2)
|
||||
line = line[:bracket_match.start()].strip()
|
||||
result["name"] = line
|
||||
return result
|
||||
|
||||
# Format: "Name (SET) CollNum"
|
||||
paren_coll_match = re.search(r"\(([A-Za-z0-9]+)\)\s+(\S+)\s*$", line)
|
||||
if paren_coll_match:
|
||||
result["set_code"] = paren_coll_match.group(1).lower()
|
||||
result["collector_number"] = paren_coll_match.group(2)
|
||||
line = line[:paren_coll_match.start()].strip()
|
||||
result["name"] = line
|
||||
return result
|
||||
|
||||
# Format: "Name (SET)"
|
||||
paren_match = re.search(r"\(([A-Za-z0-9]+)\)\s*$", line)
|
||||
if paren_match:
|
||||
result["set_code"] = paren_match.group(1).lower()
|
||||
line = line[:paren_match.start()].strip()
|
||||
result["name"] = line
|
||||
return result
|
||||
|
||||
result["name"] = line.strip()
|
||||
return result
|
||||
|
||||
|
||||
def _auto_infer_mapping(headers: list) -> dict:
|
||||
"""
|
||||
Auto-infer column → field mapping from header names.
|
||||
Returns dict of {header: field_key}.
|
||||
Field keys: name, quantity, set_code, collector_number, finish, condition, language, notes, tags
|
||||
"""
|
||||
mapping = {}
|
||||
header_lower = {h: h.lower().strip() for h in headers}
|
||||
field_patterns = {
|
||||
"name": ["name", "card name", "card_name", "cardname", "title"],
|
||||
"quantity": ["quantity", "qty", "count", "amount", "number", "#"],
|
||||
"set_code": ["set", "set code", "set_code", "edition", "set_name", "edition code"],
|
||||
"collector_number": [
|
||||
"collector number", "collector_number", "collectornumber",
|
||||
"col #", "col#", "number", "card number",
|
||||
],
|
||||
"finish": ["finish", "foil", "printing", "treatment"],
|
||||
"condition": ["condition", "cond", "grade"],
|
||||
"language": ["language", "lang", "locale"],
|
||||
"notes": ["notes", "note", "comments", "comment"],
|
||||
"tags": ["tags", "tag", "labels", "label"],
|
||||
}
|
||||
for header, lower_h in header_lower.items():
|
||||
for field, patterns in field_patterns.items():
|
||||
if lower_h in patterns:
|
||||
mapping[header] = field
|
||||
break
|
||||
return mapping
|
||||
|
||||
|
||||
def _parse_csv_content(content: str, source: str, mapping: Optional[dict]) -> list:
|
||||
"""
|
||||
Parse CSV/text content into a list of RawImportRow dicts.
|
||||
Returns list of dicts with keys: name, quantity, set_code, collector_number, finish, condition, language, notes, tags
|
||||
"""
|
||||
rows = []
|
||||
source_upper = (source or "").upper()
|
||||
|
||||
if source_upper == "TEXT":
|
||||
for line in content.splitlines():
|
||||
parsed = _parse_text_line(line)
|
||||
if parsed.get("name"):
|
||||
rows.append(parsed)
|
||||
return rows
|
||||
|
||||
# CSV-based sources
|
||||
reader = csv.DictReader(io.StringIO(content))
|
||||
headers = reader.fieldnames or []
|
||||
|
||||
# Build effective mapping
|
||||
if mapping:
|
||||
col_map = {h: mapping.get(h, "") for h in headers}
|
||||
else:
|
||||
col_map = _auto_infer_mapping(headers)
|
||||
|
||||
# Normalize col_map field values to lowercase so both "NAME" (UI) and "name" (auto-infer) work
|
||||
col_map = {h: (v.lower() if isinstance(v, str) else v) for h, v in col_map.items()}
|
||||
|
||||
for csv_row in reader:
|
||||
row = {
|
||||
"name": "",
|
||||
"quantity": 1,
|
||||
"set_code": "",
|
||||
"collector_number": "",
|
||||
"finish": "NORMAL",
|
||||
"condition": "",
|
||||
"language": "",
|
||||
"notes": "",
|
||||
"tags": "",
|
||||
}
|
||||
for header, field in col_map.items():
|
||||
val = csv_row.get(header, "").strip()
|
||||
if not val or field == "ignore" or not field:
|
||||
continue
|
||||
if field == "name":
|
||||
# Strip finish suffix like "(Foil Etched)"
|
||||
val = re.sub(r"\s*\(foil(?: etched)?\)\s*$", "", val, flags=re.IGNORECASE).strip()
|
||||
row["name"] = val
|
||||
elif field == "quantity":
|
||||
try:
|
||||
row["quantity"] = int(float(val))
|
||||
except ValueError:
|
||||
row["quantity"] = 1
|
||||
elif field in ("set_code", "set_code_secondary"):
|
||||
row["set_code"] = val.lower()
|
||||
elif field in ("collector_number", "collector_number_secondary"):
|
||||
row["collector_number"] = val
|
||||
elif field == "finish":
|
||||
row["finish"] = _parse_finish(val)
|
||||
elif field == "condition":
|
||||
row["condition"] = val
|
||||
elif field == "language":
|
||||
row["language"] = val
|
||||
elif field == "notes":
|
||||
row["notes"] = val
|
||||
elif field == "tags":
|
||||
row["tags"] = val
|
||||
|
||||
if row["name"]:
|
||||
rows.append(row)
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def _fetch_scryfall_api(name: str, set_code: str = "", collector_number: str = "") -> dict:
|
||||
"""
|
||||
Fetch raw card data from Scryfall API (with caching).
|
||||
Returns the raw Scryfall JSON dict, or a dict with '_error' on failure.
|
||||
Cache is checked first; live HTTP call only on miss.
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
# ── Cache check ──────────────────────────────────────
|
||||
cached = _scryfall_cache.get(name, set_code, collector_number)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# ── Live fetch ───────────────────────────────────────
|
||||
card_data = None
|
||||
last_error = ""
|
||||
|
||||
# Try set + collector number first
|
||||
if set_code and collector_number:
|
||||
_scryfall_rate_limit()
|
||||
try:
|
||||
url = f"https://api.scryfall.com/cards/{set_code.lower()}/{collector_number}"
|
||||
req = Request(url, headers=_API_HEADERS)
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
card_data = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
card_data = None
|
||||
|
||||
# Fall back to exact name lookup
|
||||
if not card_data:
|
||||
_scryfall_rate_limit()
|
||||
try:
|
||||
encoded_name = quote(name)
|
||||
url = f"https://api.scryfall.com/cards/named?exact={encoded_name}"
|
||||
req = Request(url, headers=_API_HEADERS)
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
card_data = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
return {"_error": f"Scryfall lookup failed for '{name}': {last_error}"}
|
||||
|
||||
if not card_data or card_data.get("object") == "error":
|
||||
err_detail = card_data.get("details", "unknown error") if card_data else last_error
|
||||
return {"_error": f"Scryfall returned error for '{name}': {err_detail}"}
|
||||
|
||||
# ── Cache the successful response ────────────────────
|
||||
_scryfall_cache.put(name, set_code, collector_number, card_data)
|
||||
# Also cache under the Scryfall-resolved name (handles spelling normalisation)
|
||||
resolved_name = card_data.get("name", "")
|
||||
if resolved_name and resolved_name.lower() != name.strip().lower():
|
||||
_scryfall_cache.put(resolved_name, set_code, collector_number, card_data)
|
||||
|
||||
return card_data
|
||||
|
||||
|
||||
def _enrich_from_scryfall(name: str, set_code: str = "", collector_number: str = "") -> dict:
|
||||
"""
|
||||
Fetch card data from Scryfall (cached) and return enriched fields dict.
|
||||
Tries set+collectorNumber first, then exact name lookup.
|
||||
Returns empty dict on failure.
|
||||
"""
|
||||
card_data = _fetch_scryfall_api(name, set_code, collector_number)
|
||||
|
||||
if not card_data or "_error" in card_data:
|
||||
return card_data or {}
|
||||
|
||||
# Extract fields
|
||||
type_line = card_data.get("type_line", "")
|
||||
color_identity = card_data.get("color_identity", [])
|
||||
keywords = card_data.get("keywords", [])
|
||||
|
||||
# Parse subtypes (after em-dash or hyphen)
|
||||
subtypes = []
|
||||
for sep in [" \u2014 ", " - "]:
|
||||
if sep in type_line:
|
||||
parts = type_line.split(sep, 1)
|
||||
subtypes = [s.strip() for s in parts[1].split() if s.strip()]
|
||||
break
|
||||
|
||||
is_legendary = 1 if "Legendary" in type_line else 0
|
||||
is_basic = 1 if "Basic" in type_line else 0
|
||||
|
||||
# Get price
|
||||
prices = card_data.get("prices", {})
|
||||
tcg_price = 0.0
|
||||
try:
|
||||
price_val = prices.get("usd") or prices.get("usd_foil") or "0"
|
||||
tcg_price = float(price_val) if price_val else 0.0
|
||||
except (ValueError, TypeError):
|
||||
tcg_price = 0.0
|
||||
|
||||
# Handle double-faced cards: oracle_text may be on card_faces
|
||||
oracle_text = card_data.get("oracle_text", "")
|
||||
if not oracle_text and card_data.get("card_faces"):
|
||||
face_texts = [f.get("oracle_text", "") for f in card_data["card_faces"]]
|
||||
oracle_text = "\n//\n".join(t for t in face_texts if t)
|
||||
|
||||
# Power/toughness (may be on card_faces for DFCs)
|
||||
power = card_data.get("power", "")
|
||||
toughness = card_data.get("toughness", "")
|
||||
if not power and card_data.get("card_faces"):
|
||||
power = card_data["card_faces"][0].get("power", "")
|
||||
toughness = card_data["card_faces"][0].get("toughness", "")
|
||||
|
||||
# Mana cost (may be on card_faces for DFCs)
|
||||
mana_cost = card_data.get("mana_cost", "")
|
||||
if not mana_cost and card_data.get("card_faces"):
|
||||
mana_cost = card_data["card_faces"][0].get("mana_cost", "")
|
||||
|
||||
# Auto-detect functional categories
|
||||
auto_roles = _detect_card_roles(oracle_text, type_line, keywords)
|
||||
|
||||
return {
|
||||
"name": card_data.get("name", name),
|
||||
"type_line": type_line,
|
||||
"subtypes": json.dumps(subtypes),
|
||||
"is_legendary": is_legendary,
|
||||
"is_basic": is_basic,
|
||||
"color_identity": json.dumps(color_identity),
|
||||
"cmc": card_data.get("cmc", 0.0),
|
||||
"mana_cost": mana_cost,
|
||||
"oracle_text": oracle_text,
|
||||
"keywords": json.dumps(keywords),
|
||||
"power": power or "",
|
||||
"toughness": toughness or "",
|
||||
"rarity": card_data.get("rarity", ""),
|
||||
"set_name": card_data.get("set_name", ""),
|
||||
"edhrec_rank": card_data.get("edhrec_rank", 0) or 0,
|
||||
"tcg_price": tcg_price,
|
||||
"salt_score": 0.0,
|
||||
"is_game_changer": 0,
|
||||
"category": json.dumps(auto_roles),
|
||||
"scryfall_id": card_data.get("id", ""),
|
||||
"tcgplayer_id": str(card_data.get("tcgplayer_id", "")),
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Collection Import Endpoint
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
@router.post("/api/collection/import")
|
||||
async def import_collection(body: dict):
|
||||
"""
|
||||
Import cards into the collection from CSV, Moxfield, Archidekt, or plain text.
|
||||
|
||||
Body:
|
||||
{
|
||||
"source": "CSV" | "MOXFIELD" | "ARCHIDEKT" | "TEXT",
|
||||
"mode": "MERGE" | "REPLACE",
|
||||
"content": "...csv or text content...",
|
||||
"mapping": { ... optional column mapping ... }
|
||||
}
|
||||
"""
|
||||
source = str(body.get("source", "CSV")).upper()
|
||||
mode = str(body.get("mode", "MERGE")).upper()
|
||||
content = str(body.get("content", ""))
|
||||
mapping = body.get("mapping", {})
|
||||
|
||||
if not content.strip():
|
||||
raise HTTPException(400, "content is required")
|
||||
|
||||
imported_count = 0
|
||||
updated_count = 0
|
||||
failed_count = 0
|
||||
errors = []
|
||||
|
||||
conn = _get_db_conn()
|
||||
|
||||
# REPLACE mode: clear existing entries
|
||||
if mode == "REPLACE":
|
||||
conn.execute("DELETE FROM collection_entries")
|
||||
conn.commit()
|
||||
|
||||
# Parse the content into raw rows
|
||||
try:
|
||||
raw_rows = _parse_csv_content(content, source, mapping if mapping else None)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Failed to parse content: {str(e)}")
|
||||
|
||||
for raw in raw_rows:
|
||||
name = raw.get("name", "").strip()
|
||||
if not name:
|
||||
continue
|
||||
|
||||
quantity = int(raw.get("quantity", 1))
|
||||
set_code = raw.get("set_code", "").lower()
|
||||
collector_number = raw.get("collector_number", "")
|
||||
finish = _parse_finish(raw.get("finish", ""))
|
||||
condition = raw.get("condition", "")
|
||||
language = raw.get("language", "")
|
||||
notes = raw.get("notes", "")
|
||||
tags = raw.get("tags", "")
|
||||
|
||||
# Scryfall enrichment
|
||||
try:
|
||||
enriched = _enrich_from_scryfall(name, set_code, collector_number)
|
||||
except Exception as e:
|
||||
errors.append(f"Scryfall error for '{name}': {str(e)}")
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
if not enriched or "_error" in enriched:
|
||||
err_msg = enriched.get("_error", f"Card not found on Scryfall: '{name}'") if enriched else f"Card not found on Scryfall: '{name}'"
|
||||
errors.append(err_msg)
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
# Use Scryfall-resolved name if available
|
||||
resolved_name = enriched.get("name", name)
|
||||
|
||||
# Check for existing entry (identity key)
|
||||
existing = conn.execute(
|
||||
"""SELECT id, quantity FROM collection_entries
|
||||
WHERE name = ? AND set_code = ? AND collector_number = ? AND finish = ?""",
|
||||
(resolved_name, set_code, collector_number, finish),
|
||||
).fetchone()
|
||||
|
||||
if existing and mode == "MERGE":
|
||||
# Update quantity
|
||||
new_qty = existing["quantity"] + quantity
|
||||
conn.execute(
|
||||
"UPDATE collection_entries SET quantity = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(new_qty, existing["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
updated_count += 1
|
||||
else:
|
||||
# Insert new row
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_entries
|
||||
(name, type_line, subtypes, is_legendary, is_basic, color_identity,
|
||||
cmc, mana_cost, oracle_text, keywords,
|
||||
power, toughness, rarity, set_name, edhrec_rank,
|
||||
tcg_price, salt_score, is_game_changer,
|
||||
category, scryfall_id, tcgplayer_id,
|
||||
quantity, finish, condition, language, notes, tags,
|
||||
set_code, collector_number)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
resolved_name,
|
||||
enriched.get("type_line", ""),
|
||||
enriched.get("subtypes", "[]"),
|
||||
enriched.get("is_legendary", 0),
|
||||
enriched.get("is_basic", 0),
|
||||
enriched.get("color_identity", "[]"),
|
||||
enriched.get("cmc", 0.0),
|
||||
enriched.get("mana_cost", ""),
|
||||
enriched.get("oracle_text", ""),
|
||||
enriched.get("keywords", "[]"),
|
||||
enriched.get("power", ""),
|
||||
enriched.get("toughness", ""),
|
||||
enriched.get("rarity", ""),
|
||||
enriched.get("set_name", ""),
|
||||
enriched.get("edhrec_rank", 0),
|
||||
enriched.get("tcg_price", 0.0),
|
||||
enriched.get("salt_score", 0.0),
|
||||
enriched.get("is_game_changer", 0),
|
||||
enriched.get("category", "[]"),
|
||||
enriched.get("scryfall_id", ""),
|
||||
enriched.get("tcgplayer_id", ""),
|
||||
quantity,
|
||||
finish,
|
||||
condition,
|
||||
language,
|
||||
notes,
|
||||
tags,
|
||||
set_code,
|
||||
collector_number,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
imported_count += 1
|
||||
|
||||
return {
|
||||
"importedCount": imported_count,
|
||||
"updatedCount": updated_count,
|
||||
"failedCount": failed_count,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Scryfall Cache Management Endpoints
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
@@ -970,4 +362,3 @@ _TYPE_TARGETS = {
|
||||
"Enchantment": [5, 10],
|
||||
"Planeswalker": [0, 5],
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user