This commit is contained in:
2026-08-24 17:59:33 +03:00
parent 1fbe845b30
commit 65a2521156
5 changed files with 136 additions and 24 deletions

View File

@@ -1991,6 +1991,7 @@ class VmixAgentHub:
tournament_external_id: str = "",
device_id: str = "",
operator_session_token: str = "",
wait_for_mapping: bool = True,
) -> dict[str, Any] | None:
"""Assign a match to exactly one Agent. Never broadcast to all active Agents."""
game_external_id = str(game_external_id or "").strip()
@@ -2094,7 +2095,17 @@ class VmixAgentHub:
)
payload["delivered"] = delivered
if delivered:
payload["mapping_apply"] = await self.apply_mapping_to_device(resolved_device_id, reason="match_assigned")
if wait_for_mapping:
payload["mapping_apply"] = await self.apply_mapping_to_device(
resolved_device_id, reason="match_assigned"
)
else:
payload["mapping_apply"] = {
"ok": True,
"deferred": True,
"reason": "match_assigned",
"device_id": resolved_device_id,
}
return payload
async def select_device_for_session(

View File

@@ -5,7 +5,7 @@ from datetime import date
from time import perf_counter
from typing import Any, Callable
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import text
@@ -1149,6 +1149,7 @@ def create_hockey_router(
@router.post("/sessions")
async def open_session(
payload: SessionPayload,
background_tasks: BackgroundTasks,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
try:
@@ -1169,7 +1170,18 @@ def create_hockey_router(
tournament_external_id=str(result.get("tournament_external_id") or ""),
device_id=vmix_device_id,
operator_session_token=str(result.get("token") or ""),
# BUILD109: selecting a match must not wait for a full
# Mapping push to vMix. The Agent receives match.assign
# immediately; Mapping starts only after the HTTP response.
wait_for_mapping=False,
)
assignment = result.get("agent_assignment") or {}
if assignment.get("delivered") and assignment.get("device_id"):
background_tasks.add_task(
agent_hub.apply_mapping_to_device,
str(assignment.get("device_id")),
reason="match_assigned",
)
except Exception as agent_error:
# The match/session must remain usable even when a browser
# carries a stale Agent id from another WFL account.

View File

@@ -368,7 +368,9 @@
return payload;
} catch (error) {
if (error?.name === "AbortError") {
throw new Error("Превышено время ожидания ответа Stat2TV");
// BUILD109: this helper is used for local DB/session/Agent endpoints too.
// Calling every HTTP timeout a Stat2TV timeout hides the real subsystem.
throw new Error("Превышено время ожидания ответа сервера");
}
throw error;
} finally {
@@ -1529,27 +1531,20 @@ function gameCard(game) {
);
state.selectedGame = discovered.game;
state.selectedMatchDetails = discovered.details || null;
} else if (scheduleGame) {
// BUILD109: the card the operator clicked is already a complete local
// schedule snapshot. Opening the workspace must be immediate; cached
// details/full match data are optional enrichment and are loaded below.
state.selectedGame = scheduleGame;
state.selectedMatchDetails = null;
} else {
try {
const cached = await request(
`/api/hockey/games/${encodeURIComponent(id)}/details?language=${encodeURIComponent(state.language)}`,
{ timeoutMs: 5000 }
);
state.selectedGame = cached.game;
state.selectedMatchDetails = cached.details || null;
} catch (_) {
try {
state.selectedGame = await request(
`/api/hockey/games/${encodeURIComponent(id)}?language=${encodeURIComponent(state.language)}`,
{ timeoutMs: 5000 }
);
state.selectedMatchDetails = null;
} catch (baseError) {
if (!scheduleGame) throw baseError;
state.selectedGame = scheduleGame;
state.selectedMatchDetails = null;
}
}
// Defensive fallback for a stale DOM/list state. This path is local DB
// only and is not used for the normal visible match-card click.
state.selectedGame = await request(
`/api/hockey/games/${encodeURIComponent(id)}?language=${encodeURIComponent(state.language)}`,
{ timeoutMs: 5000 }
);
state.selectedMatchDetails = null;
}
const actualTournamentId = String(