переделал систему подключения агента к веб-сокету

This commit is contained in:
2026-04-23 14:24:21 +03:00
parent bb1019f816
commit c03b767979
5 changed files with 56 additions and 144 deletions

View File

@@ -36,12 +36,14 @@ def read_vmix_dynamic_values():
session_token = get_text("./dynamic/value1")
match_id = get_text("./dynamic/value2")
group_name = get_text("./dynamic/value3")
operator_login = get_text("./dynamic/value4")
return {
"ok": True,
"session_token": session_token,
"match_id": int(match_id) if match_id and match_id.isdigit() else None,
"group_name": group_name,
"operator_login": operator_login,
}
except Exception as e:
return {
@@ -50,6 +52,7 @@ def read_vmix_dynamic_values():
"session_token": None,
"match_id": None,
"group_name": None,
"operator_login": None,
}
def execute_vmix_command(path: str):
@@ -82,7 +85,7 @@ async def wait_for_session():
print("[agent] waiting for vMix session...", data)
await asyncio.sleep(POLL_INTERVAL)
def build_ws_url(session_token: str, match_id: int | None, group_name: str | None):
def build_ws_url(session_token: str, match_id: int | None, group_name: str | None, operator_login: str | None):
params = {
"client_id": CLIENT_ID,
"session_token": session_token,
@@ -91,6 +94,8 @@ def build_ws_url(session_token: str, match_id: int | None, group_name: str | Non
params["match_id"] = match_id
if group_name:
params["group_name"] = group_name
if operator_login:
params["operator_name"] = operator_login
return f"{WS_BASE}?{urlencode(params)}"
async def ping_loop(ws):
@@ -100,6 +105,9 @@ async def ping_loop(ws):
async def run_agent():
current_session = None
current_match_id = None
current_group_name = None
current_operator_login = None
while True:
vmix_data = await wait_for_session()
@@ -107,12 +115,13 @@ async def run_agent():
session_token = vmix_data["session_token"]
match_id = vmix_data["match_id"]
group_name = vmix_data["group_name"]
operator_login = vmix_data["operator_login"]
if current_session != session_token:
print(f"[agent] found session: {session_token}")
current_session = session_token
ws_url = build_ws_url(session_token, match_id, group_name)
ws_url = build_ws_url(session_token, match_id, group_name, operator_login)
try:
async with websockets.connect(ws_url, ping_interval=None, max_size=2**20) as ws:
@@ -124,6 +133,7 @@ async def run_agent():
"session_token": session_token,
"match_id": match_id,
"group_name": group_name,
"operator_name": operator_login,
}))
pinger = asyncio.create_task(ping_loop(ws))
@@ -132,9 +142,17 @@ async def run_agent():
while True:
# следим, не сменился ли проект/session в vMix
latest = read_vmix_dynamic_values()
if latest["ok"] and latest["session_token"] and latest["session_token"] != current_session:
print("[agent] session changed in vMix, reconnecting")
break
if latest["ok"]:
latest_match_id = latest.get("match_id")
latest_group_name = latest.get("group_name")
latest_operator_login = latest.get("operator_login")
if (
latest_match_id != match_id
or latest_group_name != group_name
or latest_operator_login != operator_login
):
print("[agent] routing meta changed in vMix, reconnecting")
break
try:
raw = await asyncio.wait_for(ws.recv(), timeout=3)
@@ -150,6 +168,8 @@ async def run_agent():
"type": "vmix_result",
"client_id": CLIENT_ID,
"session_token": session_token,
"match_id": match_id,
"operator_name": operator_login,
"results": results,
"sent_at": time.time(),
}))

156
app.py
View File

@@ -16,11 +16,8 @@ import json
from pathlib import Path
import io
import asyncio
import contextlib
import traceback
import time
from urllib.parse import quote
from db import get_connection
from repositories.match_formation_repository import (
get_match_formations,
@@ -29,9 +26,6 @@ from repositories.match_formation_repository import (
)
from parsers.parser_game import run_parser_game
from parsers.parser_players import run_parser_players
from parsers.parser_schedule import run_parser_schedule
from parsers.parser_standings import run_parser_standings
from repositories.match_session_repository import (
create_match_session,
get_match_session_by_token,
@@ -91,7 +85,6 @@ from repositories.match_event_repository import (
from repositories.auth_repository import get_user_by_username
from services.auth_service import (
create_auth_session,
hash_password,
verify_password,
get_current_user_from_request,
build_not_authenticated_response,
@@ -128,6 +121,7 @@ class PublishVmixCommandPayload(BaseModel):
commands: List[str] = Field(default_factory=list)
target_client_id: str | None = None
target_group: str | None = None
target_login: str | None = None
meta: dict | None = None
@@ -242,6 +236,7 @@ class VmixConnectionManager:
message: dict,
target_group: str | None = None,
session_token: str | None = None,
target_login: str | None = None,
):
async with self.lock:
items = list(self.connections.values())
@@ -252,6 +247,8 @@ class VmixConnectionManager:
continue
if target_group and conn.get("group_name") != target_group:
continue
if target_login and conn.get("operator_name") != target_login:
continue
if session_token and conn.get("session_token") != session_token:
continue
matched.append(conn["client_id"])
@@ -578,13 +575,20 @@ def download_vmix_project_page(request: Request, session_token: str):
@app.get("/admin/session/{session_token}/download-vmix")
def download_vmix_project(session_token: str):
def download_vmix_project(request: Request, session_token: str):
session_row = get_match_session_by_token(session_token)
if not session_row:
return JSONResponse({"error": "session_not_found"}, status_code=404)
current_user = getattr(request.state, "current_user", None) or {}
operator_login = current_user.get("username") or None
try:
vmix_bytes = build_vmix_project_bytes(session_token=session_token, match_id=session_row[1])
vmix_bytes = build_vmix_project_bytes(
session_token=session_token,
match_id=session_row[1],
operator_login=operator_login,
)
filename = build_vmix_filename(session_row)
return StreamingResponse(
@@ -732,143 +736,17 @@ def close_session(session_token: str):
return RedirectResponse(url="/admin/matches", status_code=303)
def render_admin_db_index(
request: Request,
parser_output: str | None = None,
parser_name: str | None = None,
parser_success: bool | None = None,
account_output: str | None = None,
account_success: bool | None = None,
):
@app.get("/admin/db", response_class=HTMLResponse)
def admin_db_index(request: Request):
return templates.TemplateResponse(
name="admin_db_index.html",
request=request,
context={
"current_user": getattr(request.state, "current_user", None),
"parser_output": parser_output,
"parser_name": parser_name,
"parser_success": parser_success,
"account_output": account_output,
"account_success": account_success,
},
)
@app.get("/admin/db", response_class=HTMLResponse)
def admin_db_index(request: Request):
return render_admin_db_index(request)
@app.post("/admin/db/run-parser", response_class=HTMLResponse)
def admin_db_run_parser(request: Request, parser_name: str = Form(...)):
current_user = getattr(request.state, "current_user", None)
if not current_user or current_user.get("role") != "admin":
return RedirectResponse(url="/login", status_code=303)
parser_map = {
"players": ("Игроки", run_parser_players),
"schedule": ("Расписание", run_parser_schedule),
"standings": ("Турнирка", run_parser_standings),
}
parser_meta = parser_map.get(parser_name)
if not parser_meta:
return render_admin_db_index(
request,
parser_output="Неизвестный парсер.",
parser_name=parser_name,
parser_success=False,
)
title, parser_func = parser_meta
buffer = io.StringIO()
success = True
with contextlib.redirect_stdout(buffer), contextlib.redirect_stderr(buffer):
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Запуск парсера: {title}")
try:
parser_func()
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Парсер завершён успешно")
except Exception:
success = False
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Ошибка при выполнении парсера")
print(traceback.format_exc())
output = buffer.getvalue().strip() or "Парсер не вернул сообщений."
return render_admin_db_index(
request,
parser_output=output,
parser_name=title,
parser_success=success,
)
@app.post("/admin/db/create-account", response_class=HTMLResponse)
def admin_db_create_account(
request: Request,
username: str = Form(...),
password: str = Form(...),
role: str = Form(default="operator"),
):
current_user = getattr(request.state, "current_user", None)
if not current_user or current_user.get("role") != "admin":
return RedirectResponse(url="/login", status_code=303)
username = username.strip()
role = (role or "operator").strip().lower()
if not username:
return render_admin_db_index(request, account_output="Логин обязателен.", account_success=False)
if not password:
return render_admin_db_index(request, account_output="Пароль обязателен.", account_success=False)
if len(password) < 6:
return render_admin_db_index(request, account_output="Пароль должен быть не короче 6 символов.", account_success=False)
if role not in {"admin", "operator"}:
return render_admin_db_index(request, account_output="Роль должна быть admin или operator.", account_success=False)
password_hash = hash_password(password)
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO admin_users (username, password_hash, role, is_active)
VALUES (%s, %s, %s, TRUE)
ON CONFLICT (username) DO NOTHING
RETURNING id;
""",
(username, password_hash, role),
)
row = cur.fetchone()
conn.commit()
if row:
return render_admin_db_index(
request,
account_output=f"Пользователь создан: {username} (id={row[0]})\nРоль: {role}",
account_success=True,
)
return render_admin_db_index(
request,
account_output=f"Пользователь '{username}' уже существует.",
account_success=False,
)
except Exception:
conn.rollback()
return render_admin_db_index(
request,
account_output="Ошибка при создании аккаунта:\n" + traceback.format_exc(),
account_success=False,
)
finally:
conn.close()
@app.get("/admin/db/players", response_class=HTMLResponse)
def admin_db_players(request: Request, q: str = Query(default="")):
# denied = require_role(request, {"admin"})
@@ -1700,6 +1578,7 @@ async def ws_vmix_client(
"client_id": client_id,
"match_id": match_id,
"session_token": session_token,
"operator_name": operator_name,
})
while True:
@@ -1748,6 +1627,7 @@ async def publish_vmix_command(payload: PublishVmixCommandPayload, request: Requ
"match_id": payload.match_id,
"session_token": payload.session_token,
"commands": commands,
"target_login": payload.target_login,
"meta": payload.meta or {},
"sent_at": time.time(),
}
@@ -1761,6 +1641,7 @@ async def publish_vmix_command(payload: PublishVmixCommandPayload, request: Requ
message=message,
target_group=payload.target_group,
session_token=payload.session_token,
target_login=payload.target_login,
)
log_action(
@@ -1773,6 +1654,7 @@ async def publish_vmix_command(payload: PublishVmixCommandPayload, request: Requ
details={
"target_client_id": payload.target_client_id,
"target_group": payload.target_group,
"target_login": payload.target_login,
"commands_count": len(commands),
"results": results,
},

View File

@@ -39,6 +39,7 @@ const REFEREE_ROLE_OPTIONS = [
const VMIX_TARGET_CLIENT_ID = MATCH_DATA.vmixTargetClientId || null;
const VMIX_TARGET_GROUP = MATCH_DATA.vmixTargetGroup || null;
const CURRENT_USER_LOGIN = MATCH_DATA.currentUser?.username || null;
function vmixCmd(path) {
const value = String(path || "").trim();
@@ -1467,9 +1468,9 @@ async function sendDirectVmixCommands(commands) {
},
body: JSON.stringify({
match_id: MATCH_ID,
session_token: SESSION_TOKEN,
target_client_id: VMIX_TARGET_CLIENT_ID,
target_group: VMIX_TARGET_GROUP,
target_login: CURRENT_USER_LOGIN,
commands: items
})
});

View File

@@ -1072,6 +1072,8 @@ data-role="{{ p.position or '' }}"
referees: {{ referees | tojson }},
refereePool: {{ referee_pool | tojson }},
currentUser: {{ current_user | tojson }},
homeSquadPool: [],
awaySquadPool: [],
homeCoachPool: [],

View File

@@ -133,12 +133,18 @@ def change_vmix_datasource_urls(
# value2 = match_id
get_or_create_dynamic_value(1).text = "" if match_id is None else str(match_id)
# value4 = operator_login
get_or_create_dynamic_value(3).text = "" if not operator_login else str(operator_login)
return ET.tostring(root, encoding="utf-8", method="xml")
def build_vmix_project_bytes(session_token: str, match_id: str | int | None = None) -> bytes:
def build_vmix_project_bytes(
session_token: str,
match_id: str | int | None = None,
operator_login: str | None = None,
) -> bytes:
vmix_bio = nasio.load_bio(
user=SYNO_USERNAME,
password=SYNO_PASSWORD,
@@ -152,6 +158,7 @@ def build_vmix_project_bytes(session_token: str, match_id: str | int | None = No
FQDN,
session_token,
match_id,
operator_login,
)
if isinstance(edited_vmix, str):