Launcher UI redesign + server mirror sync + file download optimization

This commit is contained in:
SashegDev
2026-05-09 23:47:04 +00:00
parent 59480217aa
commit a8f3ca5049
10 changed files with 2266 additions and 703 deletions
+39 -1
View File
@@ -15,7 +15,8 @@ def parse_args():
mode_group.add_argument("--dev", action="store_true", help="Development mode with auto-reload")
mode_group.add_argument("--prod", action="store_true", help="Production mode with 4 workers")
mode_group.add_argument("--test", action="store_true", help="Test mode - validate builds and generate manifests")
mode_group.add_argument("--sync", action="store_true", help="Sync mode - sync with main server as mirror")
# Additional options
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)")
parser.add_argument("--port", type=int, default=1582, help="Port to bind to (default: 1582)")
@@ -53,6 +54,43 @@ async def run_test_mode():
logger.info("All packs validated successfully")
sys.exit(0)
async def run_sync_mode():
"""Sync with main server as mirror"""
import os
main_url = os.environ.get("MAIN_SERVER_URL")
if not main_url:
logger.error("MAIN_SERVER_URL not set. Run: MAIN_SERVER_URL=http://main:1582 python cli.py --sync")
sys.exit(1)
logger.info(f"Starting mirror sync from {main_url}")
# Get version from main
import httpx
async with httpx.AsyncClient() as client:
# Get version
try:
resp = await client.get(f"{main_url}/launcher/version")
data = resp.json()
version = data.get("version")
logger.info(f"Main server version: {version}")
except Exception as e:
logger.error(f"Failed to get version from main: {e}")
sys.exit(1)
# Get sync manifest
try:
resp = await client.get(f"{main_url}/launcher/sync/{version}")
sync_data = resp.json()
logger.info(f"Files to sync: {len(sync_data.get('files', []))}")
except Exception as e:
logger.error(f"Failed to get sync manifest: {e}")
sys.exit(1)
# Sync happens during server startup in mirror mode
# Just verify we can reach main
logger.info("Mirror sync configured. Server will sync on startup.")
def run_production_mode(host: str, port: int, workers: int):
"""Run with multiple workers"""
logger.info(f"Starting in PRODUCTION mode with {workers} workers on {host}:{port}")
+370 -40
View File
@@ -12,7 +12,7 @@ import json
import structlog
from cachetools import TTLCache
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, JSONResponse
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
# Disable httpx debug logging
@@ -22,13 +22,18 @@ logging.getLogger("httpcore").setLevel(logging.WARNING)
from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR
from models import PackMeta
from middleware import LoggingMiddleware
from cli import parse_args, run_test_mode, run_production_mode, run_development_mode
from cli import parse_args, run_test_mode, run_production_mode, run_development_mode, run_sync_mode
from log_manager import init_logging
from auth import get_current_user, router as auth_router, init_db, verify_jwt
from roles import Permissions, has_permission
from admin_router import router as admin_router
import asyncio
import hashlib
import aiofiles
import mimetypes
logger = structlog.get_logger(__name__)
# Cache for manifests - expires after 5 minutes
@@ -37,6 +42,18 @@ manifest_cache = TTLCache(maxsize=100, ttl=300)
BUILDS_DIR = Path("builds")
VERSIONS_DIR = BUILDS_DIR / "versions"
# Mirror configuration
LAUNCHER_MIRRORS = {
"main": "http://87.120.187.36:1582",
"mirror-1": "http://212.22.82.243:1582",
}
# Server role: "main" or "mirror"
SERVER_ROLE = os.environ.get("SERVER_ROLE", "main")
MAIN_SERVER_URL = os.environ.get("MAIN_SERVER_URL", "") # For mirrors to sync from
SYNC_API_KEY = os.environ.get("SYNC_API_KEY", "changeme") # API key for mirror sync
MASTER_KEY = os.environ.get("MASTER_KEY", "sashegdevsupeddevepta") # Master key for admin/mirror
# IP Filtering Configuration
import os
import middleware as mw
@@ -147,7 +164,51 @@ async def lifespan(app: FastAPI):
logger.error(f"Failed to scan pack: {pack_dir.name} - {e}", exc_info=True)
logger.info("All packs ready. Server is running.")
# Mirror sync with main server
if SERVER_ROLE == "mirror" and MAIN_SERVER_URL:
logger.info(f"Mirror mode: syncing from {MAIN_SERVER_URL}")
try:
async with httpx.AsyncClient() as client:
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/version")
main_data = resp.json()
main_version = main_data.get("version")
logger.info(f"Main server version: {main_version}")
# Get sync manifest with API key
headers = {"X-Sync-Key": SYNC_API_KEY}
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/sync/{main_version}", headers=headers)
if resp.status_code != 200:
logger.warning(f"Sync failed: {resp.status_code} - {resp.text}")
raise Exception(f"Sync auth failed: {resp.status_code}")
sync_data = resp.json()
logger.info(f"Need to sync {len(sync_data.get('files', []))} files")
# Download each file
for f in sync_data.get("files", []):
file_path = BUILDS_DIR / f["path"]
if not file_path.exists():
logger.info(f"Syncing: {f['path']}")
file_path.parent.mkdir(parents=True, exist_ok=True)
# Download file
file_url = f"{MAIN_SERVER_URL}/launcher/sync/{main_version}/file/{f['path']}"
resp = await client.get(file_url, headers=headers)
file_path.write_bytes(resp.content)
logger.debug(f"Downloaded: {f['path']}")
# Delete removed files
for deleted_file in sync_data.get("delete", []):
del_path = BUILDS_DIR / deleted_file
if del_path.exists():
del_path.unlink()
logger.info(f"Deleted: {deleted_file}")
logger.info("Mirror sync complete")
except Exception as e:
logger.warning(f"Mirror sync failed: {e}")
# Scan launcher versions and generate meta
logger.info("Scanning launcher versions...")
@@ -172,6 +233,54 @@ async def lifespan(app: FastAPI):
global proxy_client
proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
# Start background sync task for mirrors
if SERVER_ROLE == "mirror" and MAIN_SERVER_URL:
import asyncio
async def periodic_sync():
sync_interval = 7200 # 2 hours
while True:
await asyncio.sleep(sync_interval)
try:
logger.info("Periodic mirror sync started...")
headers = {"X-Sync-Key": SYNC_API_KEY}
resp = await proxy_client.get(f"{MAIN_SERVER_URL}/launcher/version")
main_data = resp.json()
main_version = main_data.get("version")
resp = await proxy_client.get(
f"{MAIN_SERVER_URL}/launcher/sync/{main_version}",
headers=headers
)
if resp.status_code != 200:
logger.warning(f"Periodic sync failed: {resp.status_code}")
continue
sync_data = resp.json()
logger.info(f"Periodic sync: {len(sync_data.get('files', []))} files")
for f in sync_data.get("files", []):
file_path = BUILDS_DIR / f["path"]
if not file_path.exists():
file_path.parent.mkdir(parents=True, exist_ok=True)
file_url = f"{MAIN_SERVER_URL}/launcher/sync/{main_version}/file/{f['path']}"
resp = await proxy_client.get(file_url, headers=headers)
file_path.write_bytes(resp.content)
logger.debug(f"Synced: {f['path']}")
for deleted_file in sync_data.get("delete", []):
del_path = BUILDS_DIR / deleted_file
if del_path.exists():
del_path.unlink()
logger.info(f"Deleted: {deleted_file}")
logger.info("Periodic mirror sync complete")
except Exception as e:
logger.warning(f"Periodic sync error: {e}")
asyncio.create_task(periodic_sync())
yield
# Cleanup proxy client
@@ -512,6 +621,136 @@ app = FastAPI(title="ZernMC Launcher Server", lifespan=lifespan)
# Add Logging middleware
app.add_middleware(LoggingMiddleware)
# ====================== ОПТИМИЗАЦИЯ ЗАГРУЗКИ ФАЙЛОВ ======================
class CacheControlMiddleware:
"""Middleware for caching static and large files"""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
path = scope.get("path", "")
# Skip caching for dynamic endpoints
skip_cache = any(p in path for p in ["/api/", "/auth/", "/login", "/launch", "/install"])
if skip_cache:
await self.app(scope, receive, send)
return
# Add caching headers for static files
async def send_wrapper(status, headers, *args, **kwargs):
# Add cache headers for static files
cache_headers = [
(b"cache-control", b"public, max-age=86400"), # 24 hours
(b"etag", b'"file-etag"'),
]
headers = list(headers) + cache_headers
await send(status, headers, *args, **kwargs)
# Use original send
await self.app(scope, receive, send)
app.add_middleware(CacheControlMiddleware)
# Cache for file hashes (ETag)
file_etag_cache = TTLCache(maxsize=1000, ttl=3600)
async def get_etag_for_file(file_path: Path) -> str:
"""Get or calculate ETag for file"""
cache_key = str(file_path)
if cache_key in file_etag_cache:
return file_etag_cache[cache_key]
# Calculate from file size + mtime
stat = file_path.stat()
etag = f'"{stat.st_size}-{stat.st_mtime}"'
file_etag_cache[cache_key] = etag
return etag
async def send_file_async(
file_path: Path,
request: Request,
content_type: str = None,
cache: bool = True
):
"""Optimized async file serving with Range support"""
if not file_path.exists():
raise HTTPException(404, "File not found")
file_size = file_path.stat().st_size
# Determine content type
if content_type is None:
content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
# Check for Range header (for resumable downloads)
range_header = request.headers.get("range")
if range_header:
# Parse Range header
match = re.match(r"bytes=(\d+)-(\d+)?", range_header)
if match:
start = int(match.group(1))
end = min(file_size - 1, int(match.group(2)) if match.group(2) else file_size - 1)
else:
start, end = 0, file_size - 1
content_length = end - start + 1
# Read chunk asynchronously
async with aiofiles.open(file_path, "rb") as f:
await f.seek(start)
chunk = await f.read(content_length)
# Return 206 Partial Content
return StreamingResponse(
iter([chunk]),
status_code=206,
media_type=content_type,
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(content_length),
"Cache-Control": "public, max-age=86400" if cache else "no-cache",
}
)
else:
# Return full file with streaming
async def file_iterator():
async with aiofiles.open(file_path, "rb") as f:
while True:
chunk = await f.read(65536) # 64KB chunks
if not chunk:
break
yield chunk
# Calculate ETag
etag = await get_etag_for_file(file_path)
return StreamingResponse(
file_iterator(),
media_type=content_type,
headers={
"Accept-Ranges": "bytes",
"Content-Length": str(file_size),
"Cache-Control": "public, max-age=86400" if cache else "no-cache",
"ETag": etag,
"X-Content-Type-Options": "nosniff",
}
)
# Register routers
app.include_router(auth_router)
app.include_router(admin_router)
@@ -583,18 +822,23 @@ async def activate_pass_page():
# ====================== ЭНДПОИНТЫ ДЛЯ ПАКОВ ======================
@app.get("/packs")
async def list_packs(current_user: dict = Depends(get_current_user)):
"""List all available packs - требует проходку для просмотра"""
# Проверяем, есть ли право на просмотр сборок
if not has_permission(current_user["role"], Permissions.VIEW_PACKS):
raise HTTPException(
status_code=403,
detail="Для просмотра сборок требуется активная проходка"
)
async def list_packs(request: Request):
"""List all available packs - requires auth or master key for mirrors"""
# Check for master key
master_key = request.headers.get("X-Master-Key")
if master_key == MASTER_KEY:
# Master key - allow access
pass
else:
# Normal auth required
current_user = await get_current_user(request)
if not current_user:
raise HTTPException(401, "Authentication required")
if not has_permission(current_user["role"], Permissions.VIEW_PACKS):
raise HTTPException(403, "Requires active pass")
packs = []
for pack_dir in PACKS_DIR.iterdir():
if pack_dir.is_dir():
meta_path = DATA_DIR / f"{pack_dir.name}.meta"
@@ -770,13 +1014,13 @@ async def get_pack_file(pack_name: str, file_path: str, request: Request):
client_ip=client_ip)
raise HTTPException(404, "File not found")
logger.info("Serving file",
pack=pack_name,
file=file_path,
logger.info("Serving file",
pack=pack_name,
file=file_path,
size=full_path.stat().st_size,
client_ip=client_ip)
return FileResponse(full_path, direct_passthrough=True)
return await send_file_async(full_path, request, cache=True)
# ====================== ЭНДПОИНТЫ ДЛЯ ЛАУНЧЕРА ======================
@@ -877,11 +1121,14 @@ def generate_launcher_builds_meta():
logger.warning(f"Failed to generate launcher meta: {e}")
return
mirrors = [{"name": name, "url": url} for name, url in LAUNCHER_MIRRORS.items()]
meta = {
"version": version,
"type": "builds",
"release_date": datetime.utcnow().isoformat(),
"files": files
"files": files,
"mirrors": mirrors
}
try:
@@ -1128,16 +1375,16 @@ async def get_launcher_version():
@app.get("/launcher/download/jar")
async def download_launcher_jar():
async def download_launcher_jar(request: Request = None):
"""Download launcher JAR file"""
# Prefer new shaded JAR, fallback to old
file_path = BUILDS_DIR / "zernmclauncher.jar"
if not file_path.exists():
file_path = BUILDS_DIR / "ZernMCLauncher.jar"
if not file_path.exists():
raise HTTPException(404, "JAR file not found")
if request:
return await send_file_async(file_path, request, content_type="application/java-archive", cache=True)
return FileResponse(
path=file_path,
filename="zernmclauncher.jar",
@@ -1146,13 +1393,16 @@ async def download_launcher_jar():
@app.get("/launcher/download/exe")
async def download_launcher_exe():
async def download_launcher_exe(request: Request = None):
"""Download launcher EXE file (Windows)"""
file_path = BUILDS_DIR / "ZernMCLauncher.exe"
if not file_path.exists():
raise HTTPException(404, "EXE file not found")
if request:
return await send_file_async(file_path, request, content_type="application/vnd.microsoft.portable-executable", cache=True)
return FileResponse(
path=file_path,
filename="ZernMCLauncher.exe",
@@ -1161,17 +1411,20 @@ async def download_launcher_exe():
@app.get("/launcher/download/zip/{filename}")
async def download_launcher_zip(filename: str):
async def download_launcher_zip(filename: str, request: Request = None):
"""Download specific launcher ZIP archive"""
valid_patterns = ["ZernMCLauncher-", "ZernMC-win-"]
if ".." in filename or not any(filename.startswith(p) for p in valid_patterns) 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")
if request:
return await send_file_async(file_path, request, content_type="application/zip", cache=True)
return FileResponse(
path=file_path,
filename=filename,
@@ -1233,12 +1486,82 @@ async def get_launcher_meta_list():
versions = get_launcher_versions()
return {
"versions": [
{"version": v["version"], "meta": v["meta"]}
{"version": v["version"], "meta": v["meta"]}
for v in versions
]
}
@app.get("/launcher/mirrors")
async def get_launcher_mirrors():
"""Get list of available mirrors"""
mirrors_list = [{"name": name, "url": url} for name, url in LAUNCHER_MIRRORS.items()]
return {"mirrors": mirrors_list}
# ====================== SYNC FOR MIRRORS ======================
def verify_sync_api_key(request: Request):
"""Verify API key for sync endpoints"""
api_key = request.headers.get("X-Sync-Key")
if not api_key or api_key != SYNC_API_KEY:
raise HTTPException(401, "Invalid or missing sync API key")
@app.get("/launcher/sync/{version}")
async def get_sync_manifest(version: str, request: Request):
"""Get sync manifest for mirror servers - returns files to download/delete"""
verify_sync_api_key(request)
if SERVER_ROLE != "main":
raise HTTPException(403, "Sync only available on main server")
# Check for incremental sync (if-modified-since)
last_sync = request.headers.get("If-Modified-Since")
# Get server meta
meta = get_launcher_version_meta(version)
if not meta:
raise HTTPException(404, f"Version {version} not found")
# Build sync response
sync_data = {
"version": version,
"files": [],
"delete": [], # Files that were removed from main
"timestamp": datetime.utcnow().isoformat()
}
for f in meta.get("files", []):
sync_data["files"].append({
"path": f["path"],
"size": f["size"],
"hash": f["hash"],
"url": f"/launcher/file/{version}/{f['path']}"
})
return sync_data
@app.get("/launcher/sync/{version}/file/{file_path:path}")
async def sync_download_file(version: str, file_path: str, request: Request):
"""Download file for mirror sync"""
verify_sync_api_key(request)
if SERVER_ROLE != "main":
raise HTTPException(403, "Sync only available on main server")
full_path = BUILDS_DIR / file_path
if ".." in file_path:
raise HTTPException(403, "Invalid path")
if not full_path.exists():
raise HTTPException(404, "File not found")
return await send_file_async(full_path, request, cache=False)
@app.get("/launcher/meta/{version}")
async def get_launcher_version_meta_handler(version: str):
"""Get meta for specific launcher version"""
@@ -1305,18 +1628,22 @@ async def get_launcher_file(version: str, file_path: str, request: Request):
full_path = alt_path
else:
raise HTTPException(404, "File not found: " + file_path)
return FileResponse(full_path, direct_passthrough=True)
return await send_file_async(full_path, request, cache=True)
@app.get("/launcher/download/zip/{version}")
async def download_launcher_zip_version(version: str):
async def download_launcher_zip_version(version: str, request: Request = None):
"""Download full ZIP for specific version (for new installs)"""
zip_path = BUILDS_DIR / f"ZernMC-win-{version}.zip"
if not zip_path.exists():
raise HTTPException(404, f"ZIP for version {version} not found")
if request:
return await send_file_async(zip_path, request, content_type="application/zip", cache=True)
# Fallback without request
return FileResponse(
path=zip_path,
filename=f"ZernMC-win-{version}.zip",
@@ -1776,10 +2103,13 @@ async def global_exception_handler(request: Request, exc: Exception):
if __name__ == "__main__":
args = parse_args()
if args.test:
import asyncio
asyncio.run(run_test_mode())
elif args.sync:
import asyncio
asyncio.run(run_sync_mode())
elif args.dev:
run_development_mode(args.host, args.port, args.reload)
else:
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""
Lightweight Mirror Server - only serves static files
"""
import os
import asyncio
from pathlib import Path
import structlog
import httpx
MAIN_SERVER_URL = os.environ.get("MAIN_SERVER_URL", "http://87.120.187.36:1582")
MASTER_KEY = os.environ.get("MASTER_KEY", "sashegdevsupeddevepta")
PORT = int(os.environ.get("PORT", "1582"))
BUILDS_DIR = Path("builds")
VERSIONS_DIR = BUILDS_DIR / "versions"
PACKS_DIR = Path("packs")
BUILDS_DIR.mkdir(exist_ok=True)
PACKS_DIR.mkdir(exist_ok=True)
logging = structlog.get_logger()
async def sync_with_main():
"""Sync files from main server"""
logging.info(f"Syncing from {MAIN_SERVER_URL}")
client = httpx.AsyncClient(timeout=120.0)
headers = {"X-Master-Key": MASTER_KEY}
try:
# Get launcher info
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/info", headers=headers)
if resp.status_code != 200:
logging.error(f"Failed to get launcher info: {resp.status_code}")
return
data = resp.json()
current_version = data.get("current_version", "1.0.9")
files = data.get("files", {})
zips = files.get("zips", [])
logging.info(f"Current version: {current_version}, zips: {len(zips)}")
# Download latest ZIP
for z in zips:
if not z.get("is_legacy"):
zip_filename = z.get("filename")
zip_path = BUILDS_DIR / zip_filename
if not zip_path.exists():
logging.info(f"Downloading {zip_filename}...")
# Try direct download
download_url = f"{MAIN_SERVER_URL}/launcher/download/zip/{zip_filename}"
resp = await client.get(download_url, headers=headers)
if resp.status_code == 200:
zip_path.write_bytes(resp.content)
logging.info(f"Downloaded {zip_filename}")
# Extract
version = z.get("version")
extract_to = VERSIONS_DIR / version
extract_to.mkdir(parents=True, exist_ok=True)
import zipfile
with zipfile.ZipFile(zip_path, 'r') as zf:
zf.extractall(extract_to)
logging.info(f"Extracted {version}")
# Get launcher meta
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/meta/{current_version}", headers=headers)
if resp.status_code == 200:
(BUILDS_DIR / "meta.json").write_text(resp.text)
logging.info("Meta synced")
# Sync packs list
resp = await client.get(f"{MAIN_SERVER_URL}/packs", headers=headers)
if resp.status_code == 200:
packs_data = resp.json()
packs = packs_data.get("packs", [])
logging.info(f"Found {len(packs)} packs")
for pack in packs:
pack_name = pack.get("name")
pack_meta_url = f"{MAIN_SERVER_URL}/pack/meta/{pack_name}"
resp = await client.get(pack_meta_url, headers=headers)
if resp.status_code == 200:
pack_dir = PACKS_DIR / pack_name
pack_dir.mkdir(parents=True, exist_ok=True)
(pack_dir / "meta.json").write_text(resp.text)
logging.info(f"Synced pack: {pack_name}")
finally:
await client.aclose()
logging.info("Sync complete")
async def run_server():
"""Run static server"""
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import StreamingResponse
import aiofiles
import mimetypes
import re
import uvicorn
app = FastAPI(title="ZernMC Mirror")
async def send_file(file_path: Path, request: Request):
if not file_path.exists():
raise HTTPException(404, "Not found")
content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
file_size = file_path.stat().st_size
range_header = request.headers.get("range")
if range_header:
match = re.match(r"bytes=(\d+)-(\d+)?", range_header)
if match:
start = int(match.group(1))
end = min(file_size - 1, int(match.group(2)) if match.group(2) else file_size - 1)
content_length = end - start + 1
async with aiofiles.open(file_path, "rb") as f:
await f.seek(start)
chunk = await f.read(content_length)
return StreamingResponse(iter([chunk]), status_code=206, media_type=content_type,
headers={"Content-Range": f"bytes {start}-{end}/{file_size}", "Accept-Ranges": "bytes", "Content-Length": str(content_length)})
async def file_iter():
async with aiofiles.open(file_path, "rb") as f:
while True:
chunk = await f.read(65536)
if not chunk:
break
yield chunk
return StreamingResponse(file_iter(), media_type=content_type,
headers={"Accept-Ranges": "bytes", "Content-Length": str(file_size)})
@app.get("/launcher/info")
async def get_launcher_info():
meta_path = BUILDS_DIR / "meta.json"
if meta_path.exists():
import json
return json.loads(meta_path.read_text())
return {"current_version": "unknown", "files": {}}
@app.get("/launcher/version")
async def get_version():
return await get_launcher_info()
@app.get("/launcher/file/{version}/{file_path:path}")
async def get_launcher_file(version: str, file_path: str, request: Request):
full_path = BUILDS_DIR / "versions" / version / file_path
if ".." in file_path:
raise HTTPException(403, "Invalid path")
if not full_path.exists():
raise HTTPException(404, f"File not found: {file_path}")
return await send_file(full_path, request)
@app.get("/launcher/download/zip/{filename}")
async def download_zip(filename: str, request: Request):
return await send_file(BUILDS_DIR / filename, request)
@app.get("/launcher/meta/{version}")
async def get_meta(version: str):
meta_path = BUILDS_DIR / "meta.json"
if meta_path.exists():
import json
return json.loads(meta_path.read_text())
raise HTTPException(404, "Meta not found")
@app.get("/launcher/mirrors")
async def get_mirrors():
return {"mirrors": [{"name": "main", "url": MAIN_SERVER_URL}]}
@app.get("/packs")
async def list_packs():
import json
packs = []
for pack_dir in PACKS_DIR.iterdir():
if pack_dir.is_dir():
meta_path = pack_dir / "meta.json"
if meta_path.exists():
try:
meta = json.loads(meta_path.read_text())
packs.append({
"name": pack_dir.name,
"version": meta.get("version", 1),
"files_count": len(meta.get("files", {}))
})
except:
packs.append({"name": pack_dir.name, "error": "invalid"})
return {"packs": packs}
@app.get("/pack/{pack_name}")
async def get_pack(pack_name: str):
meta_path = PACKS_DIR / pack_name / "meta.json"
if meta_path.exists():
import json
return json.loads(meta_path.read_text())
raise HTTPException(404, "Pack not found")
@app.get("/pack/meta/{pack_name}")
async def get_pack_meta(pack_name: str):
return await get_pack(pack_name)
@app.get("/pack/{pack_name}/diff")
async def get_pack_diff(pack_name: str):
# For mirror, just return empty diff (no local changes)
return {"added": [], "removed": [], "changed": []}
@app.get("/pack/{pack_name}/file/{file_path:path}")
async def get_pack_file(pack_name: str, file_path: str, request: Request):
return await send_file(PACKS_DIR / pack_name / file_path, request)
config = uvicorn.Config(app, host="0.0.0.0", port=PORT, log_level="info")
server = uvicorn.Server(config)
await server.serve()
async def main():
logging.info("Starting ZernMC Mirror Server")
await sync_with_main()
await run_server()
if __name__ == "__main__":
asyncio.run(main())