Files
TurboSu/server/game_manager.py
T
AskaEth 63e3c8d607 refactor: standalone dashboard folders with index.html, zip-based import/export (.tsd/.tss/.tsp)
- Each dashboard is now a self-contained folder: manifest.json + index.html
- Auto-scan dashboards/ and data/dashboards/ on startup
- Export: .tsd (dashboards), .tss (scenes), .tsp (game plugins) - all zip format
- Dashboard index.html is complete standalone page (WS + data binding)
- Aspect ratio constraints handled in each dashboard's own JS
- Removed template-based dashboard rendering in favor of static serve
- Import via file upload endpoints, export via direct file download
2026-07-25 15:35:08 +08:00

191 lines
6.4 KiB
Python

from __future__ import annotations
import io
import json
import shutil
import zipfile
import importlib.util
import sys
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Any
from utils.logger import get_logger
logger = get_logger(__name__)
GAMES_DIR = Path(__file__).resolve().parent.parent / "games"
BUILTIN_DIR = GAMES_DIR / "builtin"
USER_DIR = GAMES_DIR / "user"
@dataclass
class GamePlugin:
id: str = ""
name: str = ""
parser_type: str = "forza"
telemetry_format: str = ""
description: str = ""
author: str = ""
version: str = "1.0.0"
default_port: int = 20777
icon: str = ""
is_builtin: bool = True
enabled: bool = True
manifest_path: str = ""
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_manifest(cls, path: Path, is_builtin: bool = True) -> GamePlugin | None:
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
gp = cls(
id=data.get("id", path.parent.name),
name=data.get("name", path.parent.name),
parser_type=data.get("parser_type", "forza"),
telemetry_format=data.get("telemetry_format", ""),
description=data.get("description", ""),
author=data.get("author", ""),
version=data.get("version", "1.0.0"),
default_port=data.get("default_port", 20777),
icon=data.get("icon", ""),
is_builtin=is_builtin,
enabled=True,
manifest_path=str(path.parent),
)
return gp
except Exception as e:
logger.error("Failed to load game plugin manifest %s: %s", path, e)
return None
class GamePluginManager:
def __init__(self):
BUILTIN_DIR.mkdir(parents=True, exist_ok=True)
USER_DIR.mkdir(parents=True, exist_ok=True)
self._plugins: dict[str, GamePlugin] = {}
self._parser_cache: dict[str, Any] = {}
self._discover()
def _discover(self):
self._plugins.clear()
for d in [BUILTIN_DIR, USER_DIR]:
if not d.exists():
continue
is_builtin = (d == BUILTIN_DIR)
for item in d.iterdir():
if item.is_dir():
manifest = item / "manifest.json"
if manifest.exists():
gp = GamePlugin.from_manifest(manifest, is_builtin)
if gp:
self._plugins[gp.id] = gp
logger.debug("Discovered game plugin: %s", gp.id)
def list_all(self) -> list[dict[str, Any]]:
self._discover()
return [p.to_dict() for p in self._plugins.values()]
def get(self, plugin_id: str) -> GamePlugin | None:
self._discover()
return self._plugins.get(plugin_id)
def get_parser(self, plugin_id: str) -> Any | None:
self._discover()
gp = self._plugins.get(plugin_id)
if not gp:
return None
cache_key = gp.id
if cache_key in self._parser_cache:
return self._parser_cache[cache_key]
parser_dir = Path(gp.manifest_path)
parser_file = parser_dir / "parser.py"
if not parser_file.exists():
return None
try:
module_name = f"turbosu_game_{gp.id}"
spec = importlib.util.spec_from_file_location(module_name, parser_file)
if spec and spec.loader:
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
if hasattr(module, "get_parser"):
parser = module.get_parser()
self._parser_cache[cache_key] = parser
logger.info("Loaded parser for game: %s", gp.name)
return parser
except Exception as e:
logger.error("Failed to load parser for %s: %s", gp.id, e)
return None
def reload(self):
self._discover()
self._parser_cache.clear()
def install_plugin_zip(self, zip_data: bytes) -> bool:
try:
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
names = zf.namelist()
manifest_name = None
for n in names:
if n.endswith('manifest.json'):
manifest_name = n
break
if not manifest_name:
logger.error("No manifest.json found in plugin zip")
return False
manifest = json.loads(zf.read(manifest_name).decode('utf-8'))
plugin_id = manifest.get("id", "")
if not plugin_id:
return False
dest_dir = USER_DIR / plugin_id
if dest_dir.exists():
shutil.rmtree(dest_dir)
dest_dir.mkdir(parents=True)
with open(dest_dir / "manifest.json", "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
for name in names:
if name == manifest_name or name.endswith('/'):
continue
if name.endswith('parser.py'):
out_path = dest_dir / "parser.py"
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(zf.read(name))
self._discover()
logger.info("Plugin installed from zip: %s", plugin_id)
return True
except Exception as e:
logger.error("Failed to install plugin zip: %s", e)
return False
def export_plugin_zip(self, plugin_id: str) -> bytes | None:
gp = self._plugins.get(plugin_id)
if not gp:
return None
parser_dir = Path(gp.manifest_path)
if not parser_dir.exists():
return None
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
for f in sorted(parser_dir.rglob('*')):
if f.is_file():
zf.write(f, f.relative_to(parser_dir))
logger.info("Plugin exported as zip: %s", plugin_id)
return buf.getvalue()
game_plugin_manager = GamePluginManager()