from __future__ import annotations import os from pathlib import Path from typing import Any import httpx from dotenv import load_dotenv from fastapi import Depends, FastAPI, HTTPException, Query, Request from fastapi.responses import JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from hockey_data import ( HockeyDataService, HockeyDatabase, HockeySettingsStore, create_hockey_router, ) from hockey_data.router import create_hockey_vmix_router from hockey_data.vmix_portable import ( HockeyVmixPortable, configure_hockey_vmix_settings, create_hockey_vmix_settings_router, ) from hockey_data.auth_bridge import HockeyAuthDependencies, create_auth_adapter from hockey_data.auth_router import create_hockey_auth_router from hockey_data.agent_bridge import VmixAgentHub, create_hockey_agent_router from ui_builder import install_ui_builder from khl_site.khl_data_center import APP as khl_site_app BASE_DIR = Path(__file__).resolve().parent BUILD_VERSION = "2026.08.19.22" load_dotenv(BASE_DIR / ".env.local") load_dotenv(BASE_DIR / ".env") VMIX_API_URL = os.getenv("VMIX_API_URL", "http://127.0.0.1:8088/api/") configure_hockey_vmix_settings(BASE_DIR) app = FastAPI(title=f"Хоккейная панель управления / Stat2TV — build {BUILD_VERSION}") print(f"[Hockey Build] {BUILD_VERSION}") hockey_settings = HockeySettingsStore(BASE_DIR / "settings") hockey_database = HockeyDatabase(BASE_DIR) hockey_auth = HockeyAuthDependencies(create_auth_adapter(hockey_database)) hockey_agent_hub = VmixAgentHub(hockey_database, settings=hockey_settings) @app.on_event("startup") async def hockey_mapping_auto_refresh_startup() -> None: hockey_agent_hub.start_auto_refresh() @app.on_event("shutdown") async def hockey_mapping_auto_refresh_shutdown() -> None: await hockey_agent_hub.stop_auto_refresh() @app.middleware("http") async def hockey_runtime_auth(request: Request, call_next): """Protect runtime and prevent stale operator-interface assets.""" if request.url.path == "/" or request.url.path.startswith("/khl-site"): try: user = await hockey_auth.optional_user(request) except HTTPException as error: return JSONResponse({"detail": error.detail}, status_code=error.status_code) if user is None: if request.url.path.startswith("/khl-site"): return JSONResponse({"detail": "Требуется вход"}, status_code=401) return RedirectResponse("/login?reason=expired", status_code=303) if request.url.path.startswith("/khl-site") and not user.is_admin: return JSONResponse({"detail": "Раздел КХЛ доступен только администратору"}, status_code=403) request.state.wfl_user = user response = await call_next(request) path = request.url.path if ( path == "/" or path.startswith("/ui-builder-assets/") or path.startswith("/hockey-assets/") or path.startswith("/api/ui-builder/runtime/") or path.startswith("/khl-site") ): response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" return response hockey_service = HockeyDataService( base_dir=BASE_DIR, database=hockey_database, settings=hockey_settings, ) hockey_vmix_portable = HockeyVmixPortable(hockey_service) HOCKEY_BOOTSTRAP = hockey_service.bootstrap() _backfill = HOCKEY_BOOTSTRAP.get("normalized_backfill", {}) if isinstance(HOCKEY_BOOTSTRAP, dict) else {} if any(int(value or 0) for value in _backfill.values() if isinstance(value, (int, float))): print( "[Hockey DB] normalized backfill updated " + " ".join(f"{key}={value}" for key, value in _backfill.items()) ) HOCKEY_SCHEMA_STATUS = hockey_database.schema_status() print( "[Hockey DB] schema ok=" f"{HOCKEY_SCHEMA_STATUS.get('ok')} " f"repaired={len(HOCKEY_SCHEMA_STATUS.get('repaired', []))} " f"rebuilt={HOCKEY_SCHEMA_STATUS.get('rebuilt', False)}" ) app.mount("/khl-site", khl_site_app) app.mount( "/hockey-assets", StaticFiles(directory=BASE_DIR / "hockey_data" / "static"), name="hockey-assets", ) def hockey_stat2tv_data() -> dict[str, Any]: return hockey_service.data_for_builder() def vmix_demo_data() -> dict[str, Any]: home_players = [ {"id": f"H{i:02d}", "number": number, "name": name, "position": position} for i, (number, name, position) in enumerate([ (1, "Максим Лебедев", "ВР"), (30, "Илья Зорин", "ВР"), (2, "Алексей Титов", "ЗАЩ"), (4, "Егор Савельев", "ЗАЩ"), (7, "Антон Крылов", "НАП"), (9, "Даниил Орехов", "НАП"), (10, "Никита Сергеев", "НАП"), (11, "Михаил Сафонов", "НАП"), (13, "Владимир Комаров", "ЗАЩ"), (15, "Роман Беляев", "НАП"), (17, "Андрей Иванов", "НАП"), (19, "Степан Волков", "НАП"), (21, "Кирилл Панов", "ЗАЩ"), (23, "Олег Миронов", "НАП"), (25, "Арсений Белов", "НАП"), (27, "Матвей Фролов", "ЗАЩ"), (29, "Глеб Мельников", "НАП"), (44, "Павел Кузнецов", "ЗАЩ"), (71, "Иван Ларионов", "НАП"), (77, "Семён Жуков", "НАП"), (81, "Фёдор Егоров", "НАП"), (91, "Артём Петров", "НАП"), ], start=1) ] away_players = [ {"id": f"A{i:02d}", "number": number, "name": name, "position": position} for i, (number, name, position) in enumerate([ (20, "Лев Корнеев", "ВР"), (35, "Тимур Котов", "ВР"), (3, "Ярослав Сорокин", "ЗАЩ"), (5, "Денис Макаров", "ЗАЩ"), (8, "Георгий Гусев", "НАП"), (12, "Виктор Назаров", "НАП"), (14, "Руслан Власов", "НАП"), (16, "Борис Тарасов", "ЗАЩ"), (18, "Марк Селезнёв", "НАП"), (22, "Станислав Никифоров", "ЗАЩ"), (24, "Игорь Соколов", "НАП"), (28, "Вадим Фомин", "НАП"), (32, "Пётр Громов", "ЗАЩ"), (37, "Леонид Васильев", "НАП"), (41, "Константин Ершов", "ЗАЩ"), (55, "Захар Захаров", "ЗАЩ"), (61, "Александр Осипов", "НАП"), (68, "Сергей Орлов", "НАП"), (73, "Дмитрий Богданов", "НАП"), (84, "Валерий Маслов", "НАП"), (88, "Николай Голубев", "НАП"), (97, "Анатолий Чернов", "НАП"), ], start=1) ] return { "vmix": { "api_url": VMIX_API_URL, "function": "OverlayInput4In", "input": 2, }, "hockey": { "home": {"name": "СЕВЕР", "players": home_players}, "away": {"name": "ВОСТОК", "players": away_players}, }, } @app.get("/api/vmix/command") async def vmix_command( function: str = Query(..., min_length=1), input_value: str | None = Query(default=None), ) -> dict[str, Any]: """Send a shortcut command to the local vMix HTTP API. The browser calls this same-origin FastAPI route. FastAPI then calls vMix, so browser CORS restrictions do not block the command. """ params: dict[str, str] = {"Function": function} if input_value not in (None, ""): params["Input"] = input_value try: async with httpx.AsyncClient(timeout=5.0) as client: response = await client.get(VMIX_API_URL, params=params) except httpx.RequestError as error: raise HTTPException( status_code=502, detail=f"Не удалось подключиться к vMix по адресу {VMIX_API_URL}: {error}", ) from error if response.status_code != 200: raise HTTPException( status_code=502, detail={ "message": "vMix вернул ошибку", "status": response.status_code, "response": response.text[:1000], }, ) return { "ok": True, "vmix_url": str(response.request.url), "function": function, "input": input_value, } install_ui_builder( app, settings_dir=BASE_DIR / "settings", data_providers={ "hockey_stat2tv": hockey_stat2tv_data, "vmix_demo": vmix_demo_data, }, source_labels={ "hockey_stat2tv": "Stat2TV — хоккей", "vmix_demo": "vMix — тестовые данные", }, editor_url="/editor", runtime_url="/", custom_script_urls=[ f"/hockey-assets/tournament-menu.js?v={BUILD_VERSION}", f"/hockey-assets/admin-directories.js?v={BUILD_VERSION}", "/hockey-assets/match-status-header.js", "/hockey-assets/agent-devices.js", ], custom_style_urls=[ "/hockey-assets/tournament-menu.css", f"/hockey-assets/admin-directories.css?v={BUILD_VERSION}", "/hockey-assets/match-status-header.css", "/hockey-assets/agent-devices.css", ], ) @app.post( "/api/hockey/ui/prematch-buttons", dependencies=[Depends(hockey_auth.require_user)], tags=["Hockey UI"], ) async def save_hockey_prematch_buttons(payload: dict[str, Any]) -> dict[str, Any]: """Save operator prematch buttons without replacing the rest of UI Builder config.""" buttons = payload.get("prematch_buttons") groups = payload.get("prematch_groups", []) selectors = payload.get("quick_panel_selectors", []) if not isinstance(buttons, list): raise HTTPException(status_code=422, detail="prematch_buttons must be a list") if not isinstance(groups, list): raise HTTPException(status_code=422, detail="prematch_groups must be a list") if not isinstance(selectors, list): raise HTTPException(status_code=422, detail="quick_panel_selectors must be a list") draft_manager = app.state.ui_builder_draft_manager published_manager = app.state.ui_builder_published_manager draft_config = draft_manager.load() draft_config["prematch_groups"] = groups draft_config["prematch_buttons"] = buttons draft_config["quick_panel_selectors"] = selectors saved_draft = draft_manager.save(draft_config) published_config = published_manager.load() published_config["prematch_groups"] = saved_draft.get("prematch_groups", []) published_config["prematch_buttons"] = saved_draft.get("prematch_buttons", []) published_config["quick_panel_selectors"] = saved_draft.get("quick_panel_selectors", []) saved_published = published_manager.save(published_config, create_backup=True) return { "ok": True, "prematch_groups": saved_published.get("prematch_groups", []), "prematch_buttons": saved_published.get("prematch_buttons", []), "quick_panel_selectors": saved_published.get("quick_panel_selectors", []), "config": saved_published, } @app.post( "/api/hockey/ui/triggers", dependencies=[Depends(hockey_auth.require_user)], tags=["Hockey UI"], ) async def save_hockey_ui_triggers(payload: dict[str, Any]) -> dict[str, Any]: """Save only UI triggers for an authenticated operator, preserving all other config.""" triggers = payload.get("triggers") if not isinstance(triggers, list): raise HTTPException(status_code=422, detail="triggers must be a list") draft_manager = app.state.ui_builder_draft_manager published_manager = app.state.ui_builder_published_manager draft_config = draft_manager.load() draft_config["triggers"] = triggers saved_draft = draft_manager.save(draft_config) published_config = published_manager.load() published_config["triggers"] = saved_draft.get("triggers", []) saved_published = published_manager.save(published_config, create_backup=True) return { "ok": True, "triggers": saved_published.get("triggers", []), } async def _require_ui_builder_editor(request: Request) -> None: await app.state.ui_builder_auth.require_editor(request) @app.get( "/api/ui-builder/editor/vmix-inventory", dependencies=[Depends(_require_ui_builder_editor)], tags=["UI Builder"], ) async def ui_builder_vmix_inventory() -> dict[str, Any]: return await hockey_agent_hub.latest_vmix_inventory() app.include_router( create_hockey_router( hockey_service, auth_dependency=hockey_auth.require_user, admin_dependency=hockey_auth.require_admin, agent_hub=hockey_agent_hub, ) ) app.include_router(create_hockey_auth_router(hockey_auth)) app.include_router( create_hockey_agent_router( hockey_agent_hub, auth_dependency=hockey_auth.require_user, admin_dependency=hockey_auth.require_admin, ) ) app.include_router( create_hockey_vmix_settings_router( hockey_vmix_portable, admin_dependency=hockey_auth.require_admin, ) ) app.include_router(create_hockey_vmix_router(hockey_service, hockey_vmix_portable)) app.state.hockey_service = hockey_service app.state.hockey_database = hockey_database app.state.hockey_settings = hockey_settings app.state.hockey_auth = hockey_auth app.state.hockey_vmix_portable = hockey_vmix_portable app.state.hockey_agent_hub = hockey_agent_hub @app.get("/runtime", include_in_schema=False) async def legacy_runtime_redirect() -> RedirectResponse: return RedirectResponse("/", status_code=302) @app.get("/ui-builder", include_in_schema=False) async def legacy_editor_redirect() -> RedirectResponse: return RedirectResponse("/editor", status_code=302)