diff --git a/agent/agent_config.json b/agent/agent_config.json index 02af949..1b1e117 100644 --- a/agent/agent_config.json +++ b/agent/agent_config.json @@ -1,5 +1,6 @@ { "server_url": "http://127.0.0.1:8000", + "fallback_server_url": "https://khl.tvstart.ru", "vmix_url": "http://127.0.0.1:8088/api/", "device_id": "69f57eb1-805e-4d9c-a98e-970f266fdb43", "device_secret": "YR_o_sqDKkiTKm8AhPzdqpufgQMcwjSVHRBG0d8o1hFGgCwDvZv1gA", diff --git a/app.py b/app.py index 95c084b..4517def 100644 --- a/app.py +++ b/app.py @@ -29,7 +29,8 @@ 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" +BUILD_VERSION = "2026.08.19.23" +# compatibility: 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/") diff --git a/hockey_data/router.py b/hockey_data/router.py index d3d66f5..72d13d6 100644 --- a/hockey_data/router.py +++ b/hockey_data/router.py @@ -1006,13 +1006,19 @@ def create_hockey_router( **session_data, ) if agent_hub is not None and result.get("game_external_id"): - result["agent_assignment"] = await agent_hub.assign_match( - wfl_user_id=user.id, - game_external_id=str(result.get("game_external_id") or ""), - tournament_external_id=str(result.get("tournament_external_id") or ""), - device_id=vmix_device_id, - operator_session_token=str(result.get("token") or ""), - ) + try: + result["agent_assignment"] = await agent_hub.assign_match( + wfl_user_id=user.id, + game_external_id=str(result.get("game_external_id") or ""), + tournament_external_id=str(result.get("tournament_external_id") or ""), + device_id=vmix_device_id, + operator_session_token=str(result.get("token") or ""), + ) + except Exception as agent_error: + # The match/session must remain usable even when a browser + # carries a stale Agent id from another WFL account. + result["agent_assignment"] = None + result["agent_warning"] = str(agent_error)[:300] return result except ValueError as error: raise HTTPException(status_code=409, detail=str(error)) from error diff --git a/hockey_data/static/tournament-menu.js b/hockey_data/static/tournament-menu.js index b87d273..4f887d8 100644 --- a/hockey_data/static/tournament-menu.js +++ b/hockey_data/static/tournament-menu.js @@ -9,6 +9,7 @@ gameDate: "hockey.gameDate", session: "hockey.operatorSessionToken", vmixDevice: "hockey.vmix.selected_device", + account: "hockey.accountId", }; const state = { @@ -280,6 +281,32 @@ return String(window.HockeyAgentRuntime?.currentDeviceId?.() || localStorage.getItem(storage.vmixDevice) || "").trim(); } + async function ensureAccountScopedRuntimeState() { + let user = null; + try { + user = await request("/api/hockey/me"); + } catch (_) { + return; + } + const currentAccountId = String(user?.id || "").trim(); + if (!currentAccountId) return; + const previousAccountId = String(localStorage.getItem(storage.account) || "").trim(); + if (previousAccountId && previousAccountId !== currentAccountId) { + // Session tokens and Agent selection belong to one WFL account only. + // Keeping them across logout/login can bind a new operator to the + // previous account's match or vMix device. + localStorage.removeItem(storage.session); + localStorage.removeItem(storage.game); + localStorage.removeItem(storage.vmixDevice); + state.selectedGameId = ""; + state.selectedGame = null; + state.selectedMatchDetails = null; + stopSelectedGamePolling(); + try { window.HockeyAgentRuntime?.selectDevice?.(""); } catch (_) {} + } + localStorage.setItem(storage.account, currentAccountId); + } + async function request(path, options = {}) { const { timeoutMs = 15000, ...fetchOptions } = options; const controller = new AbortController(); @@ -1442,22 +1469,42 @@ function gameCard(game) { } } - const session = await request("/api/hockey/sessions", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - tournament_external_id: state.selectedId, - game_external_id: id, - display_language: state.language, - vmix_language: state.language, - vmix_device_id: currentAgentDeviceId(), - replace_existing: true, - }), - }); + let session = null; + const selectedDeviceId = currentAgentDeviceId(); + const sessionPayload = { + tournament_external_id: state.selectedId, + game_external_id: id, + display_language: state.language, + vmix_language: state.language, + vmix_device_id: selectedDeviceId, + replace_existing: true, + }; + try { + session = await request("/api/hockey/sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(sessionPayload), + }); + } catch (sessionError) { + // A device stored in localStorage may belong to the previous account. + // Retry the operator session without Agent instead of blocking the match. + if (selectedDeviceId) { + localStorage.removeItem(storage.vmixDevice); + try { window.HockeyAgentRuntime?.selectDevice?.(""); } catch (_) {} + session = await request("/api/hockey/sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...sessionPayload, vmix_device_id: "" }), + }); + } else { + throw sessionError; + } + } state.selectedGameId = String(id); localStorage.setItem(storage.game, state.selectedGameId); - localStorage.setItem(storage.session, session.token); + if (session?.token) localStorage.setItem(storage.session, session.token); + else localStorage.removeItem(storage.session); const selectedGameDate = String(state.selectedGame?.date || "").trim(); const scheduleDate = String(state.games?.meta?.date || state.games?.selected_date || "").trim(); @@ -2018,6 +2065,7 @@ document.addEventListener("visibilitychange", () => { async function init() { ensureShell(); renderStaticLabels(); + await ensureAccountScopedRuntimeState(); await loadNavigation(); } diff --git a/tests/test_build65_nonadmin_match_selection.py b/tests/test_build65_nonadmin_match_selection.py new file mode 100644 index 0000000..7dad1f1 --- /dev/null +++ b/tests/test_build65_nonadmin_match_selection.py @@ -0,0 +1,25 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +JS = (ROOT / "hockey_data" / "static" / "tournament-menu.js").read_text(encoding="utf-8") +ROUTER = (ROOT / "hockey_data" / "router.py").read_text(encoding="utf-8") + + +def test_account_switch_clears_account_bound_runtime_state(): + assert 'account: "hockey.accountId"' in JS + assert 'previousAccountId !== currentAccountId' in JS + assert 'localStorage.removeItem(storage.session)' in JS + assert 'localStorage.removeItem(storage.game)' in JS + assert 'localStorage.removeItem(storage.vmixDevice)' in JS + assert 'await ensureAccountScopedRuntimeState();' in JS + + +def test_match_session_retries_without_stale_agent(): + assert 'const selectedDeviceId = currentAgentDeviceId();' in JS + assert 'Retry the operator session without Agent' in JS + assert 'JSON.stringify({ ...sessionPayload, vmix_device_id: "" })' in JS + + +def test_agent_assignment_cannot_break_operator_session_creation(): + assert 'result["agent_warning"] = str(agent_error)[:300]' in ROUTER + assert 'result["agent_assignment"] = None' in ROUTER