server update
This commit is contained in:
@@ -0,0 +1,82 @@
|
|||||||
|
# cli.py
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser(description="ZernMC Launcher Server")
|
||||||
|
|
||||||
|
# Mode selection (mutually exclusive)
|
||||||
|
mode_group = parser.add_mutually_exclusive_group()
|
||||||
|
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")
|
||||||
|
|
||||||
|
# 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)")
|
||||||
|
parser.add_argument("--workers", type=int, default=4, help="Number of workers for production mode")
|
||||||
|
parser.add_argument("--reload", action="store_true", help="Enable auto-reload (development)")
|
||||||
|
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
async def run_test_mode():
|
||||||
|
"""Validate all packs and generate/update manifests"""
|
||||||
|
logger.info("Running in TEST mode - validating builds and generating manifests")
|
||||||
|
|
||||||
|
from pack_manager import scan_pack, PACKS_DIR
|
||||||
|
|
||||||
|
pack_count = 0
|
||||||
|
error_count = 0
|
||||||
|
|
||||||
|
for pack_dir in PACKS_DIR.iterdir():
|
||||||
|
if pack_dir.is_dir():
|
||||||
|
try:
|
||||||
|
logger.info(f"Validating pack: {pack_dir.name}")
|
||||||
|
meta = await scan_pack(pack_dir.name)
|
||||||
|
pack_count += 1
|
||||||
|
logger.info(f"✓ Pack validated: {pack_dir.name} v{meta.version}, {len(meta.files)} files")
|
||||||
|
except Exception as e:
|
||||||
|
error_count += 1
|
||||||
|
logger.error(f"✗ Failed to validate pack {pack_dir.name}", error=str(e), exc_info=True)
|
||||||
|
|
||||||
|
logger.info(f"Test mode completed: {pack_count} packs validated, {error_count} errors")
|
||||||
|
|
||||||
|
if error_count > 0:
|
||||||
|
logger.error("Some packs failed validation")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
logger.info("All packs validated successfully")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
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}")
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(
|
||||||
|
"main:app",
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
workers=workers,
|
||||||
|
log_config=None,
|
||||||
|
access_log=False # We have our own logging middleware
|
||||||
|
)
|
||||||
|
|
||||||
|
def run_development_mode(host: str, port: int, reload: bool = True):
|
||||||
|
"""Run with auto-reload for development"""
|
||||||
|
logger.info(f"Starting in DEVELOPMENT mode with reload={reload} on {host}:{port}")
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(
|
||||||
|
"main:app",
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
reload=reload,
|
||||||
|
log_config=None,
|
||||||
|
access_log=False
|
||||||
|
)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# http_logger.py
|
||||||
|
import logging
|
||||||
|
from fastapi import Request, Response
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
|
class HTTPLogger:
|
||||||
|
"""Custom HTTP logger to catch invalid requests"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def log_invalid_request(data: bytes, client_addr: tuple):
|
||||||
|
"""Log invalid HTTP requests"""
|
||||||
|
try:
|
||||||
|
# Try to decode as much as possible
|
||||||
|
request_str = data.decode('utf-8', errors='replace')[:500]
|
||||||
|
logger.warning(
|
||||||
|
f"Invalid HTTP request received\n"
|
||||||
|
f"Client: {client_addr[0]}:{client_addr[1]}\n"
|
||||||
|
f"Data: {request_str}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Invalid HTTP request from {client_addr}, could not decode: {e}")
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# log_manager.py
|
||||||
|
import logging
|
||||||
|
import structlog
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
|
LOG_DIR = Path("logs")
|
||||||
|
LOG_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Путь к latest.log
|
||||||
|
LATEST_LOG = LOG_DIR / "latest.log"
|
||||||
|
|
||||||
|
def rotate_logs():
|
||||||
|
"""Rotate logs without compression (like Minecraft)"""
|
||||||
|
log_files = sorted(LOG_DIR.glob("*.log"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||||
|
|
||||||
|
for old_log in log_files[10:]:
|
||||||
|
if old_log.name != "latest.log":
|
||||||
|
try:
|
||||||
|
old_log.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if LATEST_LOG.exists() and LATEST_LOG.stat().st_size > 0:
|
||||||
|
timestamp = datetime.fromtimestamp(LATEST_LOG.stat().st_mtime).strftime("%Y-%m-%d_%H-%M-%S")
|
||||||
|
backup_name = LOG_DIR / f"{timestamp}.log"
|
||||||
|
shutil.move(str(LATEST_LOG), str(backup_name))
|
||||||
|
|
||||||
|
def _add_location(logger, method_name, event_dict):
|
||||||
|
"""Add location to event dict"""
|
||||||
|
module = event_dict.pop("module", "unknown")
|
||||||
|
func_name = event_dict.pop("func_name", "")
|
||||||
|
|
||||||
|
# Store location
|
||||||
|
if func_name and func_name != "<module>":
|
||||||
|
event_dict["_location"] = f"{module}.{func_name}"
|
||||||
|
else:
|
||||||
|
event_dict["_location"] = module
|
||||||
|
|
||||||
|
return event_dict
|
||||||
|
|
||||||
|
class HumanConsoleRenderer:
|
||||||
|
"""Render logs in human-readable format with colors"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.colors = {
|
||||||
|
'debug': '\033[36m',
|
||||||
|
'info': '\033[32m',
|
||||||
|
'warning': '\033[33m',
|
||||||
|
'error': '\033[31m',
|
||||||
|
'critical': '\033[35m',
|
||||||
|
'reset': '\033[0m'
|
||||||
|
}
|
||||||
|
|
||||||
|
def __call__(self, logger, name, event_dict):
|
||||||
|
level = event_dict.get('level', 'info')
|
||||||
|
timestamp = event_dict.get('timestamp', datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3])
|
||||||
|
event = event_dict.get('event', '')
|
||||||
|
location = event_dict.get('_location', 'unknown')
|
||||||
|
|
||||||
|
# Format event with location
|
||||||
|
formatted_event = f"{event} [{location}]"
|
||||||
|
|
||||||
|
# Colorize level
|
||||||
|
color = self.colors.get(level, self.colors['reset'])
|
||||||
|
colored_level = f"{color}{level:<8}{self.colors['reset']}"
|
||||||
|
|
||||||
|
return f"{timestamp} [{colored_level}] {formatted_event}"
|
||||||
|
|
||||||
|
class HumanFileRenderer:
|
||||||
|
"""Render logs in human-readable format without colors for files"""
|
||||||
|
|
||||||
|
def __call__(self, logger, name, event_dict):
|
||||||
|
level = event_dict.get('level', 'info')
|
||||||
|
timestamp = event_dict.get('timestamp', datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3])
|
||||||
|
event = event_dict.get('event', '')
|
||||||
|
location = event_dict.get('_location', 'unknown')
|
||||||
|
|
||||||
|
# Format event with location
|
||||||
|
formatted_event = f"{event} [{location}]"
|
||||||
|
|
||||||
|
return f"{timestamp} [{level:<8}] {formatted_event}"
|
||||||
|
|
||||||
|
def setup_logging():
|
||||||
|
"""Setup human-readable logging"""
|
||||||
|
|
||||||
|
# Rotate logs on startup
|
||||||
|
rotate_logs()
|
||||||
|
|
||||||
|
# Configure structlog processors
|
||||||
|
structlog.configure(
|
||||||
|
processors=[
|
||||||
|
structlog.contextvars.merge_contextvars,
|
||||||
|
structlog.stdlib.add_logger_name,
|
||||||
|
structlog.processors.add_log_level,
|
||||||
|
structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S.%f", utc=False),
|
||||||
|
structlog.processors.CallsiteParameterAdder(
|
||||||
|
{
|
||||||
|
structlog.processors.CallsiteParameter.MODULE,
|
||||||
|
structlog.processors.CallsiteParameter.FUNC_NAME,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
_add_location,
|
||||||
|
],
|
||||||
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||||
|
wrapper_class=structlog.stdlib.BoundLogger,
|
||||||
|
cache_logger_on_first_use=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure standard logging
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.setLevel(logging.DEBUG)
|
||||||
|
root_logger.handlers.clear()
|
||||||
|
|
||||||
|
# Create formatters
|
||||||
|
class StructlogConsoleHandler(logging.StreamHandler):
|
||||||
|
def emit(self, record):
|
||||||
|
try:
|
||||||
|
# Convert record to event dict
|
||||||
|
event_dict = {
|
||||||
|
'level': record.levelname.lower(),
|
||||||
|
'timestamp': datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3],
|
||||||
|
'event': record.getMessage(),
|
||||||
|
'_location': getattr(record, 'module', 'unknown')
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add function name if available
|
||||||
|
if hasattr(record, 'funcName') and record.funcName:
|
||||||
|
event_dict['_location'] = f"{record.module}.{record.funcName}"
|
||||||
|
|
||||||
|
# Render
|
||||||
|
renderer = HumanConsoleRenderer()
|
||||||
|
output = renderer(None, None, event_dict)
|
||||||
|
|
||||||
|
# Write to console
|
||||||
|
self.stream.write(output + '\n')
|
||||||
|
self.flush()
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
self.handleError(record)
|
||||||
|
|
||||||
|
class StructlogFileHandler(logging.FileHandler):
|
||||||
|
def emit(self, record):
|
||||||
|
try:
|
||||||
|
# Convert record to event dict
|
||||||
|
event_dict = {
|
||||||
|
'level': record.levelname.lower(),
|
||||||
|
'timestamp': datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3],
|
||||||
|
'event': record.getMessage(),
|
||||||
|
'_location': getattr(record, 'module', 'unknown')
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add function name if available
|
||||||
|
if hasattr(record, 'funcName') and record.funcName:
|
||||||
|
event_dict['_location'] = f"{record.module}.{record.funcName}"
|
||||||
|
|
||||||
|
# Render
|
||||||
|
renderer = HumanFileRenderer()
|
||||||
|
output = renderer(None, None, event_dict)
|
||||||
|
|
||||||
|
# Write to file
|
||||||
|
self.stream.write(output + '\n')
|
||||||
|
self.flush()
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
self.handleError(record)
|
||||||
|
|
||||||
|
# Add handlers
|
||||||
|
console_handler = StructlogConsoleHandler(sys.stdout)
|
||||||
|
console_handler.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
file_handler = StructlogFileHandler(LATEST_LOG, encoding='utf-8')
|
||||||
|
file_handler.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
root_logger.addHandler(console_handler)
|
||||||
|
root_logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
# Reduce noise
|
||||||
|
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("uvicorn.error").setLevel(logging.INFO)
|
||||||
|
logging.getLogger("fastapi").setLevel(logging.INFO)
|
||||||
|
|
||||||
|
return structlog.get_logger()
|
||||||
|
|
||||||
|
def get_logger(name):
|
||||||
|
"""Get a logger instance"""
|
||||||
|
return logging.getLogger(name)
|
||||||
|
|
||||||
|
# Global logger instance
|
||||||
|
_logger = None
|
||||||
|
|
||||||
|
def init_logging():
|
||||||
|
"""Initialize logging system"""
|
||||||
|
global _logger
|
||||||
|
if _logger is None:
|
||||||
|
_logger = setup_logging()
|
||||||
|
# Use standard logging for the initial message
|
||||||
|
logging.info(f"Logging initialized (log_file: {LATEST_LOG})")
|
||||||
|
return _logger
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# logging_config.py
|
||||||
|
import sys
|
||||||
|
from rich.traceback import install as install_rich_traceback
|
||||||
|
from log_manager import init_logging, get_logger, LATEST_LOG
|
||||||
|
import logging
|
||||||
|
|
||||||
|
install_rich_traceback(show_locals=False)
|
||||||
|
|
||||||
|
def setup_logging() -> None:
|
||||||
|
"""Setup human-readable logging"""
|
||||||
|
|
||||||
|
# Initialize the logger
|
||||||
|
logger_manager = init_logging()
|
||||||
|
|
||||||
|
# Determine mode
|
||||||
|
mode = "development"
|
||||||
|
if "--test" in sys.argv:
|
||||||
|
mode = "test"
|
||||||
|
elif "--prod" in sys.argv:
|
||||||
|
mode = "production"
|
||||||
|
elif "--dev" in sys.argv:
|
||||||
|
mode = "development"
|
||||||
|
|
||||||
|
# Log startup using standard logging
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
logger.info(f"Server starting in {mode} mode")
|
||||||
|
|
||||||
|
return logger
|
||||||
+443
@@ -0,0 +1,443 @@
|
|||||||
|
# main.py
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
import json
|
||||||
|
import structlog
|
||||||
|
from cachetools import TTLCache
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
import asyncio
|
||||||
|
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 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
|
||||||
|
|
||||||
|
import minecraft_launcher_lib.command as mcl_command
|
||||||
|
import minecraft_launcher_lib.utils as mcl_utils
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
# Cache for manifests - expires after 5 minutes
|
||||||
|
manifest_cache = TTLCache(maxsize=100, ttl=300)
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
# Initialize logging
|
||||||
|
init_logging()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Determine environment
|
||||||
|
if args.test:
|
||||||
|
env = "test"
|
||||||
|
elif args.dev:
|
||||||
|
env = "development"
|
||||||
|
else:
|
||||||
|
env = "production"
|
||||||
|
|
||||||
|
logger.info(f"Starting ZernMC Launcher Server (environment: {env})")
|
||||||
|
|
||||||
|
if args.test:
|
||||||
|
await run_test_mode()
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Scanning packs on startup...")
|
||||||
|
|
||||||
|
pack_dirs = [p for p in PACKS_DIR.iterdir() if p.is_dir()]
|
||||||
|
|
||||||
|
if not pack_dirs:
|
||||||
|
logger.warning(f"No packs found in directory: {PACKS_DIR}")
|
||||||
|
else:
|
||||||
|
for pack_dir in pack_dirs:
|
||||||
|
try:
|
||||||
|
meta = await scan_pack(pack_dir.name)
|
||||||
|
logger.info(f"Pack scanned successfully: {pack_dir.name} v{meta.version} ({len(meta.files)} files)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to scan pack: {pack_dir.name} - {e}", exc_info=True)
|
||||||
|
|
||||||
|
logger.info("All packs ready. Server is running.")
|
||||||
|
yield
|
||||||
|
logger.info("Server shutting down...")
|
||||||
|
|
||||||
|
# Create app with lifespan
|
||||||
|
app = FastAPI(title="ZernMC Launcher Server", lifespan=lifespan)
|
||||||
|
|
||||||
|
# Add logging middleware
|
||||||
|
app.add_middleware(LoggingMiddleware)
|
||||||
|
|
||||||
|
|
||||||
|
# Monkey patch to catch invalid HTTP requests
|
||||||
|
original_data_received = HttpToolsProtocol.data_received
|
||||||
|
|
||||||
|
def patched_data_received(self, data):
|
||||||
|
try:
|
||||||
|
return original_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
|
||||||
|
try:
|
||||||
|
raw_data = data[:500].decode('utf-8', errors='replace')
|
||||||
|
logger.debug(f"Raw request data: {raw_data}")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
HttpToolsProtocol.data_received = patched_data_received
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""Root endpoint"""
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.info("Root endpoint accessed")
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": "ZernMC Launcher Server is running",
|
||||||
|
"docs": "/docs",
|
||||||
|
"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"""
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
packs = []
|
||||||
|
|
||||||
|
for pack_dir in PACKS_DIR.iterdir():
|
||||||
|
if pack_dir.is_dir():
|
||||||
|
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:
|
||||||
|
meta = json.load(f)
|
||||||
|
packs.append({
|
||||||
|
"name": pack_dir.name,
|
||||||
|
"version": meta.get("version", 1),
|
||||||
|
"files_count": len(meta.get("files", {})),
|
||||||
|
"updated_at": meta.get("updated_at")
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load pack meta for {pack_dir.name}: {e}")
|
||||||
|
packs.append({
|
||||||
|
"name": pack_dir.name,
|
||||||
|
"error": str(e)
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
packs.append({
|
||||||
|
"name": pack_dir.name,
|
||||||
|
"status": "not_scanned"
|
||||||
|
})
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""
|
||||||
|
Client sends: { "mods/jei.jar": "sha256_hash", ... }
|
||||||
|
Server returns diff information
|
||||||
|
"""
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
logger.info("Received diff request",
|
||||||
|
pack=pack_name,
|
||||||
|
client_files_count=len(client_files),
|
||||||
|
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)
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.warning("Pack not found", pack=pack_name, client_ip=client_ip)
|
||||||
|
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")
|
||||||
|
|
||||||
|
to_download = []
|
||||||
|
to_delete = []
|
||||||
|
to_update = []
|
||||||
|
|
||||||
|
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)
|
||||||
|
if client_hash is None or client_hash != entry.hash:
|
||||||
|
url = f"/pack/{pack_name}/file/{path}"
|
||||||
|
to_download.append({
|
||||||
|
"path": path,
|
||||||
|
"url": url,
|
||||||
|
"size": entry.size,
|
||||||
|
"hash": entry.hash
|
||||||
|
})
|
||||||
|
if client_hash is not None:
|
||||||
|
to_update.append(path)
|
||||||
|
|
||||||
|
# Calculate what needs to be deleted
|
||||||
|
for path in client_files:
|
||||||
|
if path not in server_files:
|
||||||
|
to_delete.append(path)
|
||||||
|
|
||||||
|
logger.info("Diff calculated",
|
||||||
|
pack=pack_name,
|
||||||
|
version=meta.version,
|
||||||
|
to_download=len(to_download),
|
||||||
|
to_delete=len(to_delete),
|
||||||
|
to_update=len(to_update),
|
||||||
|
client_ip=client_ip)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"version": meta.version,
|
||||||
|
"to_download": to_download,
|
||||||
|
"to_delete": to_delete,
|
||||||
|
"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)
|
||||||
|
return JSONResponse(
|
||||||
|
content=cached_meta.model_dump(),
|
||||||
|
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:
|
||||||
|
meta_dict = json.load(f)
|
||||||
|
meta = PackMeta.model_validate(meta_dict)
|
||||||
|
# Update cache
|
||||||
|
manifest_cache[pack_name] = meta
|
||||||
|
|
||||||
|
logger.debug("Manifest served from disk",
|
||||||
|
pack=pack_name,
|
||||||
|
version=meta.version,
|
||||||
|
client_ip=client_ip)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content=meta_dict,
|
||||||
|
headers={"X-Pack-Version": str(meta.version), "X-Cached": "false"}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/pack/{pack_name}/launch")
|
||||||
|
async def get_launch_command(
|
||||||
|
pack_name: str,
|
||||||
|
request: Request,
|
||||||
|
options: dict # клиент присылает: {"username": "...", "uuid": "...", "token": "...", "gameDir": "...", "javaPath": "...", "maxMemory": 4096}
|
||||||
|
):
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
logger.info("Launch command requested", pack=pack_name, client_ip=client_ip)
|
||||||
|
|
||||||
|
try:
|
||||||
|
meta = get_cached_manifest(pack_name) or await scan_pack(pack_name)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(404, "Pack not found") from e
|
||||||
|
|
||||||
|
# Базовые опции для minecraft-launcher-lib
|
||||||
|
launch_options = {
|
||||||
|
"username": options.get("username", "Player"),
|
||||||
|
"uuid": options.get("uuid", "00000000-0000-0000-0000-000000000000"),
|
||||||
|
"token": options.get("token", "token"),
|
||||||
|
"gameDirectory": options.get("gameDir", str(Path("."))), # относительный
|
||||||
|
"executablePath": options.get("javaPath", "java"), # или полный путь
|
||||||
|
"maxMemory": options.get("maxMemory", 4096),
|
||||||
|
"customResolution": options.get("customResolution", False),
|
||||||
|
# можно добавить width/height и т.д.
|
||||||
|
}
|
||||||
|
|
||||||
|
# minecraft_directory — это корень пака у клиента (куда скачаны файлы)
|
||||||
|
minecraft_dir = launch_options["gameDirectory"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Генерируем команду (библиотека сама обработает Fabric/Forge через version.json)
|
||||||
|
command_list = mcl_command.get_minecraft_command(
|
||||||
|
version=meta.minecraft_version,
|
||||||
|
minecraft_directory=minecraft_dir,
|
||||||
|
options=launch_options
|
||||||
|
)
|
||||||
|
|
||||||
|
# Превращаем абсолютные пути в относительные (очень важно для портативности)
|
||||||
|
relative_command = []
|
||||||
|
game_dir_path = Path(minecraft_dir).resolve()
|
||||||
|
|
||||||
|
for arg in command_list:
|
||||||
|
try:
|
||||||
|
arg_path = Path(arg)
|
||||||
|
if arg_path.is_absolute() and game_dir_path in arg_path.parents:
|
||||||
|
rel = arg_path.relative_to(game_dir_path).as_posix()
|
||||||
|
relative_command.append(f"./{rel}" if os.name != "nt" else rel.replace("/", "\\"))
|
||||||
|
else:
|
||||||
|
relative_command.append(arg)
|
||||||
|
except Exception:
|
||||||
|
relative_command.append(arg)
|
||||||
|
|
||||||
|
# Дополнительно: если в PackMeta есть свои jvmArgs/gameArgs — мерджим
|
||||||
|
if hasattr(meta, 'launch') and meta.launch:
|
||||||
|
# Добавляем кастомные jvmArgs в начало и т.д.
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"version": meta.version,
|
||||||
|
"minecraftVersion": meta.minecraft_version,
|
||||||
|
"loaderType": meta.loader_type,
|
||||||
|
"command": relative_command, # список строк — готов к subprocess
|
||||||
|
"workingDirectory": ".", # относительный
|
||||||
|
"mainClass": meta.launch.mainClass if hasattr(meta, 'launch') else None,
|
||||||
|
"classpath": meta.launch.classpath if hasattr(meta, 'launch') else []
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to generate launch command", pack=pack_name, error=str(e), exc_info=True)
|
||||||
|
raise HTTPException(500, f"Failed to generate launch command: {str(e)}")
|
||||||
|
|
||||||
|
@app.get("/pack/{pack_name}/file/{file_path:path}")
|
||||||
|
async def get_pack_file(pack_name: str, file_path: str, request: Request):
|
||||||
|
full_path = PACKS_DIR / pack_name / file_path
|
||||||
|
client_ip = request.client.host if request.client else None
|
||||||
|
|
||||||
|
if not full_path.exists() or not full_path.is_file():
|
||||||
|
logger.warning("File not found",
|
||||||
|
pack=pack_name,
|
||||||
|
file=file_path,
|
||||||
|
client_ip=client_ip)
|
||||||
|
raise HTTPException(404, "File not found")
|
||||||
|
|
||||||
|
logger.info("Serving file",
|
||||||
|
pack=pack_name,
|
||||||
|
file=file_path,
|
||||||
|
size=full_path.stat().st_size,
|
||||||
|
client_ip=client_ip)
|
||||||
|
|
||||||
|
return FileResponse(full_path)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/launcher/version")
|
||||||
|
async def get_launcher_version():
|
||||||
|
"""Возвращает информацию о текущей версии лаунчера"""
|
||||||
|
version_file = Path("builds/build.version")
|
||||||
|
|
||||||
|
version = "1.0.0"
|
||||||
|
if version_file.exists():
|
||||||
|
version = version_file.read_text().strip()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"version": version,
|
||||||
|
"download_jar": "/launcher/download?type=jar",
|
||||||
|
"download_exe": "/launcher/download?type=exe",
|
||||||
|
"updated_at": datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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"
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
raise HTTPException(404, "Launcher file not found")
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=file_path,
|
||||||
|
filename=filename,
|
||||||
|
media_type="application/octet-stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# middleware.py
|
||||||
|
from fastapi import Request, Response
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class LoggingMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
# Generate request ID
|
||||||
|
request_id = str(uuid.uuid4())[:8]
|
||||||
|
|
||||||
|
# Get client IP
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
|
if forwarded:
|
||||||
|
client_ip = forwarded.split(",")[0].strip()
|
||||||
|
|
||||||
|
# Log incoming request
|
||||||
|
logger.info(f"→ {request.method} {request.url.path} (IP: {client_ip}, ID: {request_id})")
|
||||||
|
|
||||||
|
# Start timer
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
# Calculate duration
|
||||||
|
duration = (time.time() - start_time) * 1000
|
||||||
|
|
||||||
|
# Log response
|
||||||
|
logger.info(f"← {request.method} {request.url.path} → {response.status_code} ({duration:.0f}ms) [ID: {request_id}]")
|
||||||
|
|
||||||
|
# Add request ID to response headers
|
||||||
|
response.headers["X-Request-ID"] = request_id
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
duration = (time.time() - start_time) * 1000
|
||||||
|
# Log full traceback
|
||||||
|
error_traceback = traceback.format_exc()
|
||||||
|
logger.error(f"✗ {request.method} {request.url.path} → ERROR: {str(e)} (ID: {request_id})\n{error_traceback}")
|
||||||
|
raise
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
class FileEntry(BaseModel):
|
||||||
|
path: str # относительный путь (mods/jei.jar)
|
||||||
|
hash: str # sha256
|
||||||
|
size: int
|
||||||
|
added_at: datetime
|
||||||
|
modified_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class LaunchConfig(BaseModel):
|
||||||
|
mainClass: str
|
||||||
|
classpath: List[str] = Field(default_factory=list) # относительные пути от gameDir
|
||||||
|
jvmArgs: List[str] = Field(default_factory=list)
|
||||||
|
gameArgs: List[str] = Field(default_factory=list)
|
||||||
|
nativesPath: Optional[str] = None # например: "natives"
|
||||||
|
assetIndex: str = "1.20" # или актуальная версия
|
||||||
|
minecraftVersion: str
|
||||||
|
loaderType: str # "vanilla" | "fabric" | "forge" | "neoforge" | "quilt"
|
||||||
|
loaderVersion: Optional[str] = None
|
||||||
|
gameDirectory: str = "." # "." = корень инсталляции пака (рекомендую)
|
||||||
|
|
||||||
|
|
||||||
|
class PackMeta(BaseModel):
|
||||||
|
pack_name: str
|
||||||
|
version: int = 1
|
||||||
|
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
files: Dict[str, FileEntry] = Field(default_factory=dict)
|
||||||
|
ignored_dirs: List[str] = Field(
|
||||||
|
default_factory=lambda: [
|
||||||
|
"resourcepacks", "shaderpacks", "saves", "logs",
|
||||||
|
"crash-reports", "screenshots", "journeymap", "config"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Основные поля (один раз!)
|
||||||
|
minecraft_version: str
|
||||||
|
loader_type: str
|
||||||
|
loader_version: Optional[str] = None
|
||||||
|
|
||||||
|
# Конфигурация запуска (обязательна)
|
||||||
|
launch: LaunchConfig
|
||||||
|
|
||||||
|
|
||||||
|
class MinecraftVersion(BaseModel):
|
||||||
|
version: str
|
||||||
|
type: str # release, snapshot, old_alpha, old_beta
|
||||||
|
release_time: datetime
|
||||||
|
url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ModLoader(BaseModel):
|
||||||
|
type: str
|
||||||
|
version: str
|
||||||
|
minecraft_version: str
|
||||||
|
installer_url: Optional[str] = None
|
||||||
|
libraries: List[str] = Field(default_factory=list)
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# pack_manager.py (updated)
|
||||||
|
import hashlib
|
||||||
|
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/"
|
||||||
|
MinecraftVersion=[]
|
||||||
|
|
||||||
|
# Cache for loaded manifests
|
||||||
|
_manifest_cache: Dict[str, PackMeta] = {}
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
async def calculate_sha256(file_path: Path) -> str:
|
||||||
|
hash_sha = hashlib.sha256()
|
||||||
|
async with aiofiles.open(file_path, 'rb') as f:
|
||||||
|
while chunk := await f.read(8192):
|
||||||
|
hash_sha.update(chunk)
|
||||||
|
return hash_sha.hexdigest()
|
||||||
|
|
||||||
|
async def get_minecraft_versions() -> List[MinecraftVersion]:
|
||||||
|
"""Fetch available Minecraft versions from Mojang"""
|
||||||
|
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
|
||||||
|
# You might want to use a proper forge API
|
||||||
|
return [] # Placeholder
|
||||||
|
|
||||||
|
async def download_minecraft_version(version: str, target_path: Path) -> bool:
|
||||||
|
"""Download Minecraft version JSON and client jar"""
|
||||||
|
# Get version manifest
|
||||||
|
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()
|
||||||
|
# Save version JSON
|
||||||
|
async with aiofiles.open(target_path / f"{version}.json", 'w') as f:
|
||||||
|
await f.write(json.dumps(version_data, indent=2))
|
||||||
|
|
||||||
|
# Download client jar
|
||||||
|
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 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 if we have cached version and force_rescan is False
|
||||||
|
if not force_rescan and pack_name in _manifest_cache:
|
||||||
|
return _manifest_cache[pack_name]
|
||||||
|
|
||||||
|
if meta_path.exists():
|
||||||
|
async with aiofiles.open(meta_path, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.loads(await f.read())
|
||||||
|
current_meta = PackMeta.model_validate(data)
|
||||||
|
|
||||||
|
new_files: Dict[str, FileEntry] = {}
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
for root, dirs, files in os.walk(pack_path):
|
||||||
|
# Filter ignored directories
|
||||||
|
ignored = current_meta.ignored_dirs if current_meta else []
|
||||||
|
dirs[:] = [d for d in dirs if d not in ignored]
|
||||||
|
|
||||||
|
for file in files:
|
||||||
|
file_path = Path(root) / file
|
||||||
|
rel_path = file_path.relative_to(pack_path).as_posix()
|
||||||
|
|
||||||
|
# Skip files in ignored directories
|
||||||
|
if any(ignored_dir in rel_path.split('/') for ignored_dir in ignored):
|
||||||
|
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
|
||||||
|
|
||||||
|
if current_meta and (rel_path not in current_meta.files or
|
||||||
|
current_meta.files[rel_path].hash != file_hash):
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# Update manifest if changes detected or new pack
|
||||||
|
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
|
||||||
|
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 []
|
||||||
|
)
|
||||||
|
|
||||||
|
async with aiofiles.open(meta_path, 'w', encoding='utf-8') as f:
|
||||||
|
await f.write(new_meta.model_dump_json(indent=2))
|
||||||
|
|
||||||
|
# Update memory cache
|
||||||
|
_manifest_cache[pack_name] = new_meta
|
||||||
|
|
||||||
|
logger.info("Pack updated", pack=pack_name, new_version=version, files_count=len(new_files))
|
||||||
|
return new_meta
|
||||||
|
|
||||||
|
# Update cache with existing manifest
|
||||||
|
if current_meta:
|
||||||
|
_manifest_cache[pack_name] = current_meta
|
||||||
|
|
||||||
|
return current_meta
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fastapi>=0.115.0
|
||||||
|
uvicorn[standard]>=0.30.0
|
||||||
|
pydantic>=2.9.0
|
||||||
|
aiofiles>=24.1.0
|
||||||
|
python-multipart>=0.0.9
|
||||||
|
watchfiles>=0.24.0
|
||||||
|
structlog>=24.4.0
|
||||||
|
rich>=13.9.0
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# view_logs.py - полезный скрипт для просмотра логов
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
LOG_DIR = Path("logs")
|
||||||
|
|
||||||
|
def list_logs():
|
||||||
|
"""List all available log files"""
|
||||||
|
log_files = sorted(LOG_DIR.glob("*.log"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||||
|
|
||||||
|
print("Available logs:")
|
||||||
|
print("-" * 50)
|
||||||
|
for i, log_file in enumerate(log_files, 1):
|
||||||
|
size = log_file.stat().st_size / 1024 # KB
|
||||||
|
modified = datetime.fromtimestamp(log_file.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
current = " (current)" if log_file.name == "latest.log" else ""
|
||||||
|
print(f"{i}. {log_file.name}{current} - {size:.1f} KB - {modified}")
|
||||||
|
|
||||||
|
return log_files
|
||||||
|
|
||||||
|
def view_log(log_file):
|
||||||
|
"""View a log file"""
|
||||||
|
if not log_file.exists():
|
||||||
|
print(f"Log file not found: {log_file}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n=== {log_file.name} ===\n")
|
||||||
|
with open(log_file, 'r') as f:
|
||||||
|
# Show last 50 lines by default
|
||||||
|
lines = f.readlines()
|
||||||
|
if len(lines) > 50:
|
||||||
|
print(f"... (showing last 50 of {len(lines)} lines) ...\n")
|
||||||
|
lines = lines[-50:]
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
print(line.rstrip())
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
if sys.argv[1] == "list":
|
||||||
|
list_logs()
|
||||||
|
elif sys.argv[1].isdigit():
|
||||||
|
logs = list_logs()
|
||||||
|
idx = int(sys.argv[1]) - 1
|
||||||
|
if 0 <= idx < len(logs):
|
||||||
|
view_log(logs[idx])
|
||||||
|
else:
|
||||||
|
print("Invalid log number")
|
||||||
|
else:
|
||||||
|
# Try to open as filename
|
||||||
|
view_log(LOG_DIR / sys.argv[1])
|
||||||
|
else:
|
||||||
|
# Show latest.log by default
|
||||||
|
view_log(LOG_DIR / "latest.log")
|
||||||
Reference in New Issue
Block a user