Files
launcher/server/pack_manager.py
T

256 lines
8.8 KiB
Python

import hashlib
import os
import zipfile
from datetime import datetime
from pathlib import Path
import json
from typing import Optional, Dict, List
import structlog
import asyncio
import aiofiles
from models import PackMeta, FileEntry
logger = structlog.get_logger(__name__)
PACKS_DIR = Path("packs")
DATA_DIR = Path("data")
DATA_DIR.mkdir(exist_ok=True)
# Cache for loaded manifests
_manifest_cache: Dict[str, PackMeta] = {}
# Pack config cache
_packs_config: Optional[Dict] = None
def load_packs_config() -> dict:
global _packs_config
if _packs_config is not None:
return _packs_config
config_path = DATA_DIR / "packs_config.json"
if config_path.exists():
try:
_packs_config = json.loads(config_path.read_text())
except Exception:
_packs_config = {}
else:
_packs_config = {}
return _packs_config
def save_packs_config(config: dict):
global _packs_config
_packs_config = config
config_path = DATA_DIR / "packs_config.json"
config_path.write_text(json.dumps(config, indent=2))
def is_pack_disabled(pack_name: str) -> bool:
config = load_packs_config()
return config.get(pack_name, {}).get("disabled", False)
def toggle_pack_disabled(pack_name: str) -> bool:
config = load_packs_config()
entry = config.setdefault(pack_name, {})
entry["disabled"] = not entry.get("disabled", False)
save_packs_config(config)
return entry["disabled"]
def get_pack_presets(pack_name: str) -> List[str]:
presets_dir = PACKS_DIR / pack_name / "presets"
if not presets_dir.exists() or not presets_dir.is_dir():
return []
return sorted([d.name for d in presets_dir.iterdir() if d.is_dir()])
def get_cached_manifest(pack_name: str) -> Optional[PackMeta]:
"""Get manifest from memory cache if available"""
return _manifest_cache.get(pack_name)
def get_meta_path(pack_name: str) -> Path:
return DATA_DIR / f"{pack_name}.meta"
def calculate_sha256_sync(file_path: Path) -> str:
"""Calculate SHA256 hash of a file (synchronous version)"""
hash_sha = hashlib.sha256()
with open(file_path, 'rb') as f:
while chunk := f.read(8192):
hash_sha.update(chunk)
return hash_sha.hexdigest()
async def calculate_sha256(file_path: Path) -> str:
"""Calculate SHA256 hash of a file (async)"""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, calculate_sha256_sync, file_path)
async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
"""Scan pack directory and update manifest if needed"""
pack_path = PACKS_DIR / pack_name
if not pack_path.exists() or not pack_path.is_dir():
raise FileNotFoundError(f"Pack {pack_name} not found")
meta_path = get_meta_path(pack_name)
current_meta: Optional[PackMeta] = None
# Check cache first
if not force_rescan and pack_name in _manifest_cache:
return _manifest_cache[pack_name]
# Load existing meta if available
if meta_path.exists():
try:
async with aiofiles.open(meta_path, 'r', encoding='utf-8') as f:
data = json.loads(await f.read())
current_meta = PackMeta.model_validate(data)
except Exception as e:
logger.warning(f"Failed to load existing meta for pack {pack_name}: {e}")
current_meta = None
new_files: Dict[str, FileEntry] = {}
changed = False
# Get ignored directories (always use current list, not cached from meta)
ignored_dirs = [
"resourcepacks", "shaderpacks", "saves", "logs",
"crash-reports", "screenshots", "journeymap",
"presets"
]
# Walk through pack directory
for root, dirs, files in os.walk(pack_path):
# Filter ignored directories
dirs[:] = [d for d in dirs if d not in ignored_dirs]
for file in files:
file_path = Path(root) / file
rel_path = file_path.relative_to(pack_path).as_posix()
# Skip metadata files that should not be part of the pack
if file in ("description.txt", "instance.json"):
continue
# Skip if in ignored directory
if any(ignored_dir in rel_path.split('/') for ignored_dir in ignored_dirs):
continue
# Validate .jar files — skip corrupted ones
if file.endswith(".jar") and not _is_valid_zip(file_path):
logger.warning(f"Skipping corrupt jar in pack {pack_name}: {rel_path}")
continue
stat = file_path.stat()
file_hash = await calculate_sha256(file_path)
entry = FileEntry(
path=rel_path,
hash=file_hash,
size=stat.st_size,
added_at=datetime.utcfromtimestamp(stat.st_ctime),
modified_at=datetime.utcfromtimestamp(stat.st_mtime)
)
new_files[rel_path] = entry
# Check if file changed
if current_meta and (rel_path not in current_meta.files or
current_meta.files[rel_path].hash != file_hash):
changed = True
# Check if we need to update
if not current_meta or changed or len(new_files) != len(current_meta.files if current_meta else 0):
version = (current_meta.version + 1) if current_meta else 1
# Load instance.json for pack metadata
minecraft_version = "1.20.4"
loader_type = "vanilla"
loader_version = None
asset_index = None
pack_config_path = pack_path / "instance.json"
if pack_config_path.exists():
try:
async with aiofiles.open(pack_config_path, 'r', encoding='utf-8') as f:
config = json.loads(await f.read())
minecraft_version = config.get("minecraftVersion", minecraft_version)
loader_type = config.get("loaderType", loader_type)
loader_version = config.get("loaderVersion")
asset_index = config.get("assetIndex")
except Exception as e:
logger.warning(f"Failed to load instance.json for {pack_name}: {e}")
# Create new manifest
new_meta = PackMeta(
pack_name=pack_name,
version=version,
files=new_files,
updated_at=datetime.utcnow(),
ignored_dirs=ignored_dirs,
minecraft_version=minecraft_version,
loader_type=loader_type,
loader_version=loader_version,
asset_index=asset_index
)
async with aiofiles.open(meta_path, 'w', encoding='utf-8') as f:
await f.write(new_meta.model_dump_json(indent=2))
# Update cache
_manifest_cache[pack_name] = new_meta
logger.info(f"Pack updated: {pack_name} v{version}, {len(new_files)} files")
return new_meta
# No changes, use existing
if current_meta:
_manifest_cache[pack_name] = current_meta
return current_meta
# Should not happen
raise Exception(f"Failed to scan pack {pack_name}")
def _is_valid_zip(path: Path) -> bool:
"""Check if a file is a valid ZIP archive"""
try:
with zipfile.ZipFile(path, 'r') as zf:
return zf.testzip() is None
except zipfile.BadZipFile:
return False
except Exception:
return False
def get_merged_manifest(pack_name: str, preset: str) -> PackMeta:
"""Get manifest merged with preset files. Preset files override base files."""
base_meta = get_cached_manifest(pack_name)
if not base_meta:
raise FileNotFoundError(f"Pack {pack_name} not found")
preset_dir = PACKS_DIR / pack_name / "presets" / preset
if not preset_dir.exists() or not preset_dir.is_dir():
raise FileNotFoundError(f"Preset {preset} not found for pack {pack_name}")
merged_files = dict(base_meta.files)
for root, dirs, files in os.walk(preset_dir):
for file in files:
file_path = Path(root) / file
rel_path = file_path.relative_to(preset_dir).as_posix()
stat = file_path.stat()
file_hash = calculate_sha256_sync(file_path)
merged_files[rel_path] = FileEntry(
path=rel_path,
hash=file_hash,
size=stat.st_size,
added_at=datetime.utcfromtimestamp(stat.st_ctime),
modified_at=datetime.utcfromtimestamp(stat.st_mtime)
)
return PackMeta(
pack_name=base_meta.pack_name,
version=base_meta.version,
files=merged_files,
updated_at=base_meta.updated_at,
ignored_dirs=base_meta.ignored_dirs,
minecraft_version=base_meta.minecraft_version,
loader_type=base_meta.loader_type,
loader_version=base_meta.loader_version,
asset_index=base_meta.asset_index
)