Files
mtgonline/backend/app/services/mtgjson_manager.py
T
akadmin 2c74a107bc Phase 2.1: Card mirror system for deckbuilding features
- Created MtgCardMirror and DeckCardLink models in mirror_models.py
- Created card_mirror_service.py with sync_mirrors functionality
- Added sync_mirrors() method to mtgjson_manager.py
- Added mirror_get_db() dependency to database.py
- Added DecklistFile.status column (DRAUGHT/FINAL)
- Updated DeckCreate schema with status field
- Added DeckWithCardsResponse schema with card_count
- Updated deck router to query card mirrors and return card counts
- Added plain text deck content support
2026-07-22 03:35:57 +00:00

1297 lines
52 KiB
Python

"""
MTGJSON Data Manager - Local File Processing
Reads JSON/PSQL files from a mounted volume and upserts them into PostgreSQL.
Handles AllDeckFiles.zip extraction and processing.
Includes download functionality for container init and manual refresh.
"""
import json
import logging
import zipfile
import tempfile
import shutil
from pathlib import Path
from datetime import datetime
from typing import Optional, Dict, List, Tuple
import re
import aiohttp
import asyncio
import gzip
from sqlalchemy import text
from app.core.database import mtg_async_session
logger = logging.getLogger(__name__)
class MTGJSONManager:
"""Process local MTGJSON files and upsert into PostgreSQL."""
def __init__(self, data_dir: Path = None):
if data_dir is None:
data_dir = Path("/app/data")
if isinstance(data_dir, str):
data_dir = Path(data_dir)
self.data_dir = data_dir
self.data_dir.mkdir(parents=True, exist_ok=True)
async def get_last_refresh(self) -> Optional[datetime]:
"""Get timestamp of last successful refresh."""
async with mtg_async_session() as session:
result = await session.execute(text("""
SELECT refresh_date FROM mtg_refresh_log
WHERE status = 'SUCCESS'
ORDER BY refresh_date DESC
LIMIT 1
"""))
row = result.fetchone()
return row[0] if row else None
async def get_health_status(self) -> dict:
"""Get health status."""
try:
async with mtg_async_session() as session:
result = await session.execute(text("SELECT COUNT(*) FROM mtg_sets"))
sets_count = result.scalar() or 0
result = await session.execute(text("SELECT COUNT(*) FROM mtg_cards"))
cards_count = result.scalar() or 0
files_status = {}
for f in self.data_dir.iterdir():
if f.is_file():
if f.suffix == '.json':
files_status[f.stem + '.json'] = 'OK'
elif f.suffix == '.psql':
files_status[f.stem + '.psql'] = 'OK'
elif f.suffix == '.zip':
files_status[f.stem + '.zip'] = 'OK'
elif f.is_dir():
files_status[f.name] = 'OK'
last_refresh = await self.get_last_refresh()
required = await self.check_required_files()
# App is healthy if:
# 1. Cards and sets are loaded
# 2. All required files are present
is_healthy = (
sets_count > 0 and
cards_count > 0 and
required['all_present']
)
return {
"status": "healthy" if is_healthy else "unhealthy",
"data": {
"sets_count": sets_count,
"cards_count": cards_count,
"last_refresh": last_refresh.isoformat() if last_refresh else None,
"files": files_status,
"required_files": required,
}
}
except Exception as e:
return {"status": "unhealthy", "error": str(e)}
async def check_required_files(self) -> dict:
"""Check if all required files are present."""
available = set()
for item in self.data_dir.iterdir():
if item.is_file():
available.add(item.name)
elif item.is_dir():
available.add(item.name)
required_files = {
'AllPrintings.json': 'AllPrintings.json',
'AllPrintings.psql': 'AllPrintings.psql',
'AllIdentifiers.json': 'AllIdentifiers.json',
'Keywords.json': 'Keywords.json',
'CardTypes.json': 'CardTypes.json',
'AllDeckFiles.zip': 'AllDeckFiles.zip',
}
result = {
'all_present': True,
'missing': [],
'found': [],
'details': {}
}
# Check AllPrintings (either .json or .psql)
if 'AllPrintings.json' in available:
result['details']['AllPrintings'] = 'OK (.json)'
result['found'].append('AllPrintings.json')
elif 'AllPrintings.psql' in available:
result['details']['AllPrintings'] = 'OK (.psql)'
result['found'].append('AllPrintings.psql')
else:
result['details']['AllPrintings'] = 'MISSING'
result['missing'].append('AllPrintings.json or AllPrintings.psql')
result['all_present'] = False
# Check AllIdentifiers.json
if 'AllIdentifiers.json' in available:
result['details']['AllIdentifiers'] = 'OK'
result['found'].append('AllIdentifiers.json')
else:
result['details']['AllIdentifiers'] = 'MISSING'
result['missing'].append('AllIdentifiers.json')
result['all_present'] = False
# Check Keywords.json
if 'Keywords.json' in available:
result['details']['Keywords'] = 'OK'
result['found'].append('Keywords.json')
else:
result['details']['Keywords'] = 'MISSING'
result['missing'].append('Keywords.json')
result['all_present'] = False
# Check CardTypes.json
if 'CardTypes.json' in available:
result['details']['CardTypes'] = 'OK'
result['found'].append('CardTypes.json')
else:
result['details']['CardTypes'] = 'MISSING'
result['missing'].append('CardTypes.json')
result['all_present'] = False
# Check AllDeckFiles.zip
if 'AllDeckFiles.zip' in available:
result['details']['AllDeckFiles'] = 'OK'
result['found'].append('AllDeckFiles.zip')
else:
result['details']['AllDeckFiles'] = 'MISSING'
result['missing'].append('AllDeckFiles.zip')
result['all_present'] = False
return result
async def upsert_all(self) -> dict:
"""Upsert all data from local files."""
counts = {}
# Check what files exist
available = set()
for item in self.data_dir.iterdir():
if item.is_file():
available.add(item.name)
elif item.is_dir():
available.add(item.name)
logger.info(f"Found files: {available}")
# Upsert each file type
if "AllSetFiles" in available:
counts["sets"] = await self._upsert_sets()
# Handle AllPrintings - can be .json or .psql
if "AllPrintings.json" in available:
counts["cards"] = await self._upsert_cards()
elif "AllPrintings.psql" in available:
counts["cards"] = await self._upsert_cards_from_psql()
if "AllIdentifiers.json" in available:
counts["identifiers"] = await self._upsert_identifiers()
if "CardTypes.json" in available:
counts["card_types"] = await self._upsert_card_types()
if "Keywords.json" in available:
counts["keywords"] = await self._upsert_keywords()
if "SetList.json" in available:
counts["set_list"] = await self._upsert_set_list()
# Handle deck files - can be .zip or .json
if "AllDeckFiles.zip" in available:
counts["deck_list"] = await self._upsert_deck_files_from_zip()
elif "DeckList.json" in available:
counts["deck_list"] = await self._upsert_deck_list()
return counts
async def _upsert_sets(self) -> int:
"""Upsert sets from AllSetFiles directory."""
allsetfiles = self.data_dir / "AllSetFiles"
if not allsetfiles.exists():
logger.warning("AllSetFiles directory not found")
return 0
logger.info(f"Processing sets from {allsetfiles}")
count = 0
async with mtg_async_session() as session:
for json_file in sorted(allsetfiles.glob("*.json")):
logger.info(f"Processing {json_file.name}")
try:
with open(json_file, 'r', encoding='utf-8') as f:
data = json.load(f)
if not isinstance(data, dict):
logger.warning(f"{json_file.name} is not a dict")
continue
# Upsert sets
sets = data.get("sets", [])
for set_data in sets:
if not isinstance(set_data, dict):
continue
await session.execute(text("""
INSERT INTO mtg_sets (set_name, code, name, type, release_date,
scryfall_uri, card_count, tokens_count,
is_foil_only, is_non_foil_only)
VALUES (
:set_name, :code, :name, :type, :release_date,
:scryfall_uri, :card_count, :tokens_count,
:is_foil_only, :is_non_foil_only
)
ON CONFLICT (set_name) DO UPDATE SET
code = EXCLUDED.code,
name = EXCLUDED.name,
type = EXCLUDED.type,
release_date = EXCLUDED.release_date,
scryfall_uri = EXCLUDED.scryfall_uri,
card_count = EXCLUDED.card_count,
tokens_count = EXCLUDED.tokens_count,
is_foil_only = EXCLUDED.is_foil_only,
is_non_foil_only = EXCLUDED.is_non_foil_only
"""), {
'set_name': set_data.get('name'),
'code': set_data.get('code'),
'name': set_data.get('name'),
'type': set_data.get('type'),
'release_date': set_data.get('release_date'),
'scryfall_uri': set_data.get('scryfall_uri'),
'card_count': set_data.get('card_count'),
'tokens_count': set_data.get('tokens_count'),
'is_foil_only': set_data.get('is_foil_only'),
'is_non_foil_only': set_data.get('is_non_foil_only'),
})
count += 1
await session.commit()
logger.info(f"Upserted {count} sets from {json_file.name}")
except Exception as e:
logger.error(f"Failed to process {json_file.name}: {e}")
await session.rollback()
continue
return count
async def _upsert_cards(self) -> int:
"""Upsert cards from AllPrintings.json."""
path = self.data_dir / "AllPrintings.json"
if not path.exists():
logger.warning("AllPrintings.json not found")
return 0
logger.info(f"Processing cards from {path}")
count = 0
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
cards = data.get("cards", [])
async with mtg_async_session() as session:
for card in cards:
if not isinstance(card, dict):
continue
try:
await session.execute(text("""
INSERT INTO mtg_cards (
card_name, mtgo_id, card_type, set_name,
rarity, artist, number, language, mana_cost,
text, power, toughness, loyalty, colors,
color_identity, produced_mana, legalities,
original_type, foreign_data, rulings,
hand_modifier, life_modifier, side_names
)
VALUES (
:card_name, :mtgo_id, :card_type, :set_name,
:rarity, :artist, :number, :language, :mana_cost,
:text, :power, :toughness, :loyalty, :colors,
:color_identity, :produced_mana, :legalities,
:original_type, :foreign_data, :rulings,
:hand_modifier, :life_modifier, :side_names
)
ON CONFLICT (card_name, set_name, mtgo_id) DO UPDATE SET
card_type = EXCLUDED.card_type,
rarity = EXCLUDED.rarity,
artist = EXCLUDED.artist,
number = EXCLUDED.number,
language = EXCLUDED.language,
mana_cost = EXCLUDED.mana_cost,
text = EXCLUDED.text,
power = EXCLUDED.power,
toughness = EXCLUDED.toughness,
loyalty = EXCLUDED.loyalty,
colors = EXCLUDED.colors,
color_identity = EXCLUDED.color_identity,
produced_mana = EXCLUDED.produced_mana,
legalities = EXCLUDED.legalities,
original_type = EXCLUDED.original_type,
foreign_data = EXCLUDED.foreign_data,
rulings = EXCLUDED.rulings,
hand_modifier = EXCLUDED.hand_modifier,
life_modifier = EXCLUDED.life_modifier,
side_names = EXCLUDED.side_names
"""), {
'card_name': card.get('name'),
'mtgo_id': card.get('mtgoId'),
'card_type': card.get('type'),
'set_name': card.get('setName'),
'rarity': card.get('rarity'),
'artist': card.get('artist'),
'number': card.get('number'),
'language': card.get('language'),
'mana_cost': json.dumps(card.get('manaCost')),
'text': card.get('text'),
'power': card.get('power'),
'toughness': card.get('toughness'),
'loyalty': card.get('loyalty'),
'colors': json.dumps(card.get('colors')),
'color_identity': json.dumps(card.get('colorIdentity')),
'produced_mana': json.dumps(card.get('producedMana')),
'legalities': json.dumps(card.get('legalities')),
'original_type': card.get('originalType'),
'foreign_data': json.dumps(card.get('foreignData')),
'rulings': json.dumps(card.get('rulings')),
'hand_modifier': card.get('handModifier'),
'life_modifier': card.get('lifeModifier'),
'side_names': json.dumps(card.get('sideNames')),
})
count += 1
except Exception as e:
logger.error(f"Failed to upsert card: {e}")
await session.rollback()
continue
logger.info(f"Upserted {count} cards")
return count
async def _upsert_cards_from_psql(self) -> int:
"""Upsert cards from AllPrintings.psql file.
PSQL files contain SQL INSERT statements. We parse them to extract card data.
"""
path = self.data_dir / "AllPrintings.psql"
if not path.exists():
logger.warning("AllPrintings.psql not found")
return 0
logger.info(f"Processing cards from PSQL file: {path}")
cards = self._parse_psql_file(path)
async with mtg_async_session() as session:
count = 0
for card in cards:
if not isinstance(card, dict):
continue
try:
await session.execute(text("""
INSERT INTO mtg_cards (
card_name, mtgo_id, card_type, set_name,
rarity, artist, number, language, mana_cost,
text, power, toughness, loyalty, colors,
color_identity, produced_mana, legalities,
original_type, foreign_data, rulings,
hand_modifier, life_modifier, side_names
)
VALUES (
:card_name, :mtgo_id, :card_type, :set_name,
:rarity, :artist, :number, :language, :mana_cost,
:text, :power, :toughness, :loyalty, :colors,
:color_identity, :produced_mana, :legalities,
:original_type, :foreign_data, :rulings,
:hand_modifier, :life_modifier, :side_names
)
ON CONFLICT (card_name, set_name, mtgo_id) DO UPDATE SET
card_type = EXCLUDED.card_type,
rarity = EXCLUDED.rarity,
artist = EXCLUDED.artist,
number = EXCLUDED.number,
language = EXCLUDED.language,
mana_cost = EXCLUDED.mana_cost,
text = EXCLUDED.text,
power = EXCLUDED.power,
toughness = EXCLUDED.toughness,
loyalty = EXCLUDED.loyalty,
colors = EXCLUDED.colors,
color_identity = EXCLUDED.color_identity,
produced_mana = EXCLUDED.produced_mana,
legalities = EXCLUDED.legalities,
original_type = EXCLUDED.original_type,
foreign_data = EXCLUDED.foreign_data,
rulings = EXCLUDED.rulings,
hand_modifier = EXCLUDED.hand_modifier,
life_modifier = EXCLUDED.life_modifier,
side_names = EXCLUDED.side_names
"""), {
'card_name': card.get('name'),
'mtgo_id': card.get('mtgoId'),
'card_type': card.get('type'),
'set_name': card.get('setName'),
'rarity': card.get('rarity'),
'artist': card.get('artist'),
'number': card.get('number'),
'language': card.get('language'),
'mana_cost': json.dumps(card.get('manaCost')),
'text': card.get('text'),
'power': card.get('power'),
'toughness': card.get('toughness'),
'loyalty': card.get('loyalty'),
'colors': json.dumps(card.get('colors')),
'color_identity': json.dumps(card.get('colorIdentity')),
'produced_mana': json.dumps(card.get('producedMana')),
'legalities': json.dumps(card.get('legalities')),
'original_type': card.get('originalType'),
'foreign_data': json.dumps(card.get('foreignData')),
'rulings': json.dumps(card.get('rulings')),
'hand_modifier': card.get('handModifier'),
'life_modifier': card.get('lifeModifier'),
'side_names': json.dumps(card.get('sideNames')),
})
count += 1
except Exception as e:
logger.error(f"Failed to upsert card: {e}")
continue
await session.commit()
logger.info(f"Upserted {count} cards from PSQL")
return count
def _parse_psql_file(self, filepath: Path) -> list:
"""Parse a PSQL file containing SQL INSERT statements to extract card data."""
cards = []
try:
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
# Find all INSERT INTO mtg_cards statements
insert_pattern = r"INSERT INTO mtg_cards\s*\(([^)]+)\)\s*VALUES\s*\(([^;]+)\);"
matches = re.findall(insert_pattern, content, re.IGNORECASE | re.DOTALL)
for columns_str, values_str in matches:
# Parse column names
columns = [col.strip().strip("'\"") for col in columns_str.split(',')]
# Parse values (simplified parser)
values = self._parse_sql_values(values_str)
if len(columns) == len(values):
card = dict(zip(columns, values))
# Try to parse JSON fields
json_fields = ['manaCost', 'colors', 'colorIdentity', 'producedMana',
'legalities', 'foreignData', 'rulings', 'sideNames']
for field in json_fields:
if field in card and isinstance(card[field], str):
try:
card[field] = json.loads(card[field])
except:
pass
cards.append(card)
except Exception as e:
logger.error(f"Error parsing PSQL file {filepath}: {e}")
return cards
def _parse_sql_values(self, values_str: str) -> list:
"""Parse SQL VALUES clause to extract individual values."""
values = []
current_value = ""
in_single_quote = False
in_double_quote = False
escape_next = False
for char in values_str:
if escape_next:
current_value += char
escape_next = False
continue
if char == '\\' and not in_single_quote:
escape_next = True
continue
if char == "'" and not in_double_quote:
in_single_quote = not in_single_quote
current_value += char
continue
if char == '"' and not in_single_quote:
in_double_quote = not in_double_quote
current_value += char
continue
if char == ',' and not in_single_quote and not in_double_quote:
values.append(self._clean_sql_value(current_value.strip()))
current_value = ""
continue
current_value += char
# Add last value
if current_value.strip():
values.append(self._clean_sql_value(current_value.strip()))
return values
def _clean_sql_value(self, value: str) -> str:
"""Clean and parse a SQL value."""
value = value.strip()
# Remove quotes
if (value.startswith("'") and value.endswith("'")) or \
(value.startswith('"') and value.endswith('"')):
value = value[1:-1]
# Check for NULL
if value.upper() == "NULL":
return None
# Try to parse as JSON
if value.startswith("{") or value.startswith("["):
try:
return json.loads(value.replace("NULL", "null"))
except:
pass
return value
async def _upsert_identifiers(self) -> int:
"""Upsert identifiers from AllIdentifiers.json."""
path = self.data_dir / "AllIdentifiers.json"
if not path.exists():
logger.warning("AllIdentifiers.json not found")
return 0
logger.info(f"Processing identifiers from {path}")
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
identifiers = data.get("identifiers", {})
async with mtg_async_session() as session:
count = 0
for identifier_id, identifier in identifiers.items():
if not isinstance(identifier, dict):
continue
try:
await session.execute(text("""
INSERT INTO mtg_identifiers (identifier_id, identifier_data, created_at)
VALUES (:identifier_id, :identifier_data, CURRENT_TIMESTAMP)
ON CONFLICT (identifier_id) DO UPDATE SET
identifier_data = EXCLUDED.identifier_data,
updated_at = CURRENT_TIMESTAMP
"""), {
'identifier_id': identifier_id,
'identifier_data': json.dumps(identifier),
})
count += 1
except Exception as e:
logger.error(f"Failed to upsert identifier {identifier_id}: {e}")
continue
await session.commit()
logger.info(f"Upserted {count} identifiers")
return count
async def _upsert_card_types(self) -> int:
"""Upsert card types from CardTypes.json."""
path = self.data_dir / "CardTypes.json"
if not path.exists():
logger.warning("CardTypes.json not found")
return 0
logger.info("Processing card types")
with open(path, 'r', encoding='utf-8') as f:
card_types = json.load(f)
async with mtg_async_session() as session:
count = 0
for card_type in card_types:
if not isinstance(card_type, dict):
continue
try:
await session.execute(text("""
INSERT INTO mtg_card_types (card_type)
VALUES (:card_type)
ON CONFLICT (card_type) DO NOTHING
"""), {
'card_type': card_type.get('cardType'),
})
count += 1
except Exception as e:
logger.error(f"Failed to upsert card type: {e}")
continue
await session.commit()
logger.info(f"Upserted {count} card types")
return count
async def _upsert_keywords(self) -> int:
"""Upsert keywords from Keywords.json."""
path = self.data_dir / "Keywords.json"
if not path.exists():
logger.warning("Keywords.json not found")
return 0
logger.info("Processing keywords")
with open(path, 'r', encoding='utf-8') as f:
keywords = json.load(f)
async with mtg_async_session() as session:
count = 0
for keyword in keywords:
if not isinstance(keyword, dict):
continue
try:
await session.execute(text("""
INSERT INTO mtg_keywords (keyword)
VALUES (:keyword)
ON CONFLICT (keyword) DO NOTHING
"""), {
'keyword': keyword.get('keyword'),
})
count += 1
except Exception as e:
logger.error(f"Failed to upsert keyword: {e}")
continue
await session.commit()
logger.info(f"Upserted {count} keywords")
return count
async def _upsert_set_list(self) -> int:
"""Upsert set list from SetList.json."""
path = self.data_dir / "SetList.json"
if not path.exists():
logger.warning("SetList.json not found")
return 0
logger.info("Processing set list")
with open(path, 'r', encoding='utf-8') as f:
set_list = json.load(f)
async with mtg_async_session() as session:
count = 0
for item in set_list:
if not isinstance(item, dict):
continue
try:
await session.execute(text("""
INSERT INTO mtg_set_list (set_name, code, release_date, scryfall_uri)
VALUES (:set_name, :code, :release_date, :scryfall_uri)
ON CONFLICT (set_name) DO UPDATE SET
code = EXCLUDED.code,
release_date = EXCLUDED.release_date,
scryfall_uri = EXCLUDED.scryfall_uri
"""), {
'set_name': item.get('name'),
'code': item.get('code'),
'release_date': item.get('release_date'),
'scryfall_uri': item.get('scryfall_uri'),
})
count += 1
except Exception as e:
logger.error(f"Failed to upsert set list item: {e}")
continue
await session.commit()
logger.info(f"Upserted {count} set list items")
return count
async def _upsert_deck_list(self) -> int:
"""Upsert deck list from DeckList.json."""
path = self.data_dir / "DeckList.json"
if not path.exists():
logger.warning("DeckList.json not found")
return 0
logger.info("Processing deck list")
with open(path, 'r', encoding='utf-8') as f:
deck_list = json.load(f)
async with mtg_async_session() as session:
count = 0
for item in deck_list:
if not isinstance(item, dict):
continue
try:
await session.execute(text("""
INSERT INTO mtg_deck_list (list_id, list_name, list_year, list_date, list_format)
VALUES (:list_id, :list_name, :list_year, :list_date, :list_format)
ON CONFLICT (list_id) DO UPDATE SET
list_name = EXCLUDED.list_name,
list_year = EXCLUDED.list_year,
list_date = EXCLUDED.list_date,
list_format = EXCLUDED.list_format
"""), {
'list_id': item.get('listId'),
'list_name': item.get('listName'),
'list_year': item.get('listYear'),
'list_date': item.get('listDate'),
'list_format': item.get('listFormat'),
})
count += 1
except Exception as e:
logger.error(f"Failed to upsert deck list item: {e}")
continue
await session.commit()
logger.info(f"Upserted {count} deck list items")
return count
async def _upsert_deck_files_from_zip(self) -> int:
"""Unzip and upsert deck files from AllDeckFiles.zip."""
zip_path = self.data_dir / "AllDeckFiles.zip"
if not zip_path.exists():
logger.warning("AllDeckFiles.zip not found")
return 0
logger.info(f"Processing deck files from zip: {zip_path}")
json_files = self._extract_zip_files(zip_path)
async with mtg_async_session() as session:
count = 0
for json_file in json_files:
logger.info(f"Processing deck file: {json_file.name}")
try:
with open(json_file, 'r', encoding='utf-8') as f:
deck_data = json.load(f)
if not isinstance(deck_data, dict):
logger.warning(f"{json_file.name} is not a dict")
continue
# Upsert deck
await session.execute(text("""
INSERT INTO mtg_deck_list (list_id, list_name, list_year, list_date, list_format)
VALUES (
:list_id, :list_name, :list_year, :list_date, :list_format
)
ON CONFLICT (list_id) DO UPDATE SET
list_name = EXCLUDED.list_name,
list_year = EXCLUDED.list_year,
list_date = EXCLUDED.list_date,
list_format = EXCLUDED.list_format
"""), {
'list_id': deck_data.get('listId', str(hash(str(deck_data)))),
'list_name': deck_data.get('name', json_file.stem),
'list_year': deck_data.get('year', 'unknown'),
'list_date': deck_data.get('date', 'unknown'),
'list_format': deck_data.get('format', 'unknown'),
})
count += 1
except Exception as e:
logger.error(f"Failed to upsert deck file {json_file.name}: {e}")
continue
await session.commit()
logger.info(f"Upserted {count} deck files from zip")
return count
def _extract_zip_files(self, zip_path: Path) -> list:
"""Extract JSON files from a ZIP archive."""
json_files = []
try:
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
# Create temporary directory for extraction
with tempfile.TemporaryDirectory() as temp_dir:
zip_ref.extractall(temp_dir)
# Find all JSON files recursively
for file_path in Path(temp_dir).rglob("*.json"):
json_files.append(file_path)
except Exception as e:
logger.error(f"Error extracting zip file {zip_path}: {e}")
return json_files
async def log_refresh(self, status: str, counts: dict[str, int], duration: int, error: str = None):
"""Log refresh operation."""
async with mtg_async_session() as session:
stmt = text("""
INSERT INTO mtg_refresh_log (refresh_date, status, cards_updated, sets_updated,
identifiers_size, card_types_size, keywords_size,
sets_list_count, deck_list_count,
error_message, duration_seconds)
VALUES (CURRENT_TIMESTAMP, :status, :cards, :sets,
:identifiers, :card_types, :keywords,
:sets_list, :deck_list,
:error, :duration)
""")
await session.execute(stmt, {
'status': status,
'cards': counts.get('cards', 0),
'sets': counts.get('sets', 0),
'identifiers': counts.get('identifiers', 0),
'card_types': counts.get('card_types', 0),
'keywords': counts.get('keywords', 0),
'sets_list': counts.get('set_list', 0),
'deck_list': counts.get('deck_list', 0),
'error': error,
'duration': duration,
})
async def sync_mirrors(self):
"""Sync mirrored cards from mtg_cards to mtg_cards_mirror.
This should be called after a successful refresh to ensure
the mirror table is up-to-date with the latest card data.
"""
from sqlalchemy import insert, update
logger.info("Syncing card mirrors...")
async with mtg_async_session() as session:
# Get all cards from mtg_cards
cards_stmt = text("""
SELECT id, name, mana_cost, type_line, oracle_text, power, toughness,
rarity, layout, artist, flavor_text, numbers, identifiers, images,
image, card_parts, keywords, legalities, set_code, set_name
FROM mtg_cards
""")
result = await session.execute(cards_stmt)
cards = result.fetchall()
synced_count = 0
for card in cards:
# Upsert into mtg_cards_mirror
await session.execute(text("""
INSERT INTO mtg_cards_mirror (source_id, name, mana_cost, type_line, oracle_text,
power, toughness, rarity, layout, artist, flavor_text,
numbers, identifiers, images, image, card_parts,
keywords, legalities, set_code, set_name)
VALUES (:source_id, :name, :mana_cost, :type_line, :oracle_text,
:power, :toughness, :rarity, :layout, :artist, :flavor_text,
:numbers, :identifiers, :images, :image, :card_parts,
:keywords, :legalities, :set_code, :set_name)
ON CONFLICT (name) DO UPDATE SET
mana_cost = EXCLUDED.mana_cost,
type_line = EXCLUDED.type_line,
oracle_text = EXCLUDED.oracle_text,
power = EXCLUDED.power,
toughness = EXCLUDED.toughness,
rarity = EXCLUDED.rarity,
layout = EXCLUDED.layout,
artist = EXCLUDED.artist,
flavor_text = EXCLUDED.flavor_text,
numbers = EXCLUDED.numbers,
identifiers = EXCLUDED.identifiers,
images = EXCLUDED.images,
image = EXCLUDED.image,
card_parts = EXCLUDED.card_parts,
keywords = EXCLUDED.keywords,
legalities = EXCLUDED.legalities,
set_code = EXCLUDED.set_code,
set_name = EXCLUDED.set_name,
synced_at = CURRENT_TIMESTAMP
"""), {
'source_id': card[0],
'name': card[1],
'mana_cost': card[2],
'type_line': card[3],
'oracle_text': card[4],
'power': card[5],
'toughness': card[6],
'rarity': card[7],
'layout': card[8],
'artist': card[9],
'flavor_text': card[10],
'numbers': card[11],
'identifiers': card[12],
'images': card[13],
'image': card[14],
'card_parts': card[15],
'keywords': card[16],
'legalities': card[17],
'set_code': card[18],
'set_name': card[19],
})
synced_count += 1
await session.commit()
logger.info(f"Synced {synced_count} card mirrors")
return synced_count
async def run_refresh(self) -> bool:
"""Run complete refresh cycle from local files."""
logger.info("Starting local file refresh")
try:
import time
start_time = time.time()
# Upsert data
counts = await self.upsert_all()
# Log refresh
duration = int(time.time() - start_time)
status = "SUCCESS"
await self.log_refresh(status, counts, duration)
logger.info(f"Refresh complete in {duration}s: {counts}")
return True
except Exception as e:
logger.error(f"Refresh failed: {e}")
await self.log_refresh("FAILED", {}, 0, str(e))
return False
async def download_files(self, force: bool = False, max_retries: int = 3) -> dict:
"""
Download MTGJSON files from the API.
Args:
force: Force re-download even if files exist
max_retries: Maximum number of retry attempts
Returns:
Dictionary with download results
"""
logger.info("Starting file download from MTGJSON API")
# Check which files need downloading
files_to_download = []
for filename in self._get_file_urls().keys():
file_path = self.data_dir / filename
if force or not file_path.exists():
files_to_download.append(filename)
else:
logger.info(f"Skipping {filename} (already exists)")
if not files_to_download:
logger.info("All files already downloaded")
return {"success": True, "downloaded": [], "skipped": list(self._get_file_urls().keys())}
# Download files
results = {}
async with aiohttp.ClientSession() as session:
for filename in files_to_download:
success, message = await self._download_single_file(
session, filename, max_retries
)
results[filename] = {"success": success, "message": message}
return results
async def _download_single_file(
self,
session: aiohttp.ClientSession,
filename: str,
max_retries: int
) -> Tuple[bool, str]:
"""Download a single file with retry logic."""
url = self._get_file_urls()[filename]["url"]
decompress = self._get_file_urls()[filename]["decompress"]
for attempt in range(max_retries):
try:
logger.info(f"Downloading {filename} (attempt {attempt + 1}/{max_retries})")
async with session.get(url, timeout=aiohttp.ClientTimeout(total=3600)) as response:
if response.status != 200:
error_msg = f"HTTP {response.status} for {filename}"
logger.error(error_msg)
if attempt < max_retries - 1:
await asyncio.sleep(5)
continue
return False, error_msg
# Write to temp file
temp_path = self.data_dir / f".{filename}.tmp"
with open(temp_path, 'wb') as f:
async for chunk in response.content.iter_chunked(8192):
f.write(chunk)
# Move to final location
final_path = self.data_dir / filename
temp_path.rename(final_path)
# Decompress if needed
if decompress:
await self._decompress_file(final_path)
logger.info(f"Successfully downloaded {filename}")
return True, "Download complete"
except Exception as e:
error_msg = f"Error downloading {filename}: {str(e)}"
logger.error(error_msg)
if attempt < max_retries - 1:
await asyncio.sleep(5)
continue
return False, error_msg
return False, f"Failed after {max_retries} attempts"
def _get_file_urls(self) -> dict:
"""Get URL configuration for MTGJSON files."""
return {
"AllPrintings.psql": {
"url": "https://mtgjson.com/api/v5/AllPrintings.psql",
"decompress": False,
"description": "AllPrintings.psql"
},
"AllIdentifiers.json": {
"url": "https://mtgjson.com/api/v5/AllIdentifiers.json.gz",
"decompress": True,
"description": "AllIdentifiers.json"
},
"Keywords.json": {
"url": "https://mtgjson.com/api/v5/Keywords.json.gz",
"decompress": True,
"description": "Keywords.json"
},
"CardTypes.json": {
"url": "https://mtgjson.com/api/v5/CardTypes.json.gz",
"decompress": True,
"description": "CardTypes.json"
},
"AllDeckFiles.zip": {
"url": "https://mtgjson.com/api/v5/AllDeckFiles.zip",
"decompress": False,
"description": "AllDeckFiles.zip"
},
}
async def _decompress_file(self, filepath: Path) -> None:
"""Decompress a gzip file, detecting the target extension from URL config."""
logger.info(f"Decompressing {filepath.name}")
# Determine target extension based on filename
if filepath.name.endswith('.json.gz'):
target_path = filepath.with_suffix('.json')
elif filepath.name.endswith('.psql.gz'):
target_path = filepath.with_suffix('.psql')
else:
# Default: remove .gz extension
target_path = filepath.with_suffix('')
with gzip.open(filepath, 'rb') as f_in:
with open(target_path, 'wb') as f_out:
f_out.write(f_in.read())
filepath.unlink()
target_path.rename(filepath)
logger.info(f"Decompressed {filepath.name} -> {target_path.name}")
async def verify_files(self) -> Tuple[bool, List[str]]:
"""
Verify that all required files exist and are valid.
Returns:
Tuple of (all_valid, list_of_errors)
"""
errors = []
for filename in self._get_file_urls().keys():
file_path = self.data_dir / filename
if not file_path.exists():
errors.append(f"Missing required file: {filename}")
continue
# Check file size (basic sanity check)
size = file_path.stat().st_size
if size == 0:
errors.append(f"Empty file: {filename}")
continue
# Verify JSON files
if filename.endswith('.json'):
try:
with open(file_path, 'r') as f:
json.load(f)
except Exception as e:
errors.append(f"Invalid JSON in {filename}: {str(e)}")
# Verify ZIP files
if filename.endswith('.zip'):
try:
with zipfile.ZipFile(file_path, 'r') as zf:
zf.testzip()
except Exception as e:
errors.append(f"Invalid ZIP file {filename}: {str(e)}")
# Verify SQL files
if filename.endswith('.psql'):
with open(file_path, 'r') as f:
content = f.read(1024)
if not any(kw in content.upper() for kw in ['INSERT', 'CREATE', 'BEGIN']):
errors.append(f"File {filename} doesn't appear to contain SQL")
all_valid = len(errors) == 0
return all_valid, errors
async def download_and_refresh(self, force: bool = False) -> dict:
"""
Download files and upsert data in one operation.
Args:
force: Force re-download
Returns:
Dictionary with download and upsert results
"""
import time
start_time = time.time()
result = {
"download": None,
"upsert": None,
"success": False,
"duration": None
}
try:
# Download files
logger.info("Starting download and refresh cycle")
download_result = await self.download_files(force=force)
result["download"] = download_result
# Check if download was successful
if not all(r["success"] for r in download_result.values()):
failed_files = [f for f, r in download_result.items() if not r["success"]]
error_msg = f"Download failed for: {', '.join(failed_files)}"
logger.error(error_msg)
await self.log_refresh("FAILED", {}, 0, error_msg)
result["error"] = error_msg
result["duration"] = int(time.time() - start_time)
return result
# Upsert data
upsert_result = await self.upsert_all()
result["upsert"] = upsert_result
# Log refresh
duration = int(time.time() - start_time)
result["success"] = True
result["duration"] = duration
await self.log_refresh("SUCCESS", upsert_result, duration)
logger.info(f"Download and refresh complete in {duration}s: {upsert_result}")
return result
except Exception as e:
logger.error(f"Download and refresh failed: {e}")
duration = int(time.time() - start_time)
await self.log_refresh("FAILED", {}, duration, str(e))
result["error"] = str(e)
result["duration"] = duration
return result
# Singleton instance
_manager_instance = None
def get_manager() -> MTGJSONManager:
"""Get or create MTGJSON manager instance."""
global _manager_instance
if _manager_instance is None:
_manager_instance = MTGJSONManager()
return _manager_instance
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="MTGJSON Data Manager")
parser.add_argument("--refresh", action="store_true", help="Run refresh cycle")
parser.add_argument("--download", action="store_true", help="Download files")
parser.add_argument("--force", action="store_true", help="Force re-download")
parser.add_argument("--data-dir", type=str, default="/app/data", help="Data directory")
args = parser.parse_args()
async def main():
manager = MTGJSONManager(Path(args.data_dir))
if args.refresh:
success = await manager.run_refresh()
if success:
print("Refresh completed successfully")
else:
print("Refresh failed")
exit(1)
elif args.download:
result = await manager.download_files(force=args.force)
print(f"Download result: {result}")
else:
print("Usage: python -m app.services.mtgjson_manager [--refresh | --download]")
asyncio.run(main())