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

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

156
app.py
View File

@@ -16,11 +16,8 @@ import json
from pathlib import Path from pathlib import Path
import io import io
import asyncio import asyncio
import contextlib
import traceback
import time import time
from urllib.parse import quote from urllib.parse import quote
from db import get_connection
from repositories.match_formation_repository import ( from repositories.match_formation_repository import (
get_match_formations, get_match_formations,
@@ -29,9 +26,6 @@ from repositories.match_formation_repository import (
) )
from parsers.parser_game import run_parser_game 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 ( from repositories.match_session_repository import (
create_match_session, create_match_session,
get_match_session_by_token, 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 repositories.auth_repository import get_user_by_username
from services.auth_service import ( from services.auth_service import (
create_auth_session, create_auth_session,
hash_password,
verify_password, verify_password,
get_current_user_from_request, get_current_user_from_request,
build_not_authenticated_response, build_not_authenticated_response,
@@ -128,6 +121,7 @@ class PublishVmixCommandPayload(BaseModel):
commands: List[str] = Field(default_factory=list) commands: List[str] = Field(default_factory=list)
target_client_id: str | None = None target_client_id: str | None = None
target_group: str | None = None target_group: str | None = None
target_login: str | None = None
meta: dict | None = None meta: dict | None = None
@@ -242,6 +236,7 @@ class VmixConnectionManager:
message: dict, message: dict,
target_group: str | None = None, target_group: str | None = None,
session_token: str | None = None, session_token: str | None = None,
target_login: str | None = None,
): ):
async with self.lock: async with self.lock:
items = list(self.connections.values()) items = list(self.connections.values())
@@ -252,6 +247,8 @@ class VmixConnectionManager:
continue continue
if target_group and conn.get("group_name") != target_group: if target_group and conn.get("group_name") != target_group:
continue continue
if target_login and conn.get("operator_name") != target_login:
continue
if session_token and conn.get("session_token") != session_token: if session_token and conn.get("session_token") != session_token:
continue continue
matched.append(conn["client_id"]) 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") @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) session_row = get_match_session_by_token(session_token)
if not session_row: if not session_row:
return JSONResponse({"error": "session_not_found"}, status_code=404) 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: 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) filename = build_vmix_filename(session_row)
return StreamingResponse( return StreamingResponse(
@@ -732,143 +736,17 @@ def close_session(session_token: str):
return RedirectResponse(url="/admin/matches", status_code=303) return RedirectResponse(url="/admin/matches", status_code=303)
def render_admin_db_index( @app.get("/admin/db", response_class=HTMLResponse)
request: Request, def 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,
):
return templates.TemplateResponse( return templates.TemplateResponse(
name="admin_db_index.html", name="admin_db_index.html",
request=request, request=request,
context={ context={
"current_user": getattr(request.state, "current_user", None), "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) @app.get("/admin/db/players", response_class=HTMLResponse)
def admin_db_players(request: Request, q: str = Query(default="")): def admin_db_players(request: Request, q: str = Query(default="")):
# denied = require_role(request, {"admin"}) # denied = require_role(request, {"admin"})
@@ -1700,6 +1578,7 @@ async def ws_vmix_client(
"client_id": client_id, "client_id": client_id,
"match_id": match_id, "match_id": match_id,
"session_token": session_token, "session_token": session_token,
"operator_name": operator_name,
}) })
while True: while True:
@@ -1748,6 +1627,7 @@ async def publish_vmix_command(payload: PublishVmixCommandPayload, request: Requ
"match_id": payload.match_id, "match_id": payload.match_id,
"session_token": payload.session_token, "session_token": payload.session_token,
"commands": commands, "commands": commands,
"target_login": payload.target_login,
"meta": payload.meta or {}, "meta": payload.meta or {},
"sent_at": time.time(), "sent_at": time.time(),
} }
@@ -1761,6 +1641,7 @@ async def publish_vmix_command(payload: PublishVmixCommandPayload, request: Requ
message=message, message=message,
target_group=payload.target_group, target_group=payload.target_group,
session_token=payload.session_token, session_token=payload.session_token,
target_login=payload.target_login,
) )
log_action( log_action(
@@ -1773,6 +1654,7 @@ async def publish_vmix_command(payload: PublishVmixCommandPayload, request: Requ
details={ details={
"target_client_id": payload.target_client_id, "target_client_id": payload.target_client_id,
"target_group": payload.target_group, "target_group": payload.target_group,
"target_login": payload.target_login,
"commands_count": len(commands), "commands_count": len(commands),
"results": results, "results": results,
}, },

View File

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

View File

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

View File

@@ -133,12 +133,18 @@ def change_vmix_datasource_urls(
# value2 = match_id # value2 = match_id
get_or_create_dynamic_value(1).text = "" if match_id is None else str(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") 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( vmix_bio = nasio.load_bio(
user=SYNO_USERNAME, user=SYNO_USERNAME,
password=SYNO_PASSWORD, password=SYNO_PASSWORD,
@@ -152,6 +158,7 @@ def build_vmix_project_bytes(session_token: str, match_id: str | int | None = No
FQDN, FQDN,
session_token, session_token,
match_id, match_id,
operator_login,
) )
if isinstance(edited_vmix, str): if isinstance(edited_vmix, str):