- 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
436 lines
15 KiB
Python
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)
|