Files
akadmin 90822d48c4 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
2026-07-21 02:58:37 +00:00

436 lines
15 KiB
Python

"""
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)