Fix MTGJSON download functionality
- Add refresh router with admin-only endpoints - Fix _decompress_file to handle .psql files correctly - Fix duplicate imports in mtgjson_manager.py - Mount refresh router in main.py - Add download_mtgjson.py standalone script - Add SUPPORTED_FILE_TYPES.md documentation
This commit is contained in:
@@ -0,0 +1,101 @@
|
|||||||
|
# MTGJSON Data Manager - File Type Support
|
||||||
|
|
||||||
|
## Supported File Types
|
||||||
|
|
||||||
|
### 1. AllPrintings (Primary Card Database)
|
||||||
|
- **Accepts:** `AllPrintings.json` OR `AllPrintings.psql`
|
||||||
|
- **Processing:**
|
||||||
|
- `.json`: Parses JSON structure, extracts card data from `cards` array
|
||||||
|
- `.psql`: Parses SQL INSERT statements to extract card data
|
||||||
|
- **Database:** `mtg_cards` table
|
||||||
|
- **Required:** Yes (one of the two formats)
|
||||||
|
|
||||||
|
### 2. AllIdentifiers (Stable Card Referencing)
|
||||||
|
- **Accepts:** `AllIdentifiers.json`
|
||||||
|
- **Processing:** Parses JSON structure, extracts identifiers
|
||||||
|
- **Database:** `mtg_identifiers` table
|
||||||
|
- **Required:** Yes
|
||||||
|
|
||||||
|
### 3. Keywords & CardTypes (Game Logic/Mechanics)
|
||||||
|
- **Accepts:** `Keywords.json` and `CardTypes.json`
|
||||||
|
- **Processing:** Parses JSON arrays
|
||||||
|
- **Database:** `mtg_keywords` and `mtg_card_types` tables
|
||||||
|
- **Required:** Yes (both)
|
||||||
|
|
||||||
|
### 4. AllDeckFiles (Deck Format Testing)
|
||||||
|
- **Accepts:** `AllDeckFiles.zip`
|
||||||
|
- **Processing:**
|
||||||
|
1. Unzips the archive
|
||||||
|
2. Finds all `.json` files recursively
|
||||||
|
3. Parses each JSON file
|
||||||
|
4. Upserts deck data
|
||||||
|
- **Database:** `mtg_deck_list` table
|
||||||
|
- **Required:** Yes
|
||||||
|
|
||||||
|
## File Validation
|
||||||
|
|
||||||
|
The health check endpoint now validates that all required files are present:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"data": {
|
||||||
|
"required_files": {
|
||||||
|
"all_present": true,
|
||||||
|
"found": ["AllPrintings.json", "AllIdentifiers.json", "Keywords.json", "CardTypes.json", "AllDeckFiles.zip"],
|
||||||
|
"missing": [],
|
||||||
|
"details": {
|
||||||
|
"AllPrintings": "OK (.json)",
|
||||||
|
"AllIdentifiers": "OK",
|
||||||
|
"Keywords": "OK",
|
||||||
|
"CardTypes": "OK",
|
||||||
|
"AllDeckFiles": "OK"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## PSQL File Parsing
|
||||||
|
|
||||||
|
For `AllPrintings.psql`, the manager:
|
||||||
|
1. Reads the SQL file
|
||||||
|
2. Extracts `INSERT INTO mtg_cards (...) VALUES (...)` statements using regex
|
||||||
|
3. Parses column names and values
|
||||||
|
4. Handles NULL values, quoted strings, and JSON arrays
|
||||||
|
5. Converts parsed data into card objects for upsert
|
||||||
|
|
||||||
|
## Deck File ZIP Processing
|
||||||
|
|
||||||
|
For `AllDeckFiles.zip`:
|
||||||
|
1. Extracts to temporary directory
|
||||||
|
2. Recursively finds all `.json` files
|
||||||
|
3. Parses each file expecting deck format:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"listId": "...",
|
||||||
|
"name": "...",
|
||||||
|
"year": "...",
|
||||||
|
"date": "...",
|
||||||
|
"format": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. Upserts each deck into the database
|
||||||
|
5. Cleans up temporary files
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Validates file existence before processing
|
||||||
|
- Logs warnings for missing files
|
||||||
|
- Handles malformed JSON/PSQL gracefully
|
||||||
|
- Rolls back transactions on individual card failures
|
||||||
|
- Continues processing remaining files on errors
|
||||||
|
|
||||||
|
## Health Check
|
||||||
|
|
||||||
|
The app is considered "healthy" when:
|
||||||
|
1. Cards and sets are loaded in database (>0)
|
||||||
|
2. All required files are present in the mounted volume
|
||||||
|
3. No critical processing errors occurred
|
||||||
|
|
||||||
|
If any required file is missing, the health status shows "unhealthy" with details about what's missing.
|
||||||
+11
-12
@@ -24,7 +24,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from app.core.settings import get_settings
|
from app.core.settings import get_settings
|
||||||
from app.core.database import engine, mtg_engine, async_session, mtg_async_session
|
from app.core.database import engine, mtg_engine, async_session, mtg_async_session
|
||||||
from app.routers import auth, users, decks, rooms, games, admin, card_router, interactions
|
from app.routers import auth, users, decks, rooms, games, admin, card_router, interactions, refresh
|
||||||
from app.services.mtgjson_manager import MTGJSONManager
|
from app.services.mtgjson_manager import MTGJSONManager
|
||||||
|
|
||||||
|
|
||||||
@@ -65,20 +65,18 @@ async def run_initial_download():
|
|||||||
logger.info("Running initial MTGJSON data download with sanity checks...")
|
logger.info("Running initial MTGJSON data download with sanity checks...")
|
||||||
logger.info("This may take several minutes depending on network speed...")
|
logger.info("This may take several minutes depending on network speed...")
|
||||||
|
|
||||||
# Download files with sanity checking and retry logic
|
# Download files and upsert data in one operation
|
||||||
success = await manager.download_with_sanity_check()
|
result = await manager.download_and_refresh(force=False)
|
||||||
if not success:
|
|
||||||
logger.error("Failed to download MTGJSON files after retries - marking container unhealthy")
|
|
||||||
raise RuntimeError("MTGJSON data download failed after all retry attempts")
|
|
||||||
|
|
||||||
# Unpack files
|
if not result.get("success", False):
|
||||||
await manager.unpack_files()
|
error_msg = result.get("error", "Unknown error")
|
||||||
|
logger.error(f"Failed to download MTGJSON files: {error_msg}")
|
||||||
# Upsert data
|
raise RuntimeError(f"MTGJSON data download failed: {error_msg}")
|
||||||
counts = await manager.upsert_data()
|
|
||||||
|
|
||||||
# Log success
|
# Log success
|
||||||
await manager.log_refresh("SUCCESS", counts, 0)
|
counts = result.get("upsert", {})
|
||||||
|
await manager.log_refresh("SUCCESS", counts, result.get("duration", 0))
|
||||||
|
|
||||||
logger.info(f"Initial MTGJSON data load complete!")
|
logger.info(f"Initial MTGJSON data load complete!")
|
||||||
logger.info(f" Sets: {counts.get('sets', 0)}")
|
logger.info(f" Sets: {counts.get('sets', 0)}")
|
||||||
logger.info(f" Cards: {counts.get('cards', 0)}")
|
logger.info(f" Cards: {counts.get('cards', 0)}")
|
||||||
@@ -141,6 +139,7 @@ app.include_router(games.router, prefix="/games", tags=["Games"])
|
|||||||
app.include_router(admin.router, prefix="/admin", tags=["Admin"])
|
app.include_router(admin.router, prefix="/admin", tags=["Admin"])
|
||||||
app.include_router(card_router.router, prefix="/api", tags=["MTG Cards"])
|
app.include_router(card_router.router, prefix="/api", tags=["MTG Cards"])
|
||||||
app.include_router(interactions.router, tags=["Card Interactions"])
|
app.include_router(interactions.router, tags=["Card Interactions"])
|
||||||
|
app.include_router(refresh.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health", tags=["Health"])
|
@app.get("/health", tags=["Health"])
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.routers import games
|
|||||||
from app.routers import admin
|
from app.routers import admin
|
||||||
from app.routers import card_router
|
from app.routers import card_router
|
||||||
from app.routers import interactions
|
from app.routers import interactions
|
||||||
|
from app.routers import refresh
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"auth",
|
"auth",
|
||||||
@@ -22,4 +23,5 @@ __all__ = [
|
|||||||
"admin",
|
"admin",
|
||||||
"card_router",
|
"card_router",
|
||||||
"interactions",
|
"interactions",
|
||||||
|
"refresh",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""
|
||||||
|
MTGJSON Data Refresh Router
|
||||||
|
|
||||||
|
Provides endpoints for triggering dataset downloads and managing refresh operations.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.services.mtgjson_manager import MTGJSONManager
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/mtgjson", tags=["MTGJSON Data"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh")
|
||||||
|
async def trigger_refresh(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Trigger a refresh of MTGJSON datasets.
|
||||||
|
|
||||||
|
Downloads files from MTGJSON API and upserts data into PostgreSQL.
|
||||||
|
Requires Admin privileges.
|
||||||
|
|
||||||
|
The refresh runs in the background and may take several minutes.
|
||||||
|
"""
|
||||||
|
# Check if current user is admin
|
||||||
|
if current_user.get("privlevel") != "Admin":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Admin privileges required",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create manager and trigger refresh in background
|
||||||
|
manager = MTGJSONManager()
|
||||||
|
background_tasks.add_task(manager.download_and_refresh, force=False)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Refresh initiated in background",
|
||||||
|
"status": "started",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def get_refresh_status(current_user: dict = Depends(get_current_user)):
|
||||||
|
"""
|
||||||
|
Get current refresh status and data health.
|
||||||
|
|
||||||
|
Returns information about:
|
||||||
|
- Last successful refresh timestamp
|
||||||
|
- Current data counts
|
||||||
|
- File status
|
||||||
|
- Required files status
|
||||||
|
|
||||||
|
Requires Admin privileges.
|
||||||
|
"""
|
||||||
|
# Check if current user is admin
|
||||||
|
if current_user.get("privlevel") != "Admin":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Admin privileges required",
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = MTGJSONManager()
|
||||||
|
return await manager.get_health_status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/verify")
|
||||||
|
async def verify_files(current_user: dict = Depends(get_current_user)):
|
||||||
|
"""
|
||||||
|
Verify the integrity of downloaded MTGJSON files.
|
||||||
|
|
||||||
|
Checks that all required files exist and are valid.
|
||||||
|
Requires Admin privileges.
|
||||||
|
"""
|
||||||
|
# Check if current user is admin
|
||||||
|
if current_user.get("privlevel") != "Admin":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Admin privileges required",
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = MTGJSONManager()
|
||||||
|
all_valid, errors = await manager.verify_files()
|
||||||
|
|
||||||
|
if not all_valid:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={
|
||||||
|
"valid": False,
|
||||||
|
"errors": errors,
|
||||||
|
"message": "Some files failed verification",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": True,
|
||||||
|
"message": "All files verified successfully",
|
||||||
|
}
|
||||||
@@ -1,34 +1,29 @@
|
|||||||
"""
|
"""
|
||||||
MTGJSON Data Manager - Local File Processing
|
MTGJSON Data Manager - Local File Processing
|
||||||
|
|
||||||
Reads JSON files from a mounted volume and upserts them into PostgreSQL.
|
Reads JSON/PSQL files from a mounted volume and upserts them into PostgreSQL.
|
||||||
No network downloads - users provide the files.
|
Handles AllDeckFiles.zip extraction and processing.
|
||||||
|
Includes download functionality for container init and manual refresh.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import zipfile
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional, Dict, List, Tuple
|
||||||
|
import re
|
||||||
|
import aiohttp
|
||||||
|
import asyncio
|
||||||
|
import gzip
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app.core.database import mtg_async_session
|
from app.core.database import mtg_async_session
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Expected files to process
|
|
||||||
EXPECTED_FILES = [
|
|
||||||
"AllSetFiles/", # Directory containing set JSON files
|
|
||||||
"AllPrintings.json",
|
|
||||||
"AllIdentifiers.json",
|
|
||||||
"CardTypes.json",
|
|
||||||
"DeckList.json",
|
|
||||||
"Keywords.json",
|
|
||||||
"SetList.json",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class MTGJSONManager:
|
class MTGJSONManager:
|
||||||
"""Process local MTGJSON files and upsert into PostgreSQL."""
|
"""Process local MTGJSON files and upsert into PostgreSQL."""
|
||||||
|
|
||||||
@@ -64,25 +59,116 @@ class MTGJSONManager:
|
|||||||
|
|
||||||
files_status = {}
|
files_status = {}
|
||||||
for f in self.data_dir.iterdir():
|
for f in self.data_dir.iterdir():
|
||||||
if f.is_file() and f.suffix == '.json':
|
if f.is_file():
|
||||||
|
if f.suffix == '.json':
|
||||||
files_status[f.stem + '.json'] = 'OK'
|
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():
|
elif f.is_dir():
|
||||||
files_status[f.name] = 'OK'
|
files_status[f.name] = 'OK'
|
||||||
|
|
||||||
last_refresh = await self.get_last_refresh()
|
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 {
|
return {
|
||||||
"status": "healthy" if sets_count > 0 and cards_count > 0 else "unhealthy",
|
"status": "healthy" if is_healthy else "unhealthy",
|
||||||
"data": {
|
"data": {
|
||||||
"sets_count": sets_count,
|
"sets_count": sets_count,
|
||||||
"cards_count": cards_count,
|
"cards_count": cards_count,
|
||||||
"last_refresh": last_refresh.isoformat() if last_refresh else None,
|
"last_refresh": last_refresh.isoformat() if last_refresh else None,
|
||||||
"files": files_status,
|
"files": files_status,
|
||||||
|
"required_files": required,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"status": "unhealthy", "error": str(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:
|
async def upsert_all(self) -> dict:
|
||||||
"""Upsert all data from local files."""
|
"""Upsert all data from local files."""
|
||||||
counts = {}
|
counts = {}
|
||||||
@@ -101,8 +187,11 @@ class MTGJSONManager:
|
|||||||
if "AllSetFiles" in available:
|
if "AllSetFiles" in available:
|
||||||
counts["sets"] = await self._upsert_sets()
|
counts["sets"] = await self._upsert_sets()
|
||||||
|
|
||||||
|
# Handle AllPrintings - can be .json or .psql
|
||||||
if "AllPrintings.json" in available:
|
if "AllPrintings.json" in available:
|
||||||
counts["cards"] = await self._upsert_cards()
|
counts["cards"] = await self._upsert_cards()
|
||||||
|
elif "AllPrintings.psql" in available:
|
||||||
|
counts["cards"] = await self._upsert_cards_from_psql()
|
||||||
|
|
||||||
if "AllIdentifiers.json" in available:
|
if "AllIdentifiers.json" in available:
|
||||||
counts["identifiers"] = await self._upsert_identifiers()
|
counts["identifiers"] = await self._upsert_identifiers()
|
||||||
@@ -116,7 +205,10 @@ class MTGJSONManager:
|
|||||||
if "SetList.json" in available:
|
if "SetList.json" in available:
|
||||||
counts["set_list"] = await self._upsert_set_list()
|
counts["set_list"] = await self._upsert_set_list()
|
||||||
|
|
||||||
if "DeckList.json" in available:
|
# 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()
|
counts["deck_list"] = await self._upsert_deck_list()
|
||||||
|
|
||||||
return counts
|
return counts
|
||||||
@@ -292,6 +384,206 @@ class MTGJSONManager:
|
|||||||
logger.info(f"Upserted {count} cards")
|
logger.info(f"Upserted {count} cards")
|
||||||
return count
|
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:
|
async def _upsert_identifiers(self) -> int:
|
||||||
"""Upsert identifiers from AllIdentifiers.json."""
|
"""Upsert identifiers from AllIdentifiers.json."""
|
||||||
path = self.data_dir / "AllIdentifiers.json"
|
path = self.data_dir / "AllIdentifiers.json"
|
||||||
@@ -510,6 +802,81 @@ class MTGJSONManager:
|
|||||||
|
|
||||||
return count
|
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):
|
async def log_refresh(self, status: str, counts: dict[str, int], duration: int, error: str = None):
|
||||||
"""Log refresh operation."""
|
"""Log refresh operation."""
|
||||||
async with mtg_async_session() as session:
|
async with mtg_async_session() as session:
|
||||||
@@ -560,6 +927,250 @@ class MTGJSONManager:
|
|||||||
await self.log_refresh("FAILED", {}, 0, str(e))
|
await self.log_refresh("FAILED", {}, 0, str(e))
|
||||||
return False
|
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
|
# Singleton instance
|
||||||
_manager_instance = None
|
_manager_instance = None
|
||||||
@@ -578,6 +1189,8 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
parser = argparse.ArgumentParser(description="MTGJSON Data Manager")
|
parser = argparse.ArgumentParser(description="MTGJSON Data Manager")
|
||||||
parser.add_argument("--refresh", action="store_true", help="Run refresh cycle")
|
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")
|
parser.add_argument("--data-dir", type=str, default="/app/data", help="Data directory")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
@@ -592,7 +1205,10 @@ if __name__ == "__main__":
|
|||||||
else:
|
else:
|
||||||
print("Refresh failed")
|
print("Refresh failed")
|
||||||
exit(1)
|
exit(1)
|
||||||
|
elif args.download:
|
||||||
|
result = await manager.download_files(force=args.force)
|
||||||
|
print(f"Download result: {result}")
|
||||||
else:
|
else:
|
||||||
print("Usage: python -m app.services.mtgjson_manager --refresh --data-dir /app/data")
|
print("Usage: python -m app.services.mtgjson_manager [--refresh | --download]")
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
|
|||||||
@@ -0,0 +1,435 @@
|
|||||||
|
"""
|
||||||
|
MTGJSON Download Script
|
||||||
|
|
||||||
|
Downloads MTGJSON files from the official API and processes them into PostgreSQL.
|
||||||
|
Designed to run on container init and when refresh is triggered via API.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Parallel downloads with retry logic
|
||||||
|
- Progress tracking and logging
|
||||||
|
- Automatic decompression
|
||||||
|
- Verification and integrity checks
|
||||||
|
- Database upsert operations
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
```bash
|
||||||
|
# Download and process all files
|
||||||
|
python download_mtgjson.py --all
|
||||||
|
|
||||||
|
# Download specific files
|
||||||
|
python download_mtgjson.py --files AllPrintings.psql AllIdentifiers.json
|
||||||
|
|
||||||
|
# Force re-download
|
||||||
|
python download_mtgjson.py --all --force
|
||||||
|
|
||||||
|
# Dry run (show what would be downloaded)
|
||||||
|
python download_mtgjson.py --all --dry-run
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
|
import gzip
|
||||||
|
import zipfile
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict, List, Tuple
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.StreamHandler(),
|
||||||
|
logging.FileHandler('mtgjson_download.log', mode='a')
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadStatus(Enum):
|
||||||
|
"""Status of download operations."""
|
||||||
|
PENDING = "pending"
|
||||||
|
DOWNLOADING = "downloading"
|
||||||
|
DECOMPRESSING = "decompressing"
|
||||||
|
PROCESSING = "processing"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MTGFile:
|
||||||
|
"""Represents an MTGJSON file to download."""
|
||||||
|
name: str
|
||||||
|
filename: str
|
||||||
|
compressed: bool = True
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if not self.description:
|
||||||
|
self.description = f"{self.name} file"
|
||||||
|
|
||||||
|
|
||||||
|
class MTGJSONDownloader:
|
||||||
|
"""Download and process MTGJSON files from the official API."""
|
||||||
|
|
||||||
|
# Base URL for MTGJSON API
|
||||||
|
BASE_URL = "https://mtgjson.com/api/v5/"
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
MAX_RETRIES = 3
|
||||||
|
RETRY_DELAY = 5 # seconds
|
||||||
|
CHUNK_SIZE = 8192 # bytes per chunk for progress tracking
|
||||||
|
TIMEOUT = aiohttp.ClientTimeout(total=3600) # 1 hour timeout
|
||||||
|
|
||||||
|
# File definitions
|
||||||
|
REQUIRED_FILES = {
|
||||||
|
"AllPrintings.psql": MTGFile(
|
||||||
|
name="AllPrintings",
|
||||||
|
filename="AllPrintings.psql",
|
||||||
|
compressed=True,
|
||||||
|
description="SQL file with all MTG card data (193MB compressed)"
|
||||||
|
),
|
||||||
|
"AllIdentifiers.json": MTGFile(
|
||||||
|
name="AllIdentifiers",
|
||||||
|
filename="AllIdentifiers.json",
|
||||||
|
compressed=True,
|
||||||
|
description="JSON file with card identifiers (215MB compressed)"
|
||||||
|
),
|
||||||
|
"Keywords.json": MTGFile(
|
||||||
|
name="Keywords",
|
||||||
|
filename="Keywords.json",
|
||||||
|
compressed=True,
|
||||||
|
description="JSON file with keywords (2KB compressed)"
|
||||||
|
),
|
||||||
|
"CardTypes.json": MTGFile(
|
||||||
|
name="CardTypes",
|
||||||
|
filename="CardTypes.json",
|
||||||
|
compressed=True,
|
||||||
|
description="JSON file with card types (3KB compressed)"
|
||||||
|
),
|
||||||
|
"AllDeckFiles.zip": MTGFile(
|
||||||
|
name="AllDeckFiles",
|
||||||
|
filename="AllDeckFiles.zip",
|
||||||
|
compressed=False,
|
||||||
|
description="ZIP archive with deck files (246MB)"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, data_dir: Path, force: bool = False, dry_run: bool = False):
|
||||||
|
"""
|
||||||
|
Initialize the downloader.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data_dir: Directory to store downloaded files
|
||||||
|
force: Force re-download even if files exist
|
||||||
|
dry_run: Show what would be done without actually doing it
|
||||||
|
"""
|
||||||
|
self.data_dir = Path(data_dir)
|
||||||
|
self.force = force
|
||||||
|
self.dry_run = dry_run
|
||||||
|
|
||||||
|
# Create data directory if it doesn't exist
|
||||||
|
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Track download status
|
||||||
|
self.download_status: Dict[str, DownloadStatus] = {}
|
||||||
|
for filename in self.REQUIRED_FILES:
|
||||||
|
self.download_status[filename] = DownloadStatus.PENDING
|
||||||
|
|
||||||
|
logger.info(f"MTGJSON Downloader initialized")
|
||||||
|
logger.info(f"Data directory: {self.data_dir}")
|
||||||
|
logger.info(f"Force: {force}, Dry run: {dry_run}")
|
||||||
|
|
||||||
|
async def check_existing_files(self) -> Dict[str, bool]:
|
||||||
|
"""Check which files already exist in the data directory."""
|
||||||
|
existing = {}
|
||||||
|
for filename in self.REQUIRED_FILES:
|
||||||
|
file_path = self.data_dir / filename
|
||||||
|
if file_path.exists():
|
||||||
|
size_mb = file_path.stat().st_size / (1024 * 1024)
|
||||||
|
logger.info(f"Found existing file: {filename} ({size_mb:.1f} MB)")
|
||||||
|
existing[filename] = True
|
||||||
|
else:
|
||||||
|
logger.info(f"File not found: {filename}")
|
||||||
|
existing[filename] = False
|
||||||
|
return existing
|
||||||
|
|
||||||
|
async def download_file(
|
||||||
|
self,
|
||||||
|
session: aiohttp.ClientSession,
|
||||||
|
filename: str,
|
||||||
|
progress_callback: Optional[callable] = None
|
||||||
|
) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Download a single file with retry logic.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: aiohttp session
|
||||||
|
filename: Name of the file to download
|
||||||
|
progress_callback: Optional callback for progress updates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message)
|
||||||
|
"""
|
||||||
|
file_info = self.REQUIRED_FILES[filename]
|
||||||
|
file_path = self.data_dir / filename
|
||||||
|
|
||||||
|
for attempt in range(1, self.MAX_RETRIES + 1):
|
||||||
|
try:
|
||||||
|
logger.info(f"Downloading {filename} (attempt {attempt}/{self.MAX_RETRIES})")
|
||||||
|
self.download_status[filename] = DownloadStatus.DOWNLOADING
|
||||||
|
|
||||||
|
# Set up URL
|
||||||
|
url = f"{self.BASE_URL}{filename}"
|
||||||
|
|
||||||
|
# Download with progress
|
||||||
|
async with session.get(url, timeout=self.TIMEOUT) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
error_msg = f"HTTP {response.status} for {filename}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
|
||||||
|
if attempt < self.MAX_RETRIES:
|
||||||
|
await asyncio.sleep(self.RETRY_DELAY * attempt)
|
||||||
|
continue
|
||||||
|
return False, error_msg
|
||||||
|
|
||||||
|
# Get total size
|
||||||
|
total_size = int(response.headers.get('content-length', 0))
|
||||||
|
downloaded = 0
|
||||||
|
|
||||||
|
# Write to temp file first, then rename
|
||||||
|
temp_path = file_path.with_suffix(file_path.suffix + '.tmp')
|
||||||
|
|
||||||
|
with open(temp_path, 'wb') as f:
|
||||||
|
async for chunk in response.content.iter_chunked(self.CHUNK_SIZE):
|
||||||
|
f.write(chunk)
|
||||||
|
downloaded += len(chunk)
|
||||||
|
|
||||||
|
# Progress callback
|
||||||
|
if progress_callback and total_size > 0:
|
||||||
|
progress_callback(filename, downloaded, total_size)
|
||||||
|
|
||||||
|
# Move temp file to final location
|
||||||
|
shutil.move(str(temp_path), str(file_path))
|
||||||
|
|
||||||
|
# Decompress if needed
|
||||||
|
if file_info.compressed:
|
||||||
|
await self._decompress_file(file_path)
|
||||||
|
|
||||||
|
logger.info(f"Successfully downloaded {filename}")
|
||||||
|
self.download_status[filename] = DownloadStatus.COMPLETED
|
||||||
|
return True, "Download completed"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Error downloading {filename}: {str(e)}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
|
||||||
|
# Clean up temp file if it exists
|
||||||
|
temp_path = file_path.with_suffix(file_path.suffix + '.tmp')
|
||||||
|
if temp_path.exists():
|
||||||
|
temp_path.unlink()
|
||||||
|
|
||||||
|
if attempt < self.MAX_RETRIES:
|
||||||
|
await asyncio.sleep(self.RETRY_DELAY * attempt)
|
||||||
|
continue
|
||||||
|
return False, error_msg
|
||||||
|
|
||||||
|
return False, f"Failed after {self.MAX_RETRIES} attempts"
|
||||||
|
|
||||||
|
async def _decompress_file(self, file_path: Path) -> None:
|
||||||
|
"""
|
||||||
|
Decompress a gzip-compressed file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the compressed file
|
||||||
|
"""
|
||||||
|
self.download_status[file_path.name] = DownloadStatus.DECOMPRESSING
|
||||||
|
|
||||||
|
if file_path.suffix == '.gz':
|
||||||
|
decompressed_path = file_path.with_suffix('')
|
||||||
|
|
||||||
|
logger.info(f"Decompressing {file_path.name} -> {decompressed_path.name}")
|
||||||
|
|
||||||
|
with gzip.open(file_path, 'rb') as f_in:
|
||||||
|
with open(decompressed_path, 'wb') as f_out:
|
||||||
|
shutil.copyfileobj(f_in, f_out)
|
||||||
|
|
||||||
|
# Remove compressed file
|
||||||
|
file_path.unlink()
|
||||||
|
|
||||||
|
logger.info(f"Decompression complete: {decompressed_path.name}")
|
||||||
|
|
||||||
|
async def download_all(self, progress_callback: Optional[callable] = None) -> Dict[str, Tuple[bool, str]]:
|
||||||
|
"""
|
||||||
|
Download all required files in parallel.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
progress_callback: Optional callback for progress updates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of filename -> (success, message)
|
||||||
|
"""
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
# Check existing files
|
||||||
|
existing = await self.check_existing_files()
|
||||||
|
|
||||||
|
# Determine which files to download
|
||||||
|
to_download = []
|
||||||
|
for filename in self.REQUIRED_FILES:
|
||||||
|
if self.force or not existing[filename]:
|
||||||
|
to_download.append(filename)
|
||||||
|
else:
|
||||||
|
logger.info(f"Skipping {filename} (already exists)")
|
||||||
|
results[filename] = (True, "Already exists")
|
||||||
|
self.download_status[filename] = DownloadStatus.COMPLETED
|
||||||
|
|
||||||
|
if not to_download:
|
||||||
|
logger.info("All files already exist, nothing to download")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# Download files in parallel
|
||||||
|
logger.info(f"Downloading {len(to_download)} files in parallel...")
|
||||||
|
|
||||||
|
tasks = []
|
||||||
|
for filename in to_download:
|
||||||
|
task = self.download_file(session, filename, progress_callback)
|
||||||
|
tasks.append(task)
|
||||||
|
|
||||||
|
download_results = await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
# Collect results
|
||||||
|
for filename, result in zip(to_download, download_results):
|
||||||
|
results[filename] = result
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def print_summary(self, results: Dict[str, Tuple[bool, str]]) -> None:
|
||||||
|
"""Print a summary of download results."""
|
||||||
|
logger.info("\n" + "="*60)
|
||||||
|
logger.info("DOWNLOAD SUMMARY")
|
||||||
|
logger.info("="*60)
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
for filename, (success, message) in results.items():
|
||||||
|
status_icon = "✓" if success else "✗"
|
||||||
|
logger.info(f" {status_icon} {filename}: {message}")
|
||||||
|
if success:
|
||||||
|
success_count += 1
|
||||||
|
|
||||||
|
logger.info(f"\nTotal: {success_count}/{len(results)} files downloaded successfully")
|
||||||
|
logger.info("="*60 + "\n")
|
||||||
|
|
||||||
|
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, file_info in self.REQUIRED_FILES.items():
|
||||||
|
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:
|
||||||
|
import json
|
||||||
|
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'):
|
||||||
|
# Basic check - file should not be empty and should have some SQL content
|
||||||
|
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 main():
|
||||||
|
"""Main entry point."""
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Download MTGJSON files")
|
||||||
|
parser.add_argument("--all", action="store_true", help="Download all required files")
|
||||||
|
parser.add_argument("--files", nargs="+", help="Specific files to download")
|
||||||
|
parser.add_argument("--data-dir", type=str, default="/app/data", help="Data directory")
|
||||||
|
parser.add_argument("--force", action="store_true", help="Force re-download")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Show what would be done")
|
||||||
|
parser.add_argument("--verify", action="store_true", help="Verify files after download")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
data_dir = Path(args.data_dir)
|
||||||
|
|
||||||
|
# Create downloader
|
||||||
|
downloader = MTGJSONDownloader(
|
||||||
|
data_dir=data_dir,
|
||||||
|
force=args.force,
|
||||||
|
dry_run=args.dry_run
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine which files to download
|
||||||
|
if args.files:
|
||||||
|
files_to_download = args.files
|
||||||
|
elif args.all:
|
||||||
|
files_to_download = list(MTGJSONDownloader.REQUIRED_FILES.keys())
|
||||||
|
else:
|
||||||
|
logger.info("No files specified. Use --all or --files <filename>")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Download files
|
||||||
|
results = await downloader.download_all()
|
||||||
|
|
||||||
|
# Print summary
|
||||||
|
downloader.print_summary(results)
|
||||||
|
|
||||||
|
# Verify if requested
|
||||||
|
if args.verify:
|
||||||
|
all_valid, errors = await downloader.verify_files()
|
||||||
|
if all_valid:
|
||||||
|
logger.info("All files verified successfully!")
|
||||||
|
else:
|
||||||
|
logger.error("Verification failed:")
|
||||||
|
for error in errors:
|
||||||
|
logger.error(f" - {error}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
exit_code = asyncio.run(main())
|
||||||
|
exit(exit_code)
|
||||||
+37
-21
@@ -1,21 +1,37 @@
|
|||||||
{
|
# MTG Online Backend - Project State
|
||||||
"task_description": "MTG Online Web - Full deployment and testing",
|
|
||||||
"current_step": "Reviewing project files and saving state",
|
## Project Overview
|
||||||
"files_created": [],
|
A Python FastAPI backend service for MTG Online (Magic: The Gathering) that integrates with MTGJSON data and provides APIs for deck management, game rooms, and card data.
|
||||||
"files_modified": [],
|
|
||||||
"decisions": [
|
## Current Status: Ready for Deployment
|
||||||
"Project uses Python/FastAPI backend with PostgreSQL and Redis",
|
|
||||||
"MTGJSON data integration includes AllPrintings.json, AllIdentifiers.json, CardTypes.json, etc.",
|
### Completed Steps
|
||||||
"Docker Compose for multi-container orchestration"
|
1. ✅ Set up Docker Compose stack (PostgreSQL, Redis, Backend, Refresh services)
|
||||||
],
|
2. ✅ Clean up corrupted MTGJSON data files
|
||||||
"next_steps": [
|
3. ✅ Downloaded fresh MTGJSON data from API
|
||||||
"Push and commit to Gitea",
|
4. ✅ Simplified MTGJSON data management service
|
||||||
"Stop all Docker containers",
|
5. ✅ Updated database schema to support all JSON data
|
||||||
"Build Backend container",
|
6. ✅ Fixed directory naming issues
|
||||||
"Deploy stack and verify health",
|
7. ✅ Rebuilt and redeployed backend
|
||||||
"Check logs and fix any issues"
|
|
||||||
],
|
### Next Steps
|
||||||
"blockers": [],
|
- Verify backend health and database connectivity
|
||||||
"commit_hash": "3fae593",
|
- Test the refresh cycle
|
||||||
"timestamp": "2026-07-20T14:00:00Z"
|
- Confirm all MTGJSON data is properly stored
|
||||||
}
|
|
||||||
|
## Key Files
|
||||||
|
- Dockerfile: `/home/wall-o/projects/mtgonline/backend/Dockerfile`
|
||||||
|
- docker-compose.yml: `/home/wall-o/projects/mtgonline/docker-compose.yml`
|
||||||
|
- .env.local: `/home/wall-o/projects/mtgonline/.env.local`
|
||||||
|
- Database schema: `/home/wall-o/projects/mtgonline/backend/scripts/init-mtgdata.sql`
|
||||||
|
- Main application: `/home/wall-o/projects/mtgonline/backend/app/main.py`
|
||||||
|
- Database connection: `/home/wall-o/projects/mtgonline/backend/app/core/database.py`
|
||||||
|
- MTGJSON manager: `/home/wall-o/projects/mtgonline/backend/app/services/mtgjson_manager.py`
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
- Python 3.12 with FastAPI
|
||||||
|
- PostgreSQL 16 with asyncpg
|
||||||
|
- Redis 7 for caching
|
||||||
|
- MTGJSON data integration for card database
|
||||||
|
- Docker Compose for orchestration
|
||||||
|
- Volume-based persistence for databases and MTG data
|
||||||
|
|||||||
Reference in New Issue
Block a user