Files
TurboSu/server/api.py
T

747 lines
25 KiB
Python

from __future__ import annotations
import json
import uuid
from typing import Any
from fastapi import APIRouter, HTTPException, Request, UploadFile, File
from fastapi.responses import JSONResponse
from config.settings import get_config, update_config
from models.dashboard import dashboard_manager, DashboardTheme
from models.scene import scene_manager, Scene, SceneCanvas, DashboardPlacement
from server.telemetry.listener import telemetry_listener
from server.websocket import ws_manager
from utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/api")
# ---- Status ----
@router.get("/status")
async def get_status():
latest = telemetry_listener.latest_data
cfg = get_config()
return {
"server_running": True,
"telemetry_running": telemetry_listener.is_running,
"ws_clients": ws_manager.client_count,
"packet_count": telemetry_listener.packet_count,
"last_packet_time": telemetry_listener.last_packet_time,
"selected_game_id": cfg.get("selected_game_id"),
"forward_active": telemetry_listener.forwarder.active,
"latest_data": latest.to_dict() if latest else None,
}
# ---- Config ----
@router.get("/config")
async def api_get_config():
return get_config()
@router.put("/config")
async def api_update_config(data: dict[str, Any]):
cfg = get_config()
for k, v in data.items():
cfg[k] = v
from config.settings import save_config
save_config(cfg)
if "selected_game_id" in data:
telemetry_listener.set_parser_for_game(data["selected_game_id"])
if "theme" in data:
cfg["theme"] = data["theme"]
return {"ok": True}
# ---- Telemetry ----
@router.post("/telemetry/start")
async def api_start_telemetry():
ok = await telemetry_listener.start()
return {"ok": ok, "running": telemetry_listener.is_running}
@router.post("/telemetry/stop")
async def api_stop_telemetry():
telemetry_listener.stop()
return {"ok": True, "running": telemetry_listener.is_running}
@router.post("/test-mode/start")
async def api_start_test_mode():
telemetry_listener.start_test_mode()
return {"ok": True, "testing": True}
@router.post("/test-mode/stop")
async def api_stop_test_mode():
telemetry_listener.stop_test_mode()
return {"ok": True, "testing": False}
@router.get("/telemetry/latest")
async def api_latest_telemetry():
latest = telemetry_listener.latest_data
if latest:
return {
"data": latest.to_dict(),
"raw": latest.raw,
}
return {"data": None, "raw": None}
# ---- Dashboards ----
@router.get("/dashboards")
async def api_list_dashboards(category: str = "all"):
return dashboard_manager.list_all(category)
@router.get("/dashboards/categories")
async def api_dashboard_categories():
return dashboard_manager.get_categories()
@router.get("/dashboards/{theme_id}")
async def api_get_dashboard(theme_id: str):
theme = dashboard_manager.get(theme_id)
if not theme:
raise HTTPException(404, "Dashboard not found")
return theme
@router.get("/dashboards/{theme_id}/template")
async def api_get_dashboard_template(theme_id: str):
tmpl = dashboard_manager.get_index_html(theme_id)
return {"html": tmpl or ""}
@router.post("/dashboards")
async def api_create_dashboard(data: dict[str, Any]):
cfg = data.get("config", {})
theme = DashboardTheme(
name=data.get("name", "Untitled"),
category=data.get("category", "basic"),
description=data.get("description", ""),
author=data.get("author", ""),
icon=cfg.get("icon", "📊"),
aspect_ratio=cfg.get("aspect_ratio", "auto"),
render_mode=cfg.get("render_mode", "contain"),
)
dashboard_manager.save(theme, data.get("index_html", ""))
return theme.to_dict()
@router.put("/dashboards/{theme_id}")
async def api_update_dashboard(theme_id: str, data: dict[str, Any]):
existing = dashboard_manager.get(theme_id)
if not existing:
raise HTTPException(404, "Dashboard not found")
theme = DashboardTheme(
id=theme_id,
name=data.get("name", existing.get("name", "")),
category=data.get("category", existing.get("category", "basic")),
description=data.get("description", existing.get("description", "")),
author=data.get("author", existing.get("author", "")),
icon=data.get("icon", existing.get("icon", "📊")),
aspect_ratio=data.get("aspect_ratio", existing.get("aspect_ratio", "auto")),
render_mode=data.get("render_mode", existing.get("render_mode", "contain")),
)
dashboard_manager.save(theme, data.get("index_html", ""))
return theme.to_dict()
@router.delete("/dashboards/{theme_id}")
async def api_delete_dashboard(theme_id: str):
ok = dashboard_manager.delete(theme_id)
return {"ok": ok}
@router.get("/dashboards/{theme_id}/export")
async def api_export_dashboard(theme_id: str):
from fastapi.responses import Response
zip_bytes = dashboard_manager.export_theme_zip(theme_id)
if not zip_bytes:
raise HTTPException(404, "Dashboard not found")
theme = dashboard_manager.get(theme_id)
filename = f"{theme['id']}.tsd" if theme else "dashboard.tsd"
return Response(
content=zip_bytes,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/dashboards/import")
async def api_import_dashboard(file: UploadFile = File(...)):
if not file.filename or not file.filename.endswith('.tsd'):
raise HTTPException(400, "Only .tsd files are accepted")
zip_data = await file.read()
ok = dashboard_manager.import_theme_zip(zip_data)
return {"ok": ok}
# ---- Scenes ----
@router.get("/scenes")
async def api_list_scenes(game_id: str = ""):
return scene_manager.list_all(game_id or None)
@router.get("/scenes/{scene_id}")
async def api_get_scene(scene_id: str):
scene = scene_manager.get(scene_id)
if not scene:
raise HTTPException(404, "Scene not found")
return scene
@router.post("/scenes")
async def api_create_scene(data: dict[str, Any]):
scene = Scene(
name=data.get("name", "New Scene"),
game_id=data.get("game_id", ""),
description=data.get("description", ""),
canvases=[SceneCanvas(label="16:9", width=1920, height=1080)],
)
if data.get("canvases"):
scene.canvases = [
SceneCanvas(
width=c.get("width", 1920),
height=c.get("height", 1080),
label=c.get("label", ""),
placements=[DashboardPlacement(**p) for p in c.get("placements", [])],
)
for c in data["canvases"]
]
scene_manager.save(scene)
return scene.to_dict()
@router.put("/scenes/{scene_id}")
async def api_update_scene(scene_id: str, data: dict[str, Any]):
existing = scene_manager.get(scene_id)
if not existing:
raise HTTPException(404, "Scene not found")
scene = Scene.from_dict(existing)
for k in ["name", "game_id", "description"]:
if k in data:
setattr(scene, k, data[k])
if "canvases" in data:
scene.canvases = [
SceneCanvas(
width=c.get("width", 1920),
height=c.get("height", 1080),
label=c.get("label", "Custom"),
placements=[DashboardPlacement(**p) for p in c.get("placements", [])],
)
for c in data["canvases"]
]
scene_manager.save(scene)
return scene.to_dict()
@router.delete("/scenes/{scene_id}")
async def api_delete_scene(scene_id: str):
ok = scene_manager.delete(scene_id)
return {"ok": ok}
@router.get("/scenes/{scene_id}/export")
async def api_export_scene(scene_id: str):
from fastapi.responses import Response
zip_bytes = scene_manager.export_scene_zip(scene_id)
if not zip_bytes:
raise HTTPException(404, "Scene not found")
scene = scene_manager.get(scene_id)
filename = f"{scene['id']}.tss" if scene else "scene.tss"
return Response(
content=zip_bytes,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/scenes/import")
async def api_import_scene(file: UploadFile = File(...)):
if not file.filename or not file.filename.endswith('.tss'):
raise HTTPException(400, "Only .tss files are accepted")
zip_data = await file.read()
ok = scene_manager.import_scene_zip(zip_data)
return {"ok": ok}
# ---- Game Plugins ----
from server.game_manager import game_plugin_manager
@router.get("/games")
async def api_list_games():
return game_plugin_manager.list_all()
@router.get("/games/{plugin_id}")
async def api_get_game(plugin_id: str):
gp = game_plugin_manager.get(plugin_id)
if not gp:
raise HTTPException(404, "Game plugin not found")
return gp.to_dict()
@router.post("/games/install")
async def api_install_game_plugin(file: UploadFile = File(...)):
if not file.filename or not file.filename.endswith('.tsp'):
raise HTTPException(400, "Only .tsp files are accepted")
zip_data = await file.read()
ok = game_plugin_manager.install_plugin_zip(zip_data)
return {"ok": ok}
@router.delete("/games/{plugin_id}")
async def api_remove_game_plugin(plugin_id: str):
ok = game_plugin_manager.remove_plugin(plugin_id)
return {"ok": ok}
@router.get("/games/{plugin_id}/export")
async def api_export_game_plugin(plugin_id: str):
from fastapi.responses import Response
zip_bytes = game_plugin_manager.export_plugin_zip(plugin_id)
if not zip_bytes:
raise HTTPException(404, "Game plugin not found")
return Response(
content=zip_bytes,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{plugin_id}.tsp"'},
)
@router.post("/games/reload")
async def api_reload_game_plugins():
game_plugin_manager.reload()
return {"ok": True, "count": len(game_plugin_manager.list_all())}
# ---- Logs ----
@router.get("/logs")
async def api_get_logs(lines: int = 50):
from pathlib import Path
log_file = Path(__file__).resolve().parent.parent / "logs" / "turbosu.log"
if not log_file.exists():
return {"lines": []}
try:
with open(log_file, "r", encoding="utf-8", errors="replace") as f:
all_lines = f.readlines()
return {"lines": [l.rstrip() for l in all_lines[-lines:]]}
except Exception:
return {"lines": []}
# ---- Full Backup (.tsb) ----
@router.get("/backup/export")
async def api_export_backup():
import io, zipfile
from fastapi.responses import Response
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
base = Path(__file__).resolve().parent.parent
data_dir = base / "data"
if data_dir.exists():
for f in data_dir.rglob("*"):
if f.is_file() and "__pycache__" not in str(f):
zf.write(f, f.relative_to(base))
dash_dir = base / "dashboards"
if dash_dir.exists():
for f in dash_dir.rglob("*"):
if f.is_file():
zf.write(f, f.relative_to(base))
games_dir = base / "games" / "user"
if games_dir.exists():
for f in games_dir.rglob("*"):
if f.is_file():
zf.write(f, f.relative_to(base))
return Response(content=buf.getvalue(), media_type="application/zip",
headers={"Content-Disposition": "attachment; filename=turbosu_backup.tsb"})
@router.post("/backup/import")
async def api_import_backup(file: UploadFile = File(...)):
if not file.filename or not file.filename.endswith('.tsb'):
raise HTTPException(400, "Only .tsb files are accepted")
import io, zipfile, shutil
zip_data = await file.read()
base = Path(__file__).resolve().parent.parent
try:
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
for name in zf.namelist():
if name.endswith('/') or '__pycache__' in name:
continue
dest = base / name
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(zf.read(name))
from config.settings import reload_config
reload_config()
from server.game_manager import game_plugin_manager
game_plugin_manager.reload()
return {"ok": True, "message": "备份已恢复,请重启服务"}
except Exception as e:
return {"ok": False, "message": str(e)}
# ---- Restart ----
@router.post("/restart")
async def api_restart():
import os, sys, atexit
logger.info("Restart requested via API")
atexit.register(lambda: os.execv(sys.executable, [sys.executable] + sys.argv))
sys.exit(0)
# ---- Data Forwarding ----
@router.get("/forward")
async def api_list_forward_targets():
cfg = get_config()
return cfg.get("forward_targets", [])
@router.put("/forward")
async def api_update_forward_targets(data: dict[str, Any]):
targets = data.get("targets", [])
cfg = get_config()
cfg["forward_targets"] = targets
from config.settings import save_config
save_config(cfg)
telemetry_listener.forwarder.reload_targets()
return {"ok": True, "active": telemetry_listener.forwarder.active}
# ---- Record & Playback ----
from server.recorder import TelemetryRecorder, TelemetryPlayer
_recorder = None
_player = None
def _get_recorder():
global _recorder
if _recorder is None: _recorder = TelemetryRecorder(telemetry_listener)
return _recorder
def _get_player():
global _player
if _player is None: _player = TelemetryPlayer(telemetry_listener)
return _player
@router.post("/record/start")
async def api_start_record():
_get_recorder().start()
return {"ok": True, "recording": True}
@router.post("/record/stop")
async def api_stop_record():
_get_recorder().stop()
return {"ok": True, "recording": False}
@router.get("/record/status")
async def api_record_status():
return {"recording": _get_recorder().is_recording, "playing": _get_player().is_playing}
@router.get("/recordings")
async def api_list_recordings():
return TelemetryPlayer.list_recordings()
@router.post("/playback/start")
async def api_start_playback(data: dict):
filename = data.get("filename", "")
if not filename: raise HTTPException(400, "filename required")
_get_player().start(filename)
return {"ok": True, "playing": True}
@router.post("/playback/stop")
async def api_stop_playback():
_get_player().stop()
return {"ok": True, "playing": False}
@router.get("/recording/{filename}/export")
async def api_export_recording(filename: str):
from fastapi.responses import FileResponse
from server.recorder import RECORDINGS_DIR
path = RECORDINGS_DIR / filename
if not path.exists():
raise HTTPException(404, "Recording not found")
return FileResponse(path, media_type="application/octet-stream", filename=filename)
@router.delete("/recording/{filename}/delete")
async def api_delete_recording(filename: str):
from server.recorder import RECORDINGS_DIR
path = RECORDINGS_DIR / filename
if path.exists(): path.unlink()
return {"ok": True}
# ---- Market Proxy ----
@router.get("/market/dashboards")
async def api_market_list():
import httpx
cfg = get_config()
url = cfg.get("market_url", "http://127.0.0.1:5301")
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"{url}/api/collections/dashboards/records?sort=-name&perPage=50")
return resp.json()
except Exception:
return {"items": []}
@router.post("/market/auth")
async def api_market_auth(data: dict):
import httpx
cfg = get_config()
url = cfg.get("market_url", "http://127.0.0.1:5301")
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
f"{url}/api/collections/users/auth-with-password",
json={"identity": data.get("email", data.get("identity","")), "password": data.get("password")}
)
result = resp.json()
return result
except Exception as e:
return {"message": "服务器连接失败: " + str(e), "token": None}
@router.post("/market/register")
async def api_market_register(data: dict):
import httpx
cfg = get_config()
url = cfg.get("market_url", "http://127.0.0.1:5301")
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
f"{url}/api/collections/users/records",
json={
"email": data.get("email"),
"password": data.get("password"),
"passwordConfirm": data.get("passwordConfirm"),
"name": data.get("name", ""),
}
)
return resp.json()
except Exception as e:
return {"error": str(e)}
@router.post("/market/upload")
async def api_market_upload(file: UploadFile = File(...), name: str = "", category: str = "", author: str = "", token: str = ""):
import httpx, io, zipfile, json as _json
# Validate .tsd
if not file.filename or not file.filename.endswith('.tsd'):
return {"error": "仅支持 .tsd 文件"}
try:
zip_data = await file.read()
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
names = zf.namelist()
if 'manifest.json' not in names:
return {"error": "无效的仪表盘文件:缺少 manifest.json"}
if 'index.html' not in names:
return {"error": "无效的仪表盘文件:缺少 index.html"}
manifest = _json.loads(zf.read('manifest.json').decode('utf-8'))
if not manifest.get('id'):
return {"error": "manifest.json 缺少 id"}
if not manifest.get('name'):
return {"error": "manifest.json 缺少 name"}
actual_name = name or manifest.get('name', file.filename.replace('.tsd', ''))
actual_category = category or manifest.get('category', 'community')
actual_author = author or manifest.get('author', 'Unknown')
actual_version = manifest.get('version', '1.0.0')
supported = manifest.get('supported_games', 'all')
icon = manifest.get('config', {}).get('icon', '📦')
desc = manifest.get('description', '')
except zipfile.BadZipFile:
return {"error": "无效的 zip 文件"}
except Exception as e:
return {"error": f"文件解析失败: {str(e)}"}
cfg = get_config()
url = cfg.get("market_url", "http://127.0.0.1:5301")
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
f"{url}/api/collections/dashboards/records",
headers={"Authorization": f"Bearer {token}"},
data={
"name": actual_name, "category": actual_category, "author": actual_author,
"version": actual_version, "supported_games": supported,
"icon": icon, "description": desc, "dashb_pending": "true",
"manifest_id": manifest.get("id", ""),
},
files={"file": (file.filename, zip_data, file.content_type)},
)
return resp.json()
except Exception as e:
return {"error": str(e)}
@router.get("/market/install")
async def api_market_install(id: str = "", filename: str = ""):
import httpx
cfg = get_config()
url = cfg.get("market_url", "http://127.0.0.1:5301")
try:
async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client:
resp = await client.get(f"{url}/api/files/dashboards/{id}/{filename}")
zip_data = resp.content
from models.dashboard import dashboard_manager
ok = dashboard_manager.import_theme_zip(zip_data)
if ok:
try:
async with httpx.AsyncClient(timeout=5) as client2:
# Get current downloads
r = await client2.get(f"{url}/api/collections/dashboards/records/{id}")
current = r.json().get("downloads", 0) or 0
# Increment
await client2.patch(
f"{url}/api/collections/dashboards/records/{id}",
json={"downloads": current + 1}
)
except Exception:
pass
return {"ok": ok}
except Exception as e:
return {"ok": False, "error": str(e)}
# ---- Market Scenes ----
@router.get("/market/scenes")
async def api_market_scenes():
import httpx
cfg = get_config()
url = cfg.get("market_url", "http://127.0.0.1:5301")
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"{url}/api/collections/scenes/records?sort=-name&perPage=50")
return resp.json()
except Exception:
return {"items": []}
@router.post("/market/scenes/upload")
async def api_market_upload_scene(file: UploadFile = File(...), token: str = ""):
import io, zipfile, json as _json
if not file.filename or not file.filename.endswith('.tss'):
return {"error": "仅支持 .tss 文件"}
try:
zip_data = await file.read()
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
if 'scene.json' not in zf.namelist():
return {"error": "缺少 scene.json"}
scene = _json.loads(zf.read('scene.json').decode('utf-8'))
if not scene.get('name'):
return {"error": "scene.json 缺少 name"}
deps = set()
for canvas in scene.get('canvases', []):
for p in canvas.get('placements', []):
if p.get('dashboard_id'):
deps.add(p['dashboard_id'])
except zipfile.BadZipFile:
return {"error": "无效的 zip 文件"}
except Exception as e:
return {"error": str(e)}
import httpx as _httpx
cfg = get_config()
url = cfg.get("market_url", "http://127.0.0.1:5301")
try:
async with _httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
f"{url}/api/collections/scenes/records",
headers={"Authorization": f"Bearer {token}"},
data={"name": scene.get('name'), "description": scene.get('description',''), "game_id": scene.get('game_id',''), "author": scene.get('author','') or "Unknown", "dashb_pending": True},
files={"file": (file.filename, zip_data, file.content_type)},
)
result = resp.json()
result["dependencies"] = list(deps)
return result
except Exception as e:
return {"error": str(e)}
@router.get("/market/scenes/install")
async def api_market_install_scene(id: str = "", filename: str = ""):
import httpx, io, zipfile, json as _json
cfg = get_config()
url = cfg.get("market_url", "http://127.0.0.1:5301")
try:
async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client:
resp = await client.get(f"{url}/api/files/scenes/{id}/{filename}")
zip_data = resp.content
missing = []
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
if 'scene.json' in zf.namelist():
scene = _json.loads(zf.read('scene.json').decode('utf-8'))
from models.dashboard import dashboard_manager
for canvas in scene.get('canvases', []):
for p in canvas.get('placements', []):
did = p.get('dashboard_id', '')
if did and not dashboard_manager.get(did):
missing.append(did)
from models.scene import scene_manager
ok = scene_manager.import_scene_zip(zip_data)
return {"ok": ok, "missing_dashboards": missing}
except Exception as e:
return {"ok": False, "error": str(e)}
@router.get("/market/mine")
async def api_market_mine(token: str = ""):
import httpx
cfg = get_config()
url = cfg.get("market_url", "http://127.0.0.1:5301")
result = {"dashboards": [], "scenes": []}
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(f"{url}/api/collections/dashboards/records?sort=-name&perPage=100",
headers={"Authorization": f"Bearer {token}"})
result["dashboards"] = r.json().get("items", [])
r = await client.get(f"{url}/api/collections/scenes/records?sort=-name&perPage=100",
headers={"Authorization": f"Bearer {token}"})
result["scenes"] = r.json().get("items", [])
except Exception:
pass
return result
@router.post("/market/delete")
async def api_market_delete(data: dict):
import httpx
cfg = get_config()
url = cfg.get("market_url", "http://127.0.0.1:5301")
try:
async with httpx.AsyncClient(timeout=10) as client:
collection = data.get("collection", "dashboards")
record_id = data.get("id", "")
token = data.get("token", "")
resp = await client.delete(
f"{url}/api/collections/{collection}/records/{record_id}",
headers={"Authorization": f"Bearer {token}"}
)
return {"ok": resp.status_code < 400}
except Exception as e:
return {"ok": False, "error": str(e)}