This commit is contained in:
2026-08-19 15:08:39 +03:00
parent 478557f3ad
commit a03c54dbe8
5148 changed files with 31553313 additions and 0 deletions

View File

@@ -0,0 +1,526 @@
from __future__ import annotations
import base64
import io
import json
import re
import zipfile
from copy import deepcopy
from pathlib import Path
from typing import Any, Callable, TYPE_CHECKING
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from fastapi.responses import JSONResponse
from broadcast_settings import settings as settings_lib
from broadcast_settings.joins import flatten_dict
from broadcast_settings.transform import apply_configured_json
if TYPE_CHECKING:
from .service import HockeyDataService
SUPPORTED_SOURCE_TYPES = {
"info",
"scoreboard",
"home_lineup",
"away_lineup",
"officials",
"penalties",
"events",
"full_match",
}
SOURCE_LABELS = {
"info": "Информация о матче",
"scoreboard": "Верхний счёт",
"home_lineup": "Состав хозяев",
"away_lineup": "Состав гостей",
"officials": "Судьи",
"penalties": "Активные удаления",
"events": "События матча",
"full_match": "Полный матч",
}
def _read_json(path: Path, fallback: dict[str, Any]) -> dict[str, Any]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else deepcopy(fallback)
except Exception:
return deepcopy(fallback)
def configure_hockey_vmix_settings(base_dir: Path) -> None:
default_settings = {
"version": 1,
"active_vmix_json": "scoreboard",
"selection": {},
}
default_vmix_json = _read_json(
base_dir / "hockey_data" / "default_vmix_json.json",
{"version": 1, "configs": []},
)
default_vmix_functions = _read_json(
base_dir / "hockey_data" / "default_vmix_functions.json",
{"version": 1, "categories": []},
)
settings_lib.configure(
project_dir=base_dir,
app_dir=base_dir,
settings_dir=base_dir / "settings",
settings_file="vmix_settings.json",
vmix_json_file="vmix_json.json",
vmix_functions_file="vmix_functions.json",
default_settings=default_settings,
default_vmix_json=default_vmix_json,
default_vmix_functions=default_vmix_functions,
)
settings_lib.ensure_settings_files()
# Existing projects may have an older config file. Normalize it on startup.
settings_lib.write_vmix_json(normalize_hockey_vmix_json(settings_lib.read_vmix_json()))
def _safe_key(value: Any, fallback: str = "json") -> str:
text = re.sub(r"[^A-Za-z0-9_]+", "_", str(value or "").strip()).strip("_")
return text or fallback
def endpoint_for_key(key: str) -> str:
return f"/vmix/hockey/channel/{{channel}}/{{language}}/custom/{_safe_key(key)}.json"
def normalize_hockey_vmix_json(data: Any) -> dict[str, Any]:
source = data if isinstance(data, dict) else {}
configs = source.get("configs") if isinstance(source.get("configs"), list) else []
normalized: list[dict[str, Any]] = []
used: set[str] = set()
for index, raw in enumerate(configs, start=1):
if not isinstance(raw, dict):
continue
cfg = deepcopy(raw)
base_key = _safe_key(cfg.get("key"), f"json_{index}")
key = base_key
suffix = 2
while key in used:
key = f"{base_key}_{suffix}"
suffix += 1
used.add(key)
source_type = str(cfg.get("source_type") or "scoreboard").strip().lower()
if source_type not in SUPPORTED_SOURCE_TYPES:
cfg.setdefault("imported_source_type", source_type)
source_type = "scoreboard"
cfg.update(
{
"key": key,
"title": str(cfg.get("title") or key),
"description": str(cfg.get("description") or ""),
"endpoint": endpoint_for_key(key),
"source_type": source_type,
"root_key": str(cfg.get("root_key") or "rows"),
"output_mode": "all" if str(cfg.get("output_mode") or "columns").lower() == "all" else "columns",
"default_limit": max(0, int(cfg.get("default_limit") or 0)),
"enabled": cfg.get("enabled", True) is not False,
"columns": cfg.get("columns") if isinstance(cfg.get("columns"), list) else [],
"joins": cfg.get("joins") if isinstance(cfg.get("joins"), list) else [],
}
)
normalized.append(cfg)
return {"version": int(source.get("version") or 1), "configs": normalized}
class HockeyVmixPortable:
def __init__(self, service: "HockeyDataService") -> None:
self.service = service
@staticmethod
def _data_language(language: str) -> str:
return "en" if language == "en" else "ru"
def session_or_error(self, token: str) -> dict[str, Any]:
session = self.service.get_session(str(token or "").strip())
if session is None:
raise KeyError("Сессия не найдена")
return session
def _context(self, token: str, language: str) -> dict[str, Any]:
session = self.session_or_error(token)
data_language = self._data_language(language)
game_id = str(session.get("game_external_id") or "")
tournament_id = str(session.get("tournament_external_id") or "")
details = self.service.game_details(game_id, language=data_language) if game_id else None
tournament = self.service.tournament(tournament_id, language=data_language) if tournament_id else None
control = self.service.game_control(game_id, language=data_language) if game_id else None
return {
"session": session,
"language": language,
"data_language": data_language,
"details": details or {},
"tournament": tournament or {},
"control": control or {},
"game": (details or {}).get("game") or {},
}
@staticmethod
def _timer_text(control: dict[str, Any]) -> tuple[str, dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]:
shootout = control.get("shootout") or {}
timer_rules = control.get("timer_rules") or {}
strength = control.get("strength") or {}
timer_state = control.get("timers") or {}
main_timer = timer_state.get("main_timer") or {}
current_ms = max(
0,
int(float(main_timer.get("current_ms", timer_rules.get("duration_ms", 20 * 60 * 1000)) or 0)),
)
total_seconds = current_ms // 1000
return (
f"{total_seconds // 60:02d}:{total_seconds % 60:02d}",
shootout,
timer_rules,
strength,
timer_state,
)
def info_payload(self, token: str, language: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
ctx = context or self._context(token, language)
game = ctx["game"]
return {
"session_token": token, # legacy field
"channel_key": token,
"language": language,
"tournament": ctx["tournament"],
"game": game,
"game_id": ctx["session"].get("game_external_id", ""),
"home_team": (game.get("home") or {}).get("name", ""),
"away_team": (game.get("away") or {}).get("name", ""),
"arena": game.get("arena", ""),
"date": game.get("date", ""),
"start_time": game.get("time", ""),
"referees": ctx["details"].get("referees", []),
"control": ctx["control"],
}
def scoreboard_payload(self, token: str, language: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
ctx = context or self._context(token, language)
game = ctx["game"]
control = ctx["control"]
timer_text, shootout, timer_rules, strength, timer_state = self._timer_text(control)
main_timer = timer_state.get("main_timer") or {}
flags = control.get("flags") or {}
return {
"session_token": token, # legacy field
"channel_key": token,
"language": language,
"game_id": ctx["session"].get("game_external_id", ""),
"home_score": (game.get("home") or {}).get("score", 0),
"away_score": (game.get("away") or {}).get("score", 0),
"period": control.get("current_period", "1"),
"time": timer_text,
"timer_running": bool(main_timer.get("running", False)),
"timer_paused": bool(main_timer.get("paused", True)),
"timer_default_minutes": timer_rules.get("minutes", 20),
"timer_phase": timer_rules.get("phase", "regulation"),
"strength": strength.get("strength_label", ""),
"strength_phase": strength.get("phase", "regulation"),
"strength_advantage_side": strength.get("advantage_side", ""),
"strength_state_key": strength.get("state_key", ""),
"strength_state_label": strength.get("state_label", ""),
"home_strength": strength.get("home_skaters", 0),
"away_strength": strength.get("away_skaters", 0),
"home_powerplay_label": strength.get("home_label", ""),
"away_powerplay_label": strength.get("away_label", ""),
"home_delayed_penalty": bool(flags.get("home_delayed_penalty", False)),
"away_delayed_penalty": bool(flags.get("away_delayed_penalty", False)),
"home_empty_net": bool(flags.get("home_empty_net", False)),
"away_empty_net": bool(flags.get("away_empty_net", False)),
"status": game.get("status", "not_loaded"),
"shootout_home": (shootout.get("home") or {}).get("goals", 0),
"shootout_away": (shootout.get("away") or {}).get("goals", 0),
"shootout_attempts": shootout.get("attempts", []),
}
def line_up_payload(self, token: str, language: str, side: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
ctx = context or self._context(token, language)
items = ((ctx["game"].get(side) or {}).get("players") or [])
return {"session_token": token, "channel_key": token, "language": language, "items": items}
def officials_payload(self, token: str, language: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
ctx = context or self._context(token, language)
return {"session_token": token, "channel_key": token, "language": language, "items": ctx["details"].get("referees", [])}
def penalties_payload(self, token: str, language: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
ctx = context or self._context(token, language)
timer_state = (ctx["control"].get("timers") or {})
board = timer_state.get("penalty_board") or {}
raw_items = board.get("penalties")
items = [item for item in raw_items if isinstance(item, dict)] if isinstance(raw_items, list) else []
def _remaining(item: dict[str, Any]) -> float:
try:
return float(item.get("remainingMs", item.get("remaining_ms", 0)) or 0)
except (TypeError, ValueError):
return 0.0
home_items = sorted(
[item for item in items if str(item.get("side") or (item.get("player") or {}).get("side") or "").lower() == "home"],
key=lambda item: (_remaining(item), float(item.get("createdAt", 0) or 0)),
)
away_items = sorted(
[item for item in items if str(item.get("side") or (item.get("player") or {}).get("side") or "").lower() == "away"],
key=lambda item: (_remaining(item), float(item.get("createdAt", 0) or 0)),
)
return {
"session_token": token, # legacy field
"channel_key": token,
"language": language,
"game_id": ctx["session"].get("game_external_id", ""),
"home": home_items,
"away": away_items,
"home_next": home_items[0] if home_items else {},
"away_next": away_items[0] if away_items else {},
"items": items,
"strength": ctx["control"].get("strength") or {},
"timer_rules": ctx["control"].get("timer_rules") or {},
"updated_at": timer_state.get("updated_at", ""),
}
def events_payload(self, token: str, language: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
ctx = context or self._context(token, language)
details = ctx["details"]
items = details.get("events")
if not isinstance(items, list):
items = details.get("play_by_play")
if not isinstance(items, list):
items = []
return {"session_token": token, "channel_key": token, "language": language, "items": items}
def full_match_payload(self, token: str, language: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
ctx = context or self._context(token, language)
return {
"session": ctx["session"],
"language": language,
"tournament": ctx["tournament"],
"details": ctx["details"],
"control": ctx["control"],
"scoreboard": self.scoreboard_payload(token, language, context=ctx),
}
def source_payload(self, token: str, language: str, source_type: str, context: dict[str, Any] | None = None) -> Any:
source_type = str(source_type or "scoreboard").strip().lower()
if source_type not in SUPPORTED_SOURCE_TYPES:
raise ValueError(f"Неизвестный хоккейный источник: {source_type}")
ctx = context or self._context(token, language)
if source_type == "info":
return self.info_payload(token, language, context=ctx)
if source_type == "scoreboard":
return self.scoreboard_payload(token, language, context=ctx)
if source_type == "home_lineup":
return self.line_up_payload(token, language, "home", context=ctx)
if source_type == "away_lineup":
return self.line_up_payload(token, language, "away", context=ctx)
if source_type == "officials":
return self.officials_payload(token, language, context=ctx)
if source_type == "penalties":
return self.penalties_payload(token, language, context=ctx)
if source_type == "events":
return self.events_payload(token, language, context=ctx)
return self.full_match_payload(token, language, context=ctx)
def source_rows(self, token: str, language: str, source_type: str, context: dict[str, Any] | None = None) -> list[dict[str, Any]]:
payload = self.source_payload(token, language, source_type, context=context)
if isinstance(payload, list):
return [item for item in payload if isinstance(item, dict)]
if not isinstance(payload, dict):
return [{"value": payload}]
if source_type in {"home_lineup", "away_lineup", "officials", "penalties", "events"}:
items = payload.get("items")
if isinstance(items, list):
return [item for item in items if isinstance(item, dict)]
return [payload]
def source_fields(self, token: str, language: str, source_type: str) -> dict[str, Any]:
rows = self.source_rows(token, language, source_type)
keys: list[str] = []
for row in rows[:30]:
for key, value in flatten_dict(row).items():
if isinstance(value, (dict, list)):
continue
key = str(key)
if key not in keys:
keys.append(key)
return {"ok": True, "source_type": source_type, "count": len(rows), "fields": keys}
def configured_output(self, token: str, language: str, json_key: str, limit: int | None = None) -> list[dict[str, Any]]:
config = settings_lib.get_vmix_config(json_key)
context = self._context(token, language)
cache: dict[str, list[dict[str, Any]]] = {}
def rows_for(source_type: str) -> list[dict[str, Any]]:
source_type = str(source_type or "scoreboard").strip().lower()
if source_type not in cache:
cache[source_type] = self.source_rows(token, language, source_type, context=context)
return deepcopy(cache[source_type])
def source_resolver(join: dict[str, Any]) -> Any:
source_type = str(
join.get("source_type")
or join.get("source")
or join.get("type")
or "scoreboard"
).strip().lower()
return rows_for(source_type)
rows = rows_for(str(config.get("source_type") or "scoreboard"))
return apply_configured_json(rows, config, limit=limit, source_resolver=source_resolver)
def _extract_import_json_from_zip(file_name: str, content: bytes) -> tuple[dict[str, Any], list[str]]:
try:
archive = zipfile.ZipFile(io.BytesIO(content))
except zipfile.BadZipFile as exc:
raise ValueError("ZIP-файл не открывается или повреждён") from exc
names = [name for name in archive.namelist() if not name.endswith("/")]
json_names = [name for name in names if name.lower().endswith(".json")]
if not json_names:
raise ValueError("В ZIP не найдено JSON-файлов с настройками")
def load_member(name: str) -> Any:
with archive.open(name) as file:
return json.loads(file.read().decode("utf-8-sig"))
preferred_vmix = next(
(name for name in json_names if name.lower().replace("\\", "/").endswith("settings/vmix_json.json")),
None,
) or next((name for name in json_names if name.lower().endswith("vmix_json.json")), None)
preferred_settings = next(
(name for name in json_names if name.lower().replace("\\", "/").endswith("settings/settings.json")),
None,
) or next((name for name in json_names if name.lower().endswith("settings.json")), None)
result: dict[str, Any] = {"_import_archive": file_name}
used: list[str] = []
if preferred_settings:
result["settings"] = load_member(preferred_settings)
used.append(preferred_settings)
if preferred_vmix:
result["vmix_json"] = load_member(preferred_vmix)
if isinstance(result["vmix_json"], dict):
result["configs"] = result["vmix_json"].get("configs")
used.append(preferred_vmix)
if used:
result["_import_used_files"] = used
return result, used
files: dict[str, Any] = {}
for name in json_names[:50]:
lowered = name.lower()
if any(part in lowered for part in ("cache/", "__pycache__", "response_", "scores")):
continue
try:
files[name] = load_member(name)
used.append(name)
except Exception:
continue
if not files:
raise ValueError("JSON-файлы найдены, но настройки прочитать не удалось")
return {"_import_archive": file_name, "files": files, "_import_used_files": used}, used
def create_hockey_vmix_settings_router(
portable: HockeyVmixPortable,
*,
admin_dependency: Callable[..., Any],
) -> APIRouter:
router = APIRouter(
prefix="/api/hockey/vmix",
tags=["Hockey vMix settings"],
dependencies=[Depends(admin_dependency)],
)
@router.get("/settings")
async def get_settings() -> dict[str, Any]:
return {
"settings": settings_lib.read_settings(),
"vmix_json": normalize_hockey_vmix_json(settings_lib.read_vmix_json()),
"vmix_functions": settings_lib.read_vmix_functions(),
"source_types": [
{"value": key, "label": SOURCE_LABELS[key]}
for key in SOURCE_LABELS
],
"meta": settings_lib.settings_meta(),
}
@router.put("/configs")
async def save_configs(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
raw = payload.get("vmix_json", payload)
if not isinstance(raw, dict):
raise HTTPException(status_code=400, detail="vmix_json должен быть объектом")
normalized = normalize_hockey_vmix_json(raw)
return {"vmix_json": settings_lib.write_vmix_json(normalized), "meta": settings_lib.settings_meta()}
@router.get("/source-fields")
async def source_fields(
token: str = Query(..., min_length=4),
language: str = Query("ru", pattern="^(ru|en|bilingual)$"),
source_type: str = Query("scoreboard"),
) -> dict[str, Any]:
try:
return portable.source_fields(token, language, source_type)
except (KeyError, ValueError) as exc:
raise HTTPException(status_code=404 if isinstance(exc, KeyError) else 400, detail=str(exc)) from exc
@router.get("/preview/{json_key}")
async def preview(
json_key: str,
token: str = Query(..., min_length=4),
language: str = Query("ru", pattern="^(ru|en|bilingual)$"),
limit: int | None = Query(None, ge=0, le=1000),
) -> JSONResponse:
try:
return JSONResponse(portable.configured_output(token, language, json_key, limit=limit))
except (KeyError, ValueError) as exc:
raise HTTPException(status_code=404 if isinstance(exc, KeyError) else 400, detail=str(exc)) from exc
@router.get("/export")
async def export_settings() -> dict[str, Any]:
return {
"format": "hockey-vmix-settings",
"version": 1,
"vmix_json": normalize_hockey_vmix_json(settings_lib.read_vmix_json()),
}
@router.post("/import/preview-file")
async def import_preview_file(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
file_name = str(payload.get("file_name") or payload.get("name") or "settings.json")
content_b64 = payload.get("content_base64") or payload.get("base64")
if not content_b64:
raise HTTPException(status_code=400, detail="Нужно передать файл в content_base64")
try:
content = base64.b64decode(str(content_b64), validate=False)
if file_name.lower().endswith(".zip"):
data, _ = _extract_import_json_from_zip(file_name, content)
else:
data = json.loads(content.decode("utf-8-sig"))
preview_value = settings_lib.preview_settings_import(data, file_name=file_name)
preview_value["raw_import"] = data
return preview_value
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Не удалось прочитать настройки: {exc}") from exc
@router.post("/import/apply")
async def import_apply(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
preview_value = payload.get("preview") or payload
if not isinstance(preview_value, dict):
raise HTTPException(status_code=400, detail="Нужно передать preview")
try:
result = settings_lib.apply_settings_import(
preview=preview_value,
selected_indexes=payload.get("selected_indexes") if isinstance(payload.get("selected_indexes"), list) else None,
import_general=False,
import_vmix=True,
mode=str(payload.get("mode") or "add_new"),
)
normalized = normalize_hockey_vmix_json(result.get("vmix_json") or settings_lib.read_vmix_json())
saved = settings_lib.write_vmix_json(normalized)
return {**result, "vmix_json": saved, "meta": settings_lib.settings_meta()}
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return router