Files
Tran G. (Revernomad) Khoa d45345a094 feat: fix mods tab, add client/server split, and scaffold server dirs
Mods tab bug fixes:
- mod_manager: fix wrong kwargs in set_enabled_mods, fix scan dir to use
  mods/ subdir instead of server root, migrate old string-list format to
  dict format on read
- service: replace dead server_mods SQL JOIN with get_enabled_mods()
  call through the mod_manager capability; pass is_server_mod to
  build_mod_args
- mods_router: accept list[EnabledModEntry] objects (name + is_server_mod)
  instead of bare strings

Client/server mod split:
- Mods now stored as list[{"name": str, "is_server_mod": bool}]; old
  string-list format auto-migrated on read
- is_server_mod=true routes to -serverMod= arg; false to -mod= arg
- ModList UI: amber Client/Server badge in selected pane; toggle button
  in split-pane selector

Directory scaffold:
- process_config: adds "mods" to dir layout; provides get_dir_readme()
  with per-directory README.txt content
- file_utils: ensure_server_dirs() gains readme_provider kwarg; writes
  README.txt idempotently if absent
- service.create_server: passes readme_provider via hasattr probe
- main.py startup: backfills all existing servers with correct subdirs
  and README files (idempotent)

Docs: API.md and FRONTEND.md updated for new mod schema and types
Test __init__.py files added for pytest discovery
2026-04-20 10:54:56 +07:00

205 lines
7.1 KiB
Python

"""
FastAPI application factory.
Entry point: uvicorn main:app --reload
"""
from __future__ import annotations
import asyncio
import logging
import queue
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
from config import settings
logging.basicConfig(
level=getattr(logging, settings.log_level.upper(), logging.INFO),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
limiter = Limiter(key_func=get_remote_address)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup + shutdown logic."""
# ── Startup ──
logger.info("Starting Languard...")
# 1. Init DB and run migrations
from database import get_engine, run_migrations
engine = get_engine()
run_migrations(engine)
# 2. Register adapters
from adapters import initialize_adapters
initialize_adapters()
# 3. Create WebSocket manager (asyncio-only)
from core.websocket.manager import WebSocketManager
ws_manager = WebSocketManager()
app.state.ws_manager = ws_manager
# 4. Create global broadcast queue and BroadcastThread
broadcast_queue = queue.Queue(maxsize=1000)
app.state.broadcast_queue = broadcast_queue
from core.websocket.broadcast_thread import BroadcastThread
loop = asyncio.get_event_loop()
broadcast_thread = BroadcastThread(
event_queue=broadcast_queue,
ws_manager=ws_manager,
loop=loop,
)
broadcast_thread.start()
app.state.broadcast_thread = broadcast_thread
# 5. Create ThreadRegistry
from core.threads.thread_registry import ThreadRegistry
from core.servers.process_manager import ProcessManager
from adapters.registry import GameAdapterRegistry
process_manager = ProcessManager.get()
thread_registry = ThreadRegistry(
process_manager=process_manager,
adapter_registry=GameAdapterRegistry,
global_broadcast_queue=broadcast_queue,
)
ThreadRegistry.set_instance(thread_registry)
app.state.thread_registry = thread_registry
# 6. Recover processes that survived a restart
process_manager.recover_on_startup(engine.connect())
# 7. Reattach threads for running servers
from core.dal.server_repository import ServerRepository
with engine.connect() as db:
server_repo = ServerRepository(db)
running_servers = server_repo.get_running()
for server in running_servers:
try:
thread_registry.reattach_server_threads(server["id"], db)
logger.info("Reattached threads for server %d", server["id"])
except Exception as exc:
logger.error("Failed to reattach threads for server %d: %s", server["id"], exc)
# 8. Backfill server directory scaffold for existing servers (idempotent)
from core.dal.server_repository import ServerRepository as _ServerRepo
from core.utils.file_utils import ensure_server_dirs as _ensure_dirs
from adapters.registry import GameAdapterRegistry as _Registry
with engine.connect() as db:
for server in _ServerRepo(db).get_all():
try:
_adapter = _Registry.get(server["game_type"])
_pc = _adapter.get_process_config()
_ensure_dirs(
server["id"],
_pc.get_server_dir_layout(),
readme_provider=getattr(_pc, "get_dir_readme", None),
)
except Exception as exc:
logger.warning("Dir scaffold failed for server %d: %s", server["id"], exc)
# 9. Seed default admin if no users exist
from core.auth.service import AuthService
with engine.connect() as db:
svc = AuthService(db)
generated_password = svc.seed_admin_if_empty()
db.commit()
if generated_password:
logger.warning("=" * 60)
logger.warning(" FIRST RUN — default admin created")
logger.warning(" Username: admin")
logger.warning(" Password: %s", generated_password)
logger.warning(" Change this password immediately!")
logger.warning("=" * 60)
# 10. Register and start APScheduler cleanup jobs
from core.jobs.scheduler import start_scheduler, stop_scheduler
from core.jobs.cleanup_jobs import register_cleanup_jobs
register_cleanup_jobs()
start_scheduler()
yield
# ── Shutdown ──
logger.info("Shutting down Languard...")
try:
ThreadRegistry.stop_all()
except Exception as e:
logger.error("Thread shutdown error: %s", e)
broadcast_thread.stop()
broadcast_thread.join(timeout=5.0)
from core.jobs.scheduler import stop_scheduler
stop_scheduler()
def create_app() -> FastAPI:
app = FastAPI(
title="Languard Server Manager",
version="1.0.0",
lifespan=lifespan,
docs_url="/docs",
redoc_url="/redoc",
)
# ── Middleware ──
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── Global exception handler ──
@app.exception_handler(Exception)
async def generic_exception_handler(request: Request, exc: Exception):
logger.error("Unhandled exception: %s", exc, exc_info=True)
return JSONResponse(
status_code=500,
content={
"success": False,
"data": None,
"error": {"code": "INTERNAL_ERROR", "message": "An unexpected error occurred"},
},
)
# ── Routers ──
from core.auth.router import router as auth_router
from core.games.router import router as games_router
from core.system.router import router as system_router
from core.servers.router import router as servers_router
from core.servers.players_router import router as players_router
from core.servers.bans_router import router as bans_router
from core.servers.missions_router import router as missions_router
from core.servers.mods_router import router as mods_router
from core.servers.logfiles_router import router as logfiles_router
from core.websocket.router import router as ws_router
app.include_router(auth_router, prefix="/api")
app.include_router(games_router, prefix="/api")
app.include_router(system_router, prefix="/api")
app.include_router(servers_router, prefix="/api")
app.include_router(players_router, prefix="/api")
app.include_router(bans_router, prefix="/api")
app.include_router(missions_router, prefix="/api")
app.include_router(mods_router, prefix="/api")
app.include_router(logfiles_router, prefix="/api")
app.include_router(ws_router)
return app
app = create_app()