Both | БЛЯЯЯ ЗАГРУЗКА ПАКОВ С СЕРВЕРА СЮДААА
This commit is contained in:
@@ -102,8 +102,9 @@ def setup_logging():
|
||||
structlog.processors.CallsiteParameter.MODULE,
|
||||
structlog.processors.CallsiteParameter.FUNC_NAME,
|
||||
}
|
||||
),
|
||||
),
|
||||
_add_location,
|
||||
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||
],
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
|
||||
+222
-96
@@ -1,5 +1,3 @@
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -7,34 +5,32 @@ from pathlib import Path
|
||||
import json
|
||||
import structlog
|
||||
from cachetools import TTLCache
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
|
||||
|
||||
# Import local modules
|
||||
from pack_manager import DATA_DIR, get_fabric_versions, get_forge_versions, scan_pack, get_cached_manifest, PACKS_DIR
|
||||
from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR
|
||||
from models import PackMeta
|
||||
from logging_config import setup_logging
|
||||
from middleware import LoggingMiddleware
|
||||
from cli import parse_args, run_test_mode, run_production_mode, run_development_mode
|
||||
from log_manager import init_logging, get_logger
|
||||
|
||||
from models import MinecraftVersion
|
||||
from pack_manager import get_minecraft_versions, download_minecraft_version
|
||||
from log_manager import init_logging
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Cache for manifests - expires after 5 minutes
|
||||
manifest_cache = TTLCache(maxsize=100, ttl=300)
|
||||
|
||||
BUILDS_DIR = Path("builds")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
args = parse_args()
|
||||
|
||||
# Initialize logging
|
||||
init_logging()
|
||||
logger = logging.getLogger(__name__)
|
||||
#logger = logging.getLogger(__name__)
|
||||
|
||||
# Determine environment
|
||||
if args.test:
|
||||
@@ -46,6 +42,11 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
logger.info(f"Starting ZernMC Launcher Server (environment: {env})")
|
||||
|
||||
# Create directories if they don't exist
|
||||
BUILDS_DIR.mkdir(exist_ok=True)
|
||||
PACKS_DIR.mkdir(exist_ok=True)
|
||||
DATA_DIR.mkdir(exist_ok=True)
|
||||
|
||||
if args.test:
|
||||
await run_test_mode()
|
||||
yield
|
||||
@@ -69,6 +70,7 @@ async def lifespan(app: FastAPI):
|
||||
yield
|
||||
logger.info("Server shutting down...")
|
||||
|
||||
|
||||
# Create app with lifespan
|
||||
app = FastAPI(title="ZernMC Launcher Server", lifespan=lifespan)
|
||||
|
||||
@@ -85,17 +87,35 @@ def patched_data_received(self, data):
|
||||
except Exception as e:
|
||||
client = self.transport.get_extra_info('peername')
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"Invalid HTTP request from {client}: {str(e)[:200]}")
|
||||
# Log raw data if possible
|
||||
|
||||
# Показываем первые 200 байт запроса в HEX для диагностики
|
||||
hex_preview = data[:100].hex() if len(data) > 0 else "empty"
|
||||
|
||||
logger.error(f"Invalid HTTP request from {client}")
|
||||
logger.error(f"Error: {str(e)}")
|
||||
logger.error(f"First 100 bytes (hex): {hex_preview}")
|
||||
|
||||
try:
|
||||
raw_data = data[:500].decode('utf-8', errors='replace')
|
||||
logger.debug(f"Raw request data: {raw_data}")
|
||||
logger.error(f"Raw request data: {repr(raw_data)}")
|
||||
except:
|
||||
pass
|
||||
raise
|
||||
|
||||
# Не перевыбрасываем исключение, а возвращаем 400 ответ
|
||||
# Это важно! Иначе клиент не получит ответ
|
||||
try:
|
||||
response = b"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\nContent-Length: 21\r\n\r\nInvalid HTTP request"
|
||||
self.transport.write(response)
|
||||
self.transport.close()
|
||||
except:
|
||||
pass
|
||||
return
|
||||
|
||||
HttpToolsProtocol.data_received = patched_data_received
|
||||
|
||||
|
||||
# ====================== ОСНОВНЫЕ ЭНДПОИНТЫ ======================
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint"""
|
||||
@@ -108,12 +128,15 @@ async def root():
|
||||
"redoc": "/redoc"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""Health check endpoint"""
|
||||
return {"status": "healthy", "timestamp": datetime.utcnow().isoformat()}
|
||||
|
||||
|
||||
# ====================== ЭНДПОИНТЫ ДЛЯ ПАКОВ ======================
|
||||
|
||||
@app.get("/packs")
|
||||
async def list_packs():
|
||||
"""List all available packs"""
|
||||
@@ -125,13 +148,21 @@ async def list_packs():
|
||||
meta_path = DATA_DIR / f"{pack_dir.name}.meta"
|
||||
if meta_path.exists():
|
||||
try:
|
||||
async with open(meta_path, 'r', encoding='utf-8') as f:
|
||||
with open(meta_path, 'r', encoding='utf-8') as f:
|
||||
meta = json.load(f)
|
||||
# Исправлено: конвертируем updated_at в строку если это datetime
|
||||
updated_at = meta.get("updated_at")
|
||||
if updated_at and isinstance(updated_at, datetime):
|
||||
updated_at = updated_at.isoformat()
|
||||
|
||||
packs.append({
|
||||
"name": pack_dir.name,
|
||||
"version": meta.get("version", 1),
|
||||
"files_count": len(meta.get("files", {})),
|
||||
"updated_at": meta.get("updated_at")
|
||||
"updated_at": updated_at,
|
||||
"minecraft_version": meta.get("minecraft_version", "unknown"),
|
||||
"loader_type": meta.get("loader_type", "vanilla"),
|
||||
"loader_version": meta.get("loader_version")
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load pack meta for {pack_dir.name}: {e}")
|
||||
@@ -147,22 +178,28 @@ async def list_packs():
|
||||
|
||||
return {"packs": packs}
|
||||
|
||||
# ------------------- DIFF ENDPOINT -------------------
|
||||
|
||||
@app.post("/pack/{pack_name}/diff")
|
||||
async def get_pack_diff(pack_name: str, client_files: dict[str, str], request: Request):
|
||||
async def get_pack_diff(pack_name: str, request: Request):
|
||||
"""
|
||||
Client sends: { "mods/jei.jar": "sha256_hash", ... }
|
||||
Server returns diff information
|
||||
"""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
|
||||
# Читаем тело запроса
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse JSON body: {e}")
|
||||
raise HTTPException(400, "Invalid JSON body")
|
||||
|
||||
logger.info("Received diff request",
|
||||
pack=pack_name,
|
||||
client_files_count=len(client_files),
|
||||
client_files_count=len(body),
|
||||
client_ip=client_ip)
|
||||
|
||||
try:
|
||||
# Use cached manifest if available
|
||||
meta = get_cached_manifest(pack_name)
|
||||
if not meta:
|
||||
meta = await scan_pack(pack_name)
|
||||
@@ -171,7 +208,7 @@ async def get_pack_diff(pack_name: str, client_files: dict[str, str], request: R
|
||||
raise HTTPException(404, "Pack not found")
|
||||
except Exception as e:
|
||||
logger.error("Error loading pack meta", pack=pack_name, error=str(e), exc_info=True)
|
||||
raise HTTPException(500, "Internal server error")
|
||||
raise HTTPException(500, f"Internal server error: {str(e)}")
|
||||
|
||||
to_download = []
|
||||
to_delete = []
|
||||
@@ -179,9 +216,8 @@ async def get_pack_diff(pack_name: str, client_files: dict[str, str], request: R
|
||||
|
||||
server_files = meta.files
|
||||
|
||||
# Calculate what needs to be downloaded or updated
|
||||
for path, entry in server_files.items():
|
||||
client_hash = client_files.get(path)
|
||||
client_hash = body.get(path)
|
||||
if client_hash is None or client_hash != entry.hash:
|
||||
url = f"/pack/{pack_name}/file/{path}"
|
||||
to_download.append({
|
||||
@@ -193,8 +229,7 @@ async def get_pack_diff(pack_name: str, client_files: dict[str, str], request: R
|
||||
if client_hash is not None:
|
||||
to_update.append(path)
|
||||
|
||||
# Calculate what needs to be deleted
|
||||
for path in client_files:
|
||||
for path in body:
|
||||
if path not in server_files:
|
||||
to_delete.append(path)
|
||||
|
||||
@@ -213,76 +248,34 @@ async def get_pack_diff(pack_name: str, client_files: dict[str, str], request: R
|
||||
"to_update": to_update
|
||||
}
|
||||
|
||||
@app.get("/minecraft/versions")
|
||||
async def list_minecraft_versions():
|
||||
"""List available Minecraft versions"""
|
||||
try:
|
||||
versions = await get_minecraft_versions()
|
||||
return {"versions": [v.model_dump() for v in versions]}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch Minecraft versions: {e}")
|
||||
raise HTTPException(500, "Failed to fetch versions")
|
||||
|
||||
@app.get("/minecraft/version/{version}")
|
||||
async def get_version_details(version: str):
|
||||
"""Get details for specific Minecraft version"""
|
||||
# This would fetch version JSON from Mojang
|
||||
return {"version": version, "status": "available"}
|
||||
|
||||
@app.post("/minecraft/download/{version}")
|
||||
async def download_version(version: str, request: Request):
|
||||
"""Download Minecraft version"""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
logger.info(f"Download request for Minecraft {version}", client_ip=client_ip)
|
||||
|
||||
version_path = Path("versions") / version
|
||||
version_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
success = await download_minecraft_version(version, version_path)
|
||||
if success:
|
||||
return {"status": "success", "version": version}
|
||||
raise HTTPException(404, "Version not found")
|
||||
|
||||
@app.get("/modloaders/{loader_type}")
|
||||
async def get_modloaders(loader_type: str, minecraft_version: str = None):
|
||||
"""Get available mod loaders"""
|
||||
if loader_type == "fabric":
|
||||
versions = await get_fabric_versions(minecraft_version) if minecraft_version else []
|
||||
return {"loader": "fabric", "versions": versions}
|
||||
elif loader_type == "forge":
|
||||
versions = await get_forge_versions(minecraft_version) if minecraft_version else []
|
||||
return {"loader": "forge", "versions": versions}
|
||||
elif loader_type == "vanilla":
|
||||
return {"loader": "vanilla", "versions": ["vanilla"]}
|
||||
raise HTTPException(400, "Invalid loader type")
|
||||
|
||||
@app.get("/pack/{pack_name}")
|
||||
async def get_pack_manifest(pack_name: str, request: Request):
|
||||
"""Get pack manifest with caching"""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
|
||||
# Check cache first
|
||||
cached_meta = get_cached_manifest(pack_name)
|
||||
if cached_meta:
|
||||
logger.debug("Manifest served from cache",
|
||||
pack=pack_name,
|
||||
version=cached_meta.version,
|
||||
client_ip=client_ip)
|
||||
|
||||
# Исправлено: конвертируем datetime в строку при сериализации
|
||||
return JSONResponse(
|
||||
content=cached_meta.model_dump(),
|
||||
content=cached_meta.model_dump(mode='json'),
|
||||
headers={"X-Pack-Version": str(cached_meta.version), "X-Cached": "true"}
|
||||
)
|
||||
|
||||
# Load from disk if not in cache
|
||||
meta_path = Path("data") / f"{pack_name}.meta"
|
||||
if not meta_path.exists():
|
||||
logger.warning("Manifest requested but pack not found", pack=pack_name, client_ip=client_ip)
|
||||
raise HTTPException(404, "Pack not found")
|
||||
|
||||
async with open(meta_path, 'r', encoding='utf-8') as f:
|
||||
with open(meta_path, 'r', encoding='utf-8') as f:
|
||||
meta_dict = json.load(f)
|
||||
# Исправлено: используем model_validate для создания объекта
|
||||
meta = PackMeta.model_validate(meta_dict)
|
||||
# Update cache
|
||||
manifest_cache[pack_name] = meta
|
||||
|
||||
logger.debug("Manifest served from disk",
|
||||
@@ -290,16 +283,23 @@ async def get_pack_manifest(pack_name: str, request: Request):
|
||||
version=meta.version,
|
||||
client_ip=client_ip)
|
||||
|
||||
# Исправлено: конвертируем datetime в строку при сериализации
|
||||
return JSONResponse(
|
||||
content=meta_dict,
|
||||
content=meta.model_dump(mode='json'),
|
||||
headers={"X-Pack-Version": str(meta.version), "X-Cached": "false"}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/pack/{pack_name}/file/{file_path:path}")
|
||||
async def get_pack_file(pack_name: str, file_path: str, request: Request):
|
||||
"""Get a file from a pack"""
|
||||
full_path = PACKS_DIR / pack_name / file_path
|
||||
client_ip = request.client.host if request.client else None
|
||||
|
||||
# Security: prevent path traversal
|
||||
if ".." in file_path:
|
||||
raise HTTPException(403, "Invalid file path")
|
||||
|
||||
if not full_path.exists() or not full_path.is_file():
|
||||
logger.warning("File not found",
|
||||
pack=pack_name,
|
||||
@@ -316,51 +316,177 @@ async def get_pack_file(pack_name: str, file_path: str, request: Request):
|
||||
return FileResponse(full_path)
|
||||
|
||||
|
||||
# ====================== ЭНДПОИНТЫ ДЛЯ ЛАУНЧЕРА ======================
|
||||
|
||||
def get_current_launcher_version() -> str:
|
||||
"""Get current launcher version from build.version file"""
|
||||
version_file = BUILDS_DIR / "build.version"
|
||||
if version_file.exists():
|
||||
return version_file.read_text().strip()
|
||||
return "1.0.0"
|
||||
|
||||
|
||||
def get_available_zips() -> list:
|
||||
"""Get list of available zip archives"""
|
||||
if not BUILDS_DIR.exists():
|
||||
return []
|
||||
|
||||
zips = []
|
||||
for zip_file in BUILDS_DIR.glob("ZernMCLauncher-*.zip"):
|
||||
version = zip_file.stem.replace("ZernMCLauncher-", "")
|
||||
stat = zip_file.stat()
|
||||
zips.append({
|
||||
"version": version,
|
||||
"filename": zip_file.name,
|
||||
"size": stat.st_size,
|
||||
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat()
|
||||
})
|
||||
|
||||
zips.sort(key=lambda x: x["version"], reverse=True)
|
||||
return zips
|
||||
|
||||
|
||||
@app.get("/launcher/version")
|
||||
async def get_launcher_version():
|
||||
"""Возвращает информацию о текущей версии лаунчера"""
|
||||
version_file = Path("builds/build.version")
|
||||
"""Return launcher version information"""
|
||||
version = get_current_launcher_version()
|
||||
zips = get_available_zips()
|
||||
|
||||
version = "1.0.0"
|
||||
if version_file.exists():
|
||||
version = version_file.read_text().strip()
|
||||
|
||||
return {
|
||||
response = {
|
||||
"version": version,
|
||||
"download_jar": "/launcher/download?type=jar",
|
||||
"download_exe": "/launcher/download?type=exe",
|
||||
"updated_at": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
jar_path = BUILDS_DIR / "ZernMCLauncher.jar"
|
||||
if jar_path.exists():
|
||||
response["download_jar"] = "/launcher/download/jar"
|
||||
response["jar_size"] = jar_path.stat().st_size
|
||||
|
||||
exe_path = BUILDS_DIR / "ZernMCLauncher.exe"
|
||||
if exe_path.exists():
|
||||
response["download_exe"] = "/launcher/download/exe"
|
||||
response["exe_size"] = exe_path.stat().st_size
|
||||
|
||||
if zips:
|
||||
response["download_zip"] = f"/launcher/download/zip/{zips[0]['filename']}"
|
||||
response["zip_version"] = zips[0]["version"]
|
||||
response["zip_size"] = zips[0]["size"]
|
||||
response["all_zips"] = zips
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/launcher/download")
|
||||
async def download_launcher(type: str = "exe"):
|
||||
"""Отдаёт файл лаунчера"""
|
||||
if type == "exe":
|
||||
file_path = Path("builds/ZernMCLauncher.exe")
|
||||
filename = "ZernMCLauncher.exe"
|
||||
else:
|
||||
file_path = Path("builds/ZernMCLauncher.jar")
|
||||
filename = "ZernMCLauncher.jar"
|
||||
|
||||
@app.get("/launcher/download/jar")
|
||||
async def download_launcher_jar():
|
||||
"""Download launcher JAR file"""
|
||||
file_path = BUILDS_DIR / "ZernMCLauncher.jar"
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "Launcher file not found")
|
||||
raise HTTPException(404, "JAR file not found")
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename="ZernMCLauncher.jar",
|
||||
media_type="application/java-archive"
|
||||
)
|
||||
|
||||
|
||||
@app.get("/launcher/download/exe")
|
||||
async def download_launcher_exe():
|
||||
"""Download launcher EXE file (Windows)"""
|
||||
file_path = BUILDS_DIR / "ZernMCLauncher.exe"
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "EXE file not found")
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename="ZernMCLauncher.exe",
|
||||
media_type="application/vnd.microsoft.portable-executable"
|
||||
)
|
||||
|
||||
|
||||
@app.get("/launcher/download/zip/{filename}")
|
||||
async def download_launcher_zip(filename: str):
|
||||
"""Download specific launcher ZIP archive"""
|
||||
if ".." in filename or not filename.startswith("ZernMCLauncher-") or not filename.endswith(".zip"):
|
||||
raise HTTPException(400, "Invalid filename")
|
||||
|
||||
file_path = BUILDS_DIR / filename
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "ZIP file not found")
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=filename,
|
||||
media_type="application/octet-stream"
|
||||
media_type="application/zip"
|
||||
)
|
||||
|
||||
|
||||
@app.get("/launcher/download/latest")
|
||||
async def download_latest_launcher():
|
||||
"""Download the latest launcher (prefer ZIP if available, fallback to JAR)"""
|
||||
zips = get_available_zips()
|
||||
|
||||
if zips:
|
||||
latest_zip = zips[0]["filename"]
|
||||
return await download_launcher_zip(latest_zip)
|
||||
|
||||
jar_path = BUILDS_DIR / "ZernMCLauncher.jar"
|
||||
if jar_path.exists():
|
||||
return await download_launcher_jar()
|
||||
|
||||
raise HTTPException(404, "No launcher files available")
|
||||
|
||||
|
||||
@app.get("/launcher/info")
|
||||
async def get_launcher_full_info():
|
||||
"""Full launcher information with all available files"""
|
||||
version = get_current_launcher_version()
|
||||
zips = get_available_zips()
|
||||
|
||||
info = {
|
||||
"current_version": version,
|
||||
"updated_at": datetime.utcnow().isoformat(),
|
||||
"files": {
|
||||
"jar": None,
|
||||
"exe": None,
|
||||
"zips": zips
|
||||
},
|
||||
"recommended": "zip" if zips else ("exe" if (BUILDS_DIR / "ZernMCLauncher.exe").exists() else "jar")
|
||||
}
|
||||
|
||||
jar_path = BUILDS_DIR / "ZernMCLauncher.jar"
|
||||
if jar_path.exists():
|
||||
info["files"]["jar"] = {
|
||||
"size": jar_path.stat().st_size,
|
||||
"download_url": "/launcher/download/jar"
|
||||
}
|
||||
|
||||
exe_path = BUILDS_DIR / "ZernMCLauncher.exe"
|
||||
if exe_path.exists():
|
||||
info["files"]["exe"] = {
|
||||
"size": exe_path.stat().st_size,
|
||||
"download_url": "/launcher/download/exe"
|
||||
}
|
||||
|
||||
if zips:
|
||||
info["files"]["latest_zip"] = zips[0]
|
||||
info["files"]["download_latest"] = "/launcher/download/latest"
|
||||
|
||||
return info
|
||||
|
||||
|
||||
# ====================== ЗАПУСК ======================
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_args()
|
||||
|
||||
if args.test:
|
||||
# Test mode runs within lifespan
|
||||
import asyncio
|
||||
asyncio.run(run_test_mode())
|
||||
elif args.dev:
|
||||
run_development_mode(args.host, args.port, args.reload)
|
||||
else:
|
||||
# Default to production
|
||||
run_production_mode(args.host, args.port, args.workers)
|
||||
+46
-83
@@ -3,25 +3,17 @@ import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import json
|
||||
import aiofiles
|
||||
from typing import Optional, Dict
|
||||
import structlog
|
||||
|
||||
from models import PackMeta, FileEntry
|
||||
|
||||
import aiohttp
|
||||
from typing import List, Optional
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
PACKS_DIR = Path("packs")
|
||||
DATA_DIR = Path("data")
|
||||
DATA_DIR.mkdir(exist_ok=True)
|
||||
|
||||
MINECRAFT_VERSION_MANIFEST_URL = "https://launchermeta.mojang.com/mc/game/version_manifest.json"
|
||||
FABRIC_META_URL = "https://meta.fabricmc.net/v2/versions"
|
||||
FORGE_META_URL = "https://files.minecraftforge.net/net/minecraftforge/forge/"
|
||||
|
||||
# Cache for loaded manifests
|
||||
_manifest_cache: Dict[str, PackMeta] = {}
|
||||
|
||||
@@ -32,102 +24,63 @@ def get_cached_manifest(pack_name: str) -> Optional[PackMeta]:
|
||||
def get_meta_path(pack_name: str) -> Path:
|
||||
return DATA_DIR / f"{pack_name}.meta"
|
||||
|
||||
async def calculate_sha256(file_path: Path) -> str:
|
||||
def calculate_sha256_sync(file_path: Path) -> str:
|
||||
"""Calculate SHA256 hash of a file (synchronous version)"""
|
||||
hash_sha = hashlib.sha256()
|
||||
async with aiofiles.open(file_path, 'rb') as f:
|
||||
while chunk := await f.read(8192):
|
||||
with open(file_path, 'rb') as f:
|
||||
while chunk := f.read(8192):
|
||||
hash_sha.update(chunk)
|
||||
return hash_sha.hexdigest()
|
||||
|
||||
async def get_minecraft_versions():
|
||||
"""Fetch available Minecraft versions from Mojang"""
|
||||
from models import MinecraftVersion
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(MINECRAFT_VERSION_MANIFEST_URL) as response:
|
||||
data = await response.json()
|
||||
versions = []
|
||||
for v in data.get("versions", []):
|
||||
versions.append(MinecraftVersion(
|
||||
version=v["id"],
|
||||
type=v["type"],
|
||||
release_time=datetime.fromisoformat(v["releaseTime"].replace('Z', '+00:00')),
|
||||
url=v["url"]
|
||||
))
|
||||
return versions
|
||||
|
||||
async def get_fabric_versions(minecraft_version: str) -> List[str]:
|
||||
"""Get available Fabric versions for specific Minecraft version"""
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(f"{FABRIC_META_URL}/loader") as response:
|
||||
data = await response.json()
|
||||
fabric_versions = []
|
||||
for loader in data:
|
||||
if loader.get("stable", True):
|
||||
fabric_versions.append(loader["version"])
|
||||
return fabric_versions
|
||||
|
||||
async def get_forge_versions(minecraft_version: str) -> List[str]:
|
||||
"""Get available Forge versions for specific Minecraft version"""
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(FORGE_META_URL) as response:
|
||||
# Forge API is more complex, simplified for now
|
||||
return []
|
||||
|
||||
async def download_minecraft_version(version: str, target_path: Path) -> bool:
|
||||
"""Download Minecraft version JSON and client jar"""
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(f"https://piston-meta.mojang.com/mc/game/{version}/{version}.json") as response:
|
||||
if response.status == 200:
|
||||
version_data = await response.json()
|
||||
async with aiofiles.open(target_path / f"{version}.json", 'w') as f:
|
||||
await f.write(json.dumps(version_data, indent=2))
|
||||
|
||||
downloads = version_data.get("downloads", {})
|
||||
client_info = downloads.get("client", {})
|
||||
if client_info:
|
||||
client_url = client_info.get("url")
|
||||
async with session.get(client_url) as client_response:
|
||||
if client_response.status == 200:
|
||||
async with aiofiles.open(target_path / f"{version}.jar", 'wb') as f:
|
||||
async for chunk in client_response.content.iter_chunked(8192):
|
||||
await f.write(chunk)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def calculate_sha256(file_path: Path) -> str:
|
||||
"""Calculate SHA256 hash of a file (async wrapper)"""
|
||||
# Используем синхронную версию для простоты
|
||||
return 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():
|
||||
async with aiofiles.open(meta_path, 'r', encoding='utf-8') as f:
|
||||
data = json.loads(await f.read())
|
||||
try:
|
||||
try:
|
||||
with open(meta_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
current_meta = PackMeta.model_validate(data)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to validate existing meta for pack {pack_name}", error=str(e))
|
||||
current_meta = None
|
||||
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
|
||||
ignored_dirs = current_meta.ignored_dirs if current_meta else [
|
||||
"resourcepacks", "shaderpacks", "saves", "logs",
|
||||
"crash-reports", "screenshots", "journeymap", "config"
|
||||
]
|
||||
|
||||
# Walk through pack directory
|
||||
for root, dirs, files in os.walk(pack_path):
|
||||
ignored = current_meta.ignored_dirs if current_meta else []
|
||||
dirs[:] = [d for d in dirs if d not in ignored]
|
||||
# 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()
|
||||
|
||||
if any(ignored_dir in rel_path.split('/') for ignored_dir in ignored):
|
||||
# Skip if in ignored directory
|
||||
if any(ignored_dir in rel_path.split('/') for ignored_dir in ignored_dirs):
|
||||
continue
|
||||
|
||||
stat = file_path.stat()
|
||||
@@ -143,48 +96,58 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
|
||||
|
||||
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
|
||||
|
||||
pack_config_path = pack_path / "instance.json"
|
||||
# Load instance.json for pack metadata
|
||||
minecraft_version = "1.20.4"
|
||||
loader_type = "vanilla"
|
||||
loader_version = 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())
|
||||
# Синхронное чтение конфига
|
||||
with open(pack_config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
minecraft_version = config.get("minecraftVersion", minecraft_version)
|
||||
loader_type = config.get("loaderType", loader_type)
|
||||
loader_version = config.get("loaderVersion")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load instance.json for {pack_name}", error=str(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=current_meta.ignored_dirs if current_meta else [],
|
||||
ignored_dirs=ignored_dirs,
|
||||
minecraft_version=minecraft_version,
|
||||
loader_type=loader_type,
|
||||
loader_version=loader_version
|
||||
)
|
||||
|
||||
async with aiofiles.open(meta_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(new_meta.model_dump_json(indent=2))
|
||||
# Save to disk (синхронно)
|
||||
with open(meta_path, 'w', encoding='utf-8') as f:
|
||||
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
|
||||
|
||||
return current_meta
|
||||
# Should not happen
|
||||
raise Exception(f"Failed to scan pack {pack_name}")
|
||||
Reference in New Issue
Block a user