Fix MTGJSON download: use .json files, remove gzip unpacking
- Changed REQUIRED_FILES to REQUIRED_JSON_FILES and REQUIRED_ZIP_FILES - AllSetFiles.zip kept as zip (only available compressed on MTGJSON) - All other files downloaded as .json (no compression) - Removed gzip import and unpacking logic for .gz files - Updated EXPECTED_MIN_SIZES to reflect actual .json file sizes - Removed validation for non-existent files
This commit is contained in:
@@ -5,8 +5,8 @@ Handles downloading, unpacking, and upserting MTGJSON data into PostgreSQL.
|
|||||||
Manages the complete data lifecycle from download to database upsert.
|
Manages the complete data lifecycle from download to database upsert.
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
- Downloads required MTGJSON datasets from the API
|
- Downloads required MTGJSON datasets from the API (JSON files, except AllSetFiles as zip)
|
||||||
- Unpacks gzip and zip files
|
- Unpacks zip files (AllSetFiles.zip only)
|
||||||
- Converts JSON to PostgreSQL-compatible format
|
- Converts JSON to PostgreSQL-compatible format
|
||||||
- Upserts data without overwriting existing entries
|
- Upserts data without overwriting existing entries
|
||||||
- Tracks refresh timestamps and status
|
- Tracks refresh timestamps and status
|
||||||
@@ -17,11 +17,9 @@ Usage:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import gzip
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import time
|
import time
|
||||||
import zipfile
|
import zipfile
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@@ -38,35 +36,33 @@ from app.core.settings import get_settings
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# MTGJSON API URLs
|
# MTGJSON API URLs - .json files for most datasets, .zip for AllSetFiles
|
||||||
MTGJSON_BASE_URL = "https://mtgjson.com/api/v5"
|
MTGJSON_BASE_URL = "https://mtgjson.com/api/v5"
|
||||||
REQUIRED_FILES = {
|
REQUIRED_JSON_FILES = {
|
||||||
"AllPrintings.json.gz": MTGJSON_BASE_URL + "/AllPrintings.json.gz",
|
"AllPrintings.json": MTGJSON_BASE_URL + "/AllPrintings.json",
|
||||||
|
"AllIdentifiers.json": MTGJSON_BASE_URL + "/AllIdentifiers.json",
|
||||||
|
"CardTypes.json": MTGJSON_BASE_URL + "/CardTypes.json",
|
||||||
|
"DeckList.json": MTGJSON_BASE_URL + "/DeckList.json",
|
||||||
|
"Keywords.json": MTGJSON_BASE_URL + "/Keywords.json",
|
||||||
|
"SetList.json": MTGJSON_BASE_URL + "/SetList.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
REQUIRED_ZIP_FILES = {
|
||||||
"AllSetFiles.zip": MTGJSON_BASE_URL + "/AllSetFiles.zip",
|
"AllSetFiles.zip": MTGJSON_BASE_URL + "/AllSetFiles.zip",
|
||||||
"AllIdentifiers.json.gz": MTGJSON_BASE_URL + "/AllIdentifiers.json.gz",
|
|
||||||
"CardTypes.json.gz": MTGJSON_BASE_URL + "/CardTypes.json.gz",
|
|
||||||
"DeckList.json.gz": MTGJSON_BASE_URL + "/DeckList.json.gz",
|
|
||||||
"Keywords.json.gz": MTGJSON_BASE_URL + "/Keywords.json.gz",
|
|
||||||
"SetList.json.gz": MTGJSON_BASE_URL + "/SetList.json.gz",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DATA_DIR = Path("/app/data/mtgjson")
|
DATA_DIR = Path("/app/data/mtgjson")
|
||||||
REFRESH_LOG_TABLE = "mtg_refresh_log"
|
REFRESH_LOG_TABLE = "mtg_refresh_log"
|
||||||
|
|
||||||
# Expected minimum file sizes (in bytes) for MTGJSON v5 files
|
# Expected minimum file sizes (in bytes) for MTGJSON v5 JSON files
|
||||||
EXPECTED_MIN_SIZES = {
|
EXPECTED_MIN_SIZES = {
|
||||||
"AllPrintings.json.gz": 500 * 1024 * 1024, # 500 MB
|
"AllPrintings.json": 500 * 1024 * 1024, # 500 MB (actual: ~620 MB)
|
||||||
"AllPrintings.json": 500 * 1024 * 1024, # 500 MB
|
"AllIdentifiers.json": 500 * 1024 * 1024, # 500 MB (actual: ~599 MB)
|
||||||
"AllSetFiles.json": 10 * 1024 * 1024, # 10 MB
|
"CardTypes.json": 0.001 * 1024 * 1024, # 0.001 MB (actual: ~0.01 MB)
|
||||||
"AllIdentifiers.json.gz": 100 * 1024 * 1024, # 100 MB
|
"DeckList.json": 0.5 * 1024 * 1024, # 0.5 MB (actual: ~0.59 MB)
|
||||||
"AllIdentifiers.json": 100 * 1024 * 1024, # 100 MB
|
"Keywords.json": 0.001 * 1024 * 1024, # 0.001 MB (actual: ~0.00 MB)
|
||||||
"CardTypes.json.gz": 1 * 1024 * 1024, # 1 MB
|
"SetList.json": 5 * 1024 * 1024, # 5 MB (actual: ~11 MB)
|
||||||
"CardTypes.json": 1 * 1024 * 1024, # 1 MB
|
"AllSetFiles.zip": 50 * 1024 * 1024, # 50 MB (expected size)
|
||||||
"Keywords.json.gz": 0.5 * 1024 * 1024, # 0.5 MB
|
|
||||||
"Keywords.json": 0.5 * 1024 * 1024, # 0.5 MB
|
|
||||||
"MagicSets.json": 50 * 1024 * 1024, # 50 MB
|
|
||||||
"MagicRoots.json": 1 * 1024 * 1024, # 1 MB
|
|
||||||
"SetTranslations.json": 5 * 1024 * 1024, # 5 MB
|
|
||||||
}
|
}
|
||||||
|
|
||||||
MAX_DOWNLOAD_RETRIES = 3
|
MAX_DOWNLOAD_RETRIES = 3
|
||||||
@@ -130,13 +126,13 @@ class MTGJSONManager:
|
|||||||
def _get_estimated_size(self, filename: str) -> int:
|
def _get_estimated_size(self, filename: str) -> int:
|
||||||
"""Get estimated file size in MB."""
|
"""Get estimated file size in MB."""
|
||||||
estimates = {
|
estimates = {
|
||||||
"AllPrintings.json.gz": 500,
|
"AllPrintings.json": 620,
|
||||||
"AllSetFiles.zip": 10,
|
"AllSetFiles.zip": 10,
|
||||||
"AllIdentifiers.json.gz": 100,
|
"AllIdentifiers.json": 600,
|
||||||
"CardTypes.json.gz": 5,
|
"CardTypes.json": 0.01,
|
||||||
"DeckList.json.gz": 5,
|
"DeckList.json": 0.6,
|
||||||
"Keywords.json.gz": 2,
|
"Keywords.json": 0.01,
|
||||||
"SetList.json.gz": 10,
|
"SetList.json": 11,
|
||||||
}
|
}
|
||||||
return estimates.get(filename, 10) # Default 10MB
|
return estimates.get(filename, 10) # Default 10MB
|
||||||
|
|
||||||
@@ -178,7 +174,7 @@ class MTGJSONManager:
|
|||||||
continue
|
continue
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Unpack files
|
# Unpack zip files (AllSetFiles.zip only)
|
||||||
unpack_success = await self.unpack_files()
|
unpack_success = await self.unpack_files()
|
||||||
|
|
||||||
if not unpack_success:
|
if not unpack_success:
|
||||||
@@ -210,12 +206,14 @@ class MTGJSONManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async def download_files(self) -> bool:
|
async def download_files(self) -> bool:
|
||||||
"""Download all required MTGJSON files."""
|
"""Download all required MTGJSON files (JSON and ZIP)."""
|
||||||
logger.info(f"Starting MTGJSON data download to {self.data_dir}")
|
logger.info(f"Starting MTGJSON data download to {self.data_dir}")
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
tasks = []
|
tasks = []
|
||||||
for filename, url in REQUIRED_FILES.items():
|
for filename, url in REQUIRED_JSON_FILES.items():
|
||||||
|
tasks.append(self._download_file(session, url, filename))
|
||||||
|
for filename, url in REQUIRED_ZIP_FILES.items():
|
||||||
tasks.append(self._download_file(session, url, filename))
|
tasks.append(self._download_file(session, url, filename))
|
||||||
|
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
@@ -288,56 +286,21 @@ class MTGJSONManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async def unpack_files(self) -> bool:
|
async def unpack_files(self) -> bool:
|
||||||
"""Unpack all downloaded files."""
|
"""Unpack all downloaded zip files."""
|
||||||
logger.info("Unpacking MTGJSON files")
|
logger.info("Unpacking MTGJSON zip files")
|
||||||
|
|
||||||
unpacked_files = []
|
unpacked_files = []
|
||||||
|
|
||||||
# Unpack gzip files
|
# Unpack zip files only
|
||||||
for gz_file in self.data_dir.glob("*.gz"):
|
|
||||||
if self._unpack_gzip(gz_file):
|
|
||||||
unpacked_files.append(gz_file.with_suffix(""))
|
|
||||||
|
|
||||||
# Unpack zip files
|
|
||||||
for zip_file in self.data_dir.glob("*.zip"):
|
for zip_file in self.data_dir.glob("*.zip"):
|
||||||
if self._unpack_zip(zip_file):
|
if self._unpack_zip(zip_file):
|
||||||
unpacked_files.append(zip_file.with_suffix(""))
|
unpacked_files.append(zip_file)
|
||||||
|
|
||||||
if len(unpacked_files) > 0:
|
if len(unpacked_files) > 0:
|
||||||
logger.info(f"Unpacked {len(unpacked_files)} files")
|
logger.info(f"Unpacked {len(unpacked_files)} zip files")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.warning("No files were unpacked")
|
logger.warning("No zip files were unpacked")
|
||||||
return False
|
|
||||||
|
|
||||||
def _unpack_gzip(self, gz_file: Path) -> bool:
|
|
||||||
"""Unpack a gzip file or handle pre-uncompressed JSON."""
|
|
||||||
dest_file = gz_file.with_suffix("")
|
|
||||||
|
|
||||||
logger.info(f"Unpacking {gz_file.name}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Check if file is actually gzipped by reading first bytes
|
|
||||||
with open(gz_file, 'rb') as f:
|
|
||||||
magic = f.read(2)
|
|
||||||
|
|
||||||
if magic == b'\x1f\x8b':
|
|
||||||
# File is gzipped - proceed normally
|
|
||||||
with gzip.open(gz_file, 'rt', encoding='utf-8') as f_in:
|
|
||||||
content = f_in.read()
|
|
||||||
|
|
||||||
dest_file.write_text(content, encoding='utf-8')
|
|
||||||
logger.info(f"Unpacked {gz_file.name} to {dest_file.name}")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
# File is already plain JSON - just rename
|
|
||||||
logger.info(f"{gz_file.name} is pre-uncompressed JSON, renaming to {dest_file.name}")
|
|
||||||
dest_file.write_bytes(gz_file.read_bytes())
|
|
||||||
gz_file.unlink()
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to unpack {gz_file.name}: {e}")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _unpack_zip(self, zip_file: Path) -> bool:
|
def _unpack_zip(self, zip_file: Path) -> bool:
|
||||||
@@ -375,7 +338,7 @@ class MTGJSONManager:
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Process AllSetFiles directory
|
# Process AllSetFiles directory (from zip)
|
||||||
await self._upsert_sets()
|
await self._upsert_sets()
|
||||||
counts["sets"] = await self._count_sets()
|
counts["sets"] = await self._count_sets()
|
||||||
|
|
||||||
@@ -827,4 +790,4 @@ if __name__ == "__main__":
|
|||||||
else:
|
else:
|
||||||
print("Usage: python -m app.services.mtgjson_manager [--refresh | --health]")
|
print("Usage: python -m app.services.mtgjson_manager [--refresh | --health]")
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Test MTGJSON API endpoints and download JSON files."""
|
||||||
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DATA_DIR = Path("/app/data/mtgjson")
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# MTGJSON API endpoints for .json files
|
||||||
|
MTGJSON_BASE_URL = "https://mtgjson.com/api/v5"
|
||||||
|
JSON_FILES = {
|
||||||
|
"AllPrintings.json": MTGJSON_BASE_URL + "/AllPrintings.json",
|
||||||
|
"AllSetFiles.json": MTGJSON_BASE_URL + "/AllSetFiles.json",
|
||||||
|
"AllIdentifiers.json": MTGJSON_BASE_URL + "/AllIdentifiers.json",
|
||||||
|
"CardTypes.json": MTGJSON_BASE_URL + "/CardTypes.json",
|
||||||
|
"DeckList.json": MTGJSON_BASE_URL + "/DeckList.json",
|
||||||
|
"Keywords.json": MTGJSON_BASE_URL + "/Keywords.json",
|
||||||
|
"SetList.json": MTGJSON_BASE_URL + "/SetList.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def test_download_file(session, url, filename):
|
||||||
|
"""Test downloading a single JSON file."""
|
||||||
|
dest_path = DATA_DIR / filename
|
||||||
|
print(f"Testing {filename}...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with session.get(url) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
content = await response.read()
|
||||||
|
print(f" ✓ Status: {response.status}")
|
||||||
|
print(f" ✓ Size: {len(content) / (1024*1024):.2f} MB")
|
||||||
|
print(f" ✓ Content-Type: {response.content_type}")
|
||||||
|
|
||||||
|
# Write to file
|
||||||
|
with open(dest_path, 'wb') as f:
|
||||||
|
f.write(content)
|
||||||
|
print(f" ✓ Saved to {dest_path}")
|
||||||
|
|
||||||
|
# Test JSON parsing
|
||||||
|
import json
|
||||||
|
with open(dest_path, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
print(f" ✓ Valid JSON: {type(data).__name__}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f" ✗ Status: {response.status}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Test all MTGJSON JSON endpoints."""
|
||||||
|
print("Testing MTGJSON JSON API endpoints...\n")
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
results = []
|
||||||
|
for filename, url in JSON_FILES.items():
|
||||||
|
success = await test_download_file(session, url, filename)
|
||||||
|
results.append((filename, success))
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("\n=== Results ===")
|
||||||
|
for filename, success in results:
|
||||||
|
status = "✓" if success else "✗"
|
||||||
|
print(f"{status} {filename}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
+16
-32
@@ -1,43 +1,27 @@
|
|||||||
{
|
{
|
||||||
"task_description": "MTG Online Backend - MTGJSON Data Integration & Docker Deployment",
|
"task_description": "Update MTGJSON data manager to download .json files instead of .json.gz files, remove unnecessary unpacking logic",
|
||||||
"current_step": "Testing full container lifecycle",
|
"current_step": "Docker image rebuilt with updated code",
|
||||||
"files_created": [
|
"files_created": [
|
||||||
"backend/app/services/mtgjson_manager.py",
|
"backend/app/services/mtgjson_manager.py"
|
||||||
"backend/scripts/sanity_check_mtgjson.py",
|
|
||||||
"backend/scripts/verify_mtgjson_data.py",
|
|
||||||
"backend/scripts/maintain_mtgjson.sh"
|
|
||||||
],
|
],
|
||||||
"files_modified": [
|
"files_modified": [
|
||||||
"backend/app/services/mtgjson_manager.py",
|
"backend/app/services/mtgjson_manager.py"
|
||||||
"backend/app/scripts/refresh_mtg.py",
|
|
||||||
"backend/app/main.py",
|
|
||||||
"docker-compose.yml",
|
|
||||||
"docker-compose.dev.yml",
|
|
||||||
"backend/Dockerfile"
|
|
||||||
],
|
],
|
||||||
"decisions": [
|
"decisions": [
|
||||||
"Created comprehensive MTGJSONManager service in app/services/",
|
"Changed REQUIRED_FILES to REQUIRED_JSON_FILES and REQUIRED_ZIP_FILES",
|
||||||
"Manager handles download, unpack, upsert with ON CONFLICT DO UPDATE",
|
"AllSetFiles.zip kept as zip (only available compressed on MTGJSON API)",
|
||||||
"Startup triggers initial download on first container init",
|
"All other files downloaded as .json (no compression)",
|
||||||
"Health check verifies MTG data exists in database",
|
"Removed gzip import and unpacking logic for .gz files",
|
||||||
"Refresh interval set to 7 days (weekly)",
|
"Updated EXPECTED_MIN_SIZES to reflect actual .json file sizes (500MB+ for large files)",
|
||||||
"Docker compose start_period set to 600s for download time",
|
"Removed validation for non-existent files (MagicSets.json, MagicRoots.json, SetTranslations.json)"
|
||||||
"Added file size validation against expected minimums",
|
|
||||||
"Implemented retry logic with exponential backoff (up to 3 attempts)",
|
|
||||||
"Cleanup deletes corrupted data before retry",
|
|
||||||
"Container marked unhealthy if validation fails after all retries",
|
|
||||||
"Download timeout increased to 60 minutes (3600s) for large files"
|
|
||||||
],
|
],
|
||||||
"next_steps": [
|
"next_steps": [
|
||||||
"Save state to state.json",
|
"Restart Docker container with new image",
|
||||||
"Commit and push to Gitea",
|
"Run data refresh to verify download and upsert work correctly",
|
||||||
"Stop and destroy all Docker containers",
|
"Monitor logs for any issues",
|
||||||
"Build backend Docker container",
|
"Verify database has correct data"
|
||||||
"Deploy stack and monitor",
|
|
||||||
"Check container health status",
|
|
||||||
"Verify logs show successful initialization"
|
|
||||||
],
|
],
|
||||||
"blockers": [],
|
"blockers": [],
|
||||||
"commit_hash": "0643544",
|
"commit_hash": "",
|
||||||
"timestamp": 1784598400
|
"timestamp": "2026-07-20T13:52:00Z"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user