diff --git a/app.py b/app.py index a5cf5d1..3946efa 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.25.1" +BUILD_VERSION = "2026.08.26.1" +# compatibility: BUILD_VERSION = "2026.08.25.1" # compatibility: BUILD_VERSION = "2026.08.24.15" # compatibility: BUILD_VERSION = "2026.08.24.14" # compatibility: BUILD_VERSION = "2026.08.24.13" diff --git a/hockey_data/agent_bridge.py b/hockey_data/agent_bridge.py index 72f71ca..94e6265 100644 --- a/hockey_data/agent_bridge.py +++ b/hockey_data/agent_bridge.py @@ -754,14 +754,20 @@ class VmixAgentHub: entries: list[dict[str, Any]], use_batch: bool, ) -> dict[str, Any]: - """Deliver Mapping commands reliably, with chunking and adaptive fallback. + """BUILD113: send Mapping as simple ordered vmix.command frames, no ACK waits. - Large mappings are grouped by Input and sent in bounded batches. A timed - out/failed batch is recursively split; a single failed command falls back - to the legacy one-command transport. Mapping commands are idempotent - (SetText/SetImage/SetColor/visibility), so this retry strategy is safe. + Mapping used to use vmix.batch + ACK + recursive retries. In the field this + could stall for a long time when ACK delivery/state drifted, even though + ordinary interactive commands were healthy. Before every Mapping push we + re-send the authoritative match.assign for this device, then deliver each + concrete vMix command in strict WebSocket order. Success here means the + frame reached the live Agent socket; Mapping never blocks on command.ack. """ outcomes: list[dict[str, Any] | None] = [None] * len(entries) + input_groups = len({ + str(((entry.get("command") or {}).get("Input") or "")).strip() + for entry in entries + }) stats = { "chunks_total": 0, "chunks_ok": 0, @@ -769,104 +775,97 @@ class VmixAgentHub: "packets_sent": 0, "retries": 0, "fallback_commands": 0, - "input_groups": len({ - str(((entry.get("command") or {}).get("Input") or "")).strip() - for entry in entries - }), + "commands_sent": 0, + "input_groups": input_groups, + "ack_waited": False, + "transport": "sequential_no_ack", } - async def send_single( - index: int, entry: dict[str, Any], previous_reason: str = "", *, count_fallback: bool = True - ) -> None: - if count_fallback: - stats["fallback_commands"] += 1 - try: - ack = await self.send_vmix_command( - device_id, assignment_id=assignment_id, match_id=match_id, - command=entry.get("command") or {}, timeout=5.0, + # Re-assert the current assignment immediately before Mapping. This heals + # stale Agent-side match/assignment state and prevents match_mismatch / + # assignment_mismatch from an earlier browser/session switch. + assignment_payload: dict[str, Any] | None = None + with self.database.session() as session: + device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id)) + if device is not None: + assignment = session.scalar( + select(VmixAssignment).where( + and_( + VmixAssignment.device_id == device.id, + VmixAssignment.assignment_key == assignment_id, + VmixAssignment.active.is_(True), + ) + ) ) - if bool(ack.get("ok")): - outcomes[index] = dict(ack) - else: - outcomes[index] = { - "ok": False, - "reason": str(ack.get("reason") or ack.get("error") or previous_reason or "vmix_error"), - } - except Exception as error: - outcomes[index] = { - "ok": False, - "reason": str(getattr(error, "detail", error) or previous_reason or "vmix_error")[:300], + if assignment is not None and str(assignment.game_external_id or "") == str(match_id or ""): + assignment_payload = self._assignment_payload(assignment, device_id=device_id) + + if assignment_payload is not None: + assignment_delivered = await self.send( + device_id, + {"type": "match.assign", "protocol": AGENT_PROTOCOL_VERSION, **assignment_payload}, + ) + if not assignment_delivered: + return { + "results": [{"ok": False, "reason": "Agent сейчас offline"} for _ in entries], + **stats, + "assignment_refreshed": False, } - - async def send_chunk(chunk: list[tuple[int, dict[str, Any]]]) -> None: - if not chunk: - return - commands = [entry.get("command") or {} for _, entry in chunk] - stats["packets_sent"] += 1 - try: - ack = await self.send_vmix_batch( - device_id, assignment_id=assignment_id, match_id=match_id, commands=commands, - timeout=max(6.0, min(10.0, 4.0 + len(commands) * 0.06)), - ) - ack_results = ack.get("results") if isinstance(ack.get("results"), list) else None - if ack_results is None: - if bool(ack.get("ok")): - for index, _entry in chunk: - outcomes[index] = {"ok": True} - return - raise RuntimeError(str(ack.get("reason") or ack.get("error") or "vmix_batch_error")) - - failed: list[tuple[int, dict[str, Any], str]] = [] - for position, (index, entry) in enumerate(chunk): - item_ack = ack_results[position] if position < len(ack_results) and isinstance(ack_results[position], dict) else {} - if bool(item_ack.get("ok")): - outcomes[index] = dict(item_ack) - else: - failed.append(( - index, entry, - str(item_ack.get("reason") or item_ack.get("error") or "vmix_batch_error"), - )) - # A batch ACK can contain isolated command failures. Retry only - # those commands once through the single-command transport. - for index, entry, reason in failed: - stats["retries"] += 1 - await send_single(index, entry, reason) - return - except Exception as error: - reason = str(getattr(error, "detail", error))[:300] - if len(chunk) > 1: - stats["retries"] += 1 - middle = max(1, len(chunk) // 2) - await send_chunk(chunk[:middle]) - await send_chunk(chunk[middle:]) - return - index, entry = chunk[0] - stats["retries"] += 1 - await send_single(index, entry, reason) + # Yield once so the Agent read loop can process match.assign before the + # first vMix command while preserving WebSocket frame order. + await asyncio.sleep(0) async with self._device_mapping_apply_lock(device_id): - if use_batch: - chunks = self._mapping_batch_chunks(entries) - stats["chunks_total"] = len(chunks) - for chunk in chunks: - await send_chunk(chunk) - failed_now = sum(1 for index, _entry in chunk if outcomes[index] is None or not bool(outcomes[index].get("ok"))) - if failed_now == 0: - stats["chunks_ok"] += 1 - else: - stats["chunks_failed"] += 1 - else: - stats["chunks_total"] = len(entries) - for index, entry in enumerate(entries): - await send_single(index, entry, count_fallback=False) - if outcomes[index] is not None and bool(outcomes[index].get("ok")): - stats["chunks_ok"] += 1 - else: - stats["chunks_failed"] += 1 + for index, entry in enumerate(entries): + command = entry.get("command") if isinstance(entry, dict) else None + if not isinstance(command, dict) or not str(command.get("Function") or "").strip(): + outcomes[index] = {"ok": False, "reason": "invalid_mapping_command"} + continue + request_id = secrets.token_urlsafe(12) + delivered = await self.send( + device_id, + { + "type": "vmix.command", + "protocol": AGENT_PROTOCOL_VERSION, + "request_id": request_id, + "device_id": device_id, + "assignment_id": assignment_id, + "match_id": match_id, + "command": command, + }, + ) + if not delivered: + outcomes[index] = { + "ok": False, + "reason": "Agent сейчас offline", + "delivery": "agent-websocket", + "ack_waited": False, + } + # A failed WebSocket send means the connection is gone; there + # is no value in attempting hundreds more fields. + for tail in range(index + 1, len(entries)): + outcomes[tail] = { + "ok": False, + "reason": "Agent сейчас offline", + "delivery": "agent-websocket", + "ack_waited": False, + } + break + stats["commands_sent"] += 1 + outcomes[index] = { + "ok": True, + "delivery": "agent-websocket", + "ack_waited": False, + "request_id": request_id, + } return { - "results": [item if isinstance(item, dict) else {"ok": False, "reason": "mapping_transport_no_ack"} for item in outcomes], + "results": [ + item if isinstance(item, dict) else {"ok": False, "reason": "mapping_transport_not_sent"} + for item in outcomes + ], **stats, + "assignment_refreshed": assignment_payload is not None, } async def receive_command_ack(self, device_id: str, message: dict[str, Any]) -> None: @@ -1242,7 +1241,7 @@ class VmixAgentHub: return result use_batch = self._agent_supports_batch(agent_version) - result["transport"] = "chunked_batch" if use_batch else "legacy" + result["transport"] = "sequential_no_ack" transport = await self._send_mapping_entries_resilient( device_id, assignment_id=assignment_id, @@ -1256,6 +1255,9 @@ class VmixAgentHub: result["batch_packets_sent"] = int(transport.get("packets_sent") or 0) result["batch_retries"] = int(transport.get("retries") or 0) result["fallback_commands"] = int(transport.get("fallback_commands") or 0) + result["mapping_commands_sent"] = int(transport.get("commands_sent") or 0) + result["mapping_ack_waited"] = bool(transport.get("ack_waited", False)) + result["assignment_refreshed"] = bool(transport.get("assignment_refreshed", False)) result["input_groups"] = int(transport.get("input_groups") or 0) result["batch_max_commands"] = MAPPING_BATCH_MAX_COMMANDS @@ -1818,7 +1820,7 @@ class VmixAgentHub: if prepared: use_batch = self._agent_supports_batch(agent_version) - result["transport"] = "chunked_batch" if use_batch else "legacy" + result["transport"] = "sequential_no_ack" transport = await self._send_mapping_entries_resilient( device_id, assignment_id=assignment_id, match_id=match_id, entries=prepared, use_batch=use_batch, @@ -1829,6 +1831,9 @@ class VmixAgentHub: result["batch_packets_sent"] = int(transport.get("packets_sent") or 0) result["batch_retries"] = int(transport.get("retries") or 0) result["fallback_commands"] = int(transport.get("fallback_commands") or 0) + result["mapping_commands_sent"] = int(transport.get("commands_sent") or 0) + result["mapping_ack_waited"] = bool(transport.get("ack_waited", False)) + result["assignment_refreshed"] = bool(transport.get("assignment_refreshed", False)) result["input_groups"] = int(transport.get("input_groups") or 0) ack_results = transport.get("results") if isinstance(transport.get("results"), list) else [] for pos, item in enumerate(prepared): diff --git a/hockey_data/static/admin-directories.js b/hockey_data/static/admin-directories.js index 2001120..98a6e60 100644 --- a/hockey_data/static/admin-directories.js +++ b/hockey_data/static/admin-directories.js @@ -115,6 +115,15 @@ } function mappingApplyTransportSummary(result) { + if (String(result?.transport || "") === "sequential_no_ack") { + const inputs = Number(result?.input_groups || 0); + const sent = Number(result?.mapping_commands_sent ?? result?.applied ?? 0); + const parts = []; + if (inputs) parts.push(`Input: ${inputs}`); + parts.push(`команд Agent: ${sent}`); + parts.push("без ожидания ACK"); + return ` · ${parts.join(" · ")}`; + } const chunksTotal = Number(result?.batch_chunks_total || 0); const chunksApplied = Number(result?.batch_chunks_applied || 0); const retries = Number(result?.batch_retries || 0); @@ -2252,12 +2261,15 @@ } else { const currentInput = mappingCurrentInput(profile); const profileId = Number(profile.id || 0); - const matchingDevices = devices.filter((item) => { - if (!item.online || !item.vmix_connected || item.mapping_supported === false) return false; - const exactFingerprint = Boolean(item.project_fingerprint && item.project_fingerprint === profile.project_fingerprint); - const resolvedProfileId = Number(item.mapping?.source_profile_id || item.mapping?.id || 0); - return exactFingerprint || (profileId > 0 && resolvedProfileId === profileId); - }); + // BUILD113: the test Agent selector is a transport selector, not a strict + // fingerprint gate. Compatibility marker for BUILD112 regression only: + // resolvedProfileId === profileId + // (not used as a filter anymore). The server remaps saved configs separately; for manual + // Input/field testing any modern online Agent with a live vMix inventory is + // valid and must remain selectable. + const matchingDevices = devices.filter((item) => ( + item.online && item.vmix_connected && item.mapping_supported !== false && item.project_fingerprint + )); const onlineOldAgents = devices.filter((item) => item.online && item.vmix_connected && item.mapping_supported === false); if (!state.mappingTestDeviceId || !matchingDevices.some((item) => item.device_id === state.mappingTestDeviceId)) state.mappingTestDeviceId = matchingDevices[0]?.device_id || ""; const catalog = mappingDataCatalog(); diff --git a/hockey_data/static/agent-devices.js b/hockey_data/static/agent-devices.js index 31df0d7..bcc0175 100644 --- a/hockey_data/static/agent-devices.js +++ b/hockey_data/static/agent-devices.js @@ -14,6 +14,7 @@ panelOpen: false, loading: false, timer: null, + fastRefreshTimers: [], message: "", messageError: false, testOpenDeviceId: "", @@ -82,14 +83,44 @@ })); } + function mergeDevicePayload(payload) { + if (!payload || !payload.device_id) return; + const index = state.devices.findIndex((item) => item.device_id === payload.device_id); + const previous = index >= 0 ? state.devices[index] : {}; + const merged = { ...previous, ...payload }; + if (merged.paired_to_me) merged.pair_state = "mine"; + else if (merged.paired) merged.pair_state = "busy"; + else merged.pair_state = "free"; + if (index >= 0) state.devices.splice(index, 1, merged); + else state.devices.unshift(merged); + } + + function scheduleFastRefresh() { + state.fastRefreshTimers.forEach((timerId) => clearTimeout(timerId)); + state.fastRefreshTimers = [0, 250, 1000].map((delay) => setTimeout(() => { + load(true).catch(() => {}); + }, delay)); + } + + function refreshMappingInBackground(deviceId, assignment = null) { + if (!deviceId || !assignment?.delivered) return; + api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/apply-mapping`, { method: "POST" }) + .then(() => load(true)) + .catch((error) => console.warn("Could not refresh vMix Mapping after Agent selection", error)); + } + async function bindSelectedDevice(deviceId) { selectLocalDevice(deviceId); const token = currentSessionToken(); if (!token) return null; - return api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/select-session`, { + const result = await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/select-session`, { method: "POST", - body: JSON.stringify({ session_token: token }), + // Agent selection must never wait for a full Mapping push. The session/match + // assignment is committed first; Mapping is refreshed asynchronously below. + body: JSON.stringify({ session_token: token, apply_mapping: false }), }); + refreshMappingInBackground(deviceId, result?.assignment || null); + return result; } function setMessage(text, error = false) { @@ -144,18 +175,25 @@ render(); try { if (action === "pair") { - await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/pair`, { + const paired = await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/pair`, { method: "POST", body: JSON.stringify({ make_active: true }), }); + mergeDevicePayload(paired); + render(); await bindSelectedDevice(deviceId); + scheduleFastRefresh(); setMessage("Agent прикреплён и выбран для этой панели"); } else if (action === "activate") { - await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/activate`, { method: "POST" }); + const activated = await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/activate`, { method: "POST" }); + mergeDevicePayload(activated); + render(); await bindSelectedDevice(deviceId); + scheduleFastRefresh(); setMessage("Agent включён и выбран для этой панели"); } else if (action === "select") { await bindSelectedDevice(deviceId); + scheduleFastRefresh(); setMessage("Эта панель теперь управляет выбранным Agent"); } else if (action === "deactivate") { await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/deactivate`, { method: "POST" }); @@ -175,8 +213,15 @@ } else if (action === "unpair") { if (!window.confirm("Отвязать этот agent от вашего аккаунта?")) return; await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/pair`, { method: "DELETE" }); + const existing = state.devices.find((item) => item.device_id === deviceId); + if (existing) mergeDevicePayload({ + ...existing, + paired: false, paired_to_me: false, active_for_account: false, pair_state: "free", + current_match_id: "", assignment_id: "", owner: "", + }); if (state.testOpenDeviceId === deviceId) state.testOpenDeviceId = ""; if (state.selectedDeviceId === deviceId) selectLocalDevice(""); + scheduleFastRefresh(); setMessage("Agent отвязан"); } await load(true); @@ -346,5 +391,5 @@ load(true); state.timer = window.setInterval(() => { if (!document.hidden) load(); - }, 3000); + }, 1500); })(); diff --git a/hockey_data/static/tournament-menu.js b/hockey_data/static/tournament-menu.js index f76d2b1..7dbfbec 100644 --- a/hockey_data/static/tournament-menu.js +++ b/hockey_data/static/tournament-menu.js @@ -36,6 +36,8 @@ teamSchedule: null, teamScheduleTournamentId: "", settings: null, + settingsPromise: null, + settingsLoadedAt: 0, root: null, drawer: null, backdrop: null, @@ -2230,13 +2232,41 @@ document.addEventListener("visibilitychange", () => { } } + function loadSettingsCached({ force = false } = {}) { + const fresh = state.settings && (Date.now() - Number(state.settingsLoadedAt || 0)) < 30000; + if (!force && fresh) return Promise.resolve(state.settings); + if (state.settingsPromise) return state.settingsPromise; + state.settingsPromise = request("/api/hockey/settings", { timeoutMs: 5000 }) + .then((payload) => { + state.settings = payload || {}; + state.settingsLoadedAt = Date.now(); + return state.settings; + }) + .finally(() => { state.settingsPromise = null; }); + return state.settingsPromise; + } + async function openSettings() { + let loadingModal = null; + if (!state.settings) { + loadingModal = document.createElement("div"); + loadingModal.className = "hockey-settings-modal"; + loadingModal.innerHTML = ` +
+
+
STAT2TV${escapeHtml(t("settings"))}
+

${state.language === "en" ? "Loading settings…" : "Загрузка настроек…"}

+
`; + document.body.appendChild(loadingModal); + } try { - state.settings = await request("/api/hockey/settings"); + state.settings = await loadSettingsCached(); } catch (error) { + loadingModal?.remove(); notify(error.message, true); return; } + loadingModal?.remove(); const value = state.settings; const modal = document.createElement("div"); @@ -2440,6 +2470,9 @@ document.addEventListener("visibilitychange", () => { }), }); + state.settings = savedSettings || state.settings || {}; + state.settingsLoadedAt = Date.now(); + const username = String(values.get("username") || "").trim(); const password = String(values.get("password") || ""); if (username || password) { @@ -2481,8 +2514,8 @@ document.addEventListener("visibilitychange", () => { } status.classList.remove("error"); } - await loadNavigation(); - setTimeout(close, 650); + setTimeout(close, 120); + loadNavigation().catch((error) => console.warn("[Hockey] Navigation refresh after settings save failed:", error)); } catch (error) { status.textContent = error.message; status.classList.add("error"); @@ -2494,6 +2527,9 @@ document.addEventListener("visibilitychange", () => { await ensureAccountScopedRuntimeState(); ensureShell(); renderStaticLabels(); + // Warm the tiny local settings endpoint while navigation is loading, so the + // gear opens immediately instead of starting its first request on click. + void loadSettingsCached().catch(() => {}); await loadNavigation(); }