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", }, 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=-created&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)}