Files
hockey_new/hockey_data/broadcast_readiness.py

266 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Passive, scoped broadcast checks. Viewing never executes a source or sends a command."""
from __future__ import annotations
import json
import re
from datetime import datetime, timezone
from typing import Any
from fastapi import HTTPException
from sqlalchemy import select
from .async_work import run_db
from .models import VmixAssignment, VmixDevice, VmixMappingField, VmixMappingProfile
HEARTBEAT_MAX_AGE_SECONDS = 35
MAX_MAPPING_RESULTS = 256
_UNSPECIFIED = object()
NOTICE = "Показана последняя обработка источников/Mapping. Актуальность данных внешних источников этой проверкой не подтверждается."
LIMITATIONS = {"source_sql_executed": False, "upstream_freshness_checked": False}
_LABELS = {
"selection": "Выбранный матч и Agent", "assignment": "Назначение Agent",
"agent": "Связь с Agent", "vmix": "Подключение vMix", "inventory": "Схема текущего vMix",
"publication": "Опубликованный Mapping", "targets": "Поля Mapping в vMix",
"delivery": "Последняя обработка источников/Mapping", "pending": "Ожидающие команды",
}
def _utc_naive(value: datetime) -> datetime:
return value.astimezone(timezone.utc).replace(tzinfo=None) if value.tzinfo is not None else value
def _iso(value: datetime) -> str:
return _utc_naive(value).replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
def _results(hub: Any) -> dict[str, dict[str, Any]]:
results = getattr(hub, "_broadcast_mapping_results", None)
if not isinstance(results, dict):
results = {}
hub._broadcast_mapping_results = results
return results
def _number(value: Any) -> int:
try:
return max(0, int(value or 0))
except (TypeError, ValueError, OverflowError):
return 0
def _errors(result: dict[str, Any]) -> int:
count = sum(len(result[key]) for key in ("errors", "missing", "rule_missing", "warnings") if isinstance(result.get(key), list))
return max(count, 1 if result.get("ok") is not True else 0)
def begin_mapping_attempt(hub: Any, device_id: str, generation: int, *, full: bool,
configuration_token: Any = _UNSPECIFIED) -> None:
token = getattr(hub, "_broadcast_configuration_token", None) if configuration_token is _UNSPECIFIED else configuration_token
if full and token is getattr(hub, "_broadcast_configuration_token", None) and generation == getattr(hub, "_mapping_generations", {}).get(device_id, 0):
# An interrupted new full apply cannot leave an older success green.
_results(hub).pop(device_id, None)
def record_mapping_result(hub: Any, device_id: str, generation: int, result: dict[str, Any], *, full: bool,
configuration_token: Any = _UNSPECIFIED) -> None:
token = getattr(hub, "_broadcast_configuration_token", None) if configuration_token is _UNSPECIFIED else configuration_token
if token is not getattr(hub, "_broadcast_configuration_token", None) or generation != getattr(hub, "_mapping_generations", {}).get(device_id, 0):
return
errors = _errors(result)
if not full and not errors:
return # A partial success is never proof for the full profile.
scope = {"match_id": str(result.get("match_id") or ""),
"assignment_id": str(result.get("assignment_id") or ""),
"profile_id": _number(result.get("profile_id")),
"profile_version": _number(result.get("profile_version"))}
if not all(scope.values()):
# Early preparation failures have no trustworthy publication scope.
_results(hub).pop(device_id, None)
return
confirmed = sum(_number(result.get(key)) for key in ("applied", "rules_applied", "unchanged", "rules_unchanged"))
record = {**scope, "generation": generation, "configuration_token": token, "completed_at": _iso(datetime.utcnow()),
"full_profile": full, "outcome": "error" if errors else "ready",
"errors_count": errors, "confirmed": confirmed,
"commands_sent": _number(result.get("mapping_commands_sent"))}
_results(hub).pop(device_id, None)
_results(hub)[device_id] = record
while len(_results(hub)) > MAX_MAPPING_RESULTS:
_results(hub).pop(next(iter(_results(hub))))
def record_mapping_exception(hub: Any, device_id: str, generation: int, *, full: bool,
configuration_token: Any = _UNSPECIFIED) -> None:
token = getattr(hub, "_broadcast_configuration_token", None) if configuration_token is _UNSPECIFIED else configuration_token
if token is not getattr(hub, "_broadcast_configuration_token", None) or generation != getattr(hub, "_mapping_generations", {}).get(device_id, 0):
return
prior = _results(hub).get(device_id)
if not full and prior and prior.get("generation") == generation and prior.get("configuration_token") is token:
# Preserve the known scope, never the exception text or source values.
_results(hub)[device_id] = {**prior, "completed_at": _iso(datetime.utcnow()),
"full_profile": False, "outcome": "error", "errors_count": max(1, _number(prior.get("errors_count"))),
"confirmed": 0, "commands_sent": 0}
else:
_results(hub).pop(device_id, None)
def _check(key: str, status: str, reason: str, message: str, **details: Any) -> dict[str, Any]:
return {"key": key, "status": status, "label": _LABELS[key], "reason": reason,
"message": message, "details": details}
def _response(device_id: str, game_id: str, checks: list[dict[str, Any]], now: datetime) -> dict[str, Any]:
present = {item["key"]: item for item in checks}
selection_required = present.get("selection", {}).get("reason") == "selection_required"
checks = [present.get(key) or _check(key, "unknown",
"selection_required" if selection_required else "state_unavailable",
"Для проверки выберите матч и Agent" if selection_required else "Нет данных для проверки") for key in _LABELS]
summary = {status: sum(item["status"] == status for item in checks) for status in ("ready", "warning", "error", "unknown")}
status = next((status for status in ("error", "unknown", "warning") if summary[status]), "ready")
return {"ok": True, "device_id": device_id, "game_id": game_id, "checked_at": _iso(now),
"status": status, "checks": checks, "summary": summary, "notice": NOTICE,
"limitations": dict(LIMITATIONS)}
def _load_metadata(hub: Any, user_id: str, device_id: str, game_id: str) -> dict[str, Any]:
with hub.database.session() as session:
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id,
VmixDevice.wfl_user_id == user_id, VmixDevice.is_active_for_account.is_(True)))
if device is None:
raise HTTPException(404, detail="Выбранный Agent недоступен")
assignment = session.scalar(select(VmixAssignment).where(VmixAssignment.device_id == device.id,
VmixAssignment.assignment_key == device.current_assignment_key,
VmixAssignment.wfl_user_id == user_id, VmixAssignment.game_external_id == game_id,
VmixAssignment.active.is_(True)))
assigned = bool(assignment and str(device.current_match_external_id or "") == game_id
and str(device.current_assignment_key or ""))
data: dict[str, Any] = {"assigned": assigned, "assignment_id": str(device.current_assignment_key or ""),
"fingerprint": str(device.project_fingerprint or ""), "inventory_json": device.project_inventory_json,
"scanned_at": device.project_scanned_at, "profile_id": _number(device.mapping_profile_id),
"profile_version": _number(device.mapping_profile_version), "profile": None, "fields": []}
# Never invoke a profile resolver here: legacy resolution publishes rows.
if assigned and data["profile_id"]:
profile = session.get(VmixMappingProfile, data["profile_id"])
if profile is not None:
source_id = _number(device.mapping_source_profile_id)
source = session.get(VmixMappingProfile, source_id) if source_id else None
data["profile"] = {"version": _number(profile.version), "source_deleted": bool(source_id and source is None),
"update_available": bool(source and _number(source.version) != _number(profile.version))}
data["fields"] = [{"input": str(field.vmix_input_title or ""), "field": str(field.vmix_field or "").strip()}
for field in session.scalars(select(VmixMappingField).where(
VmixMappingField.profile_id == profile.id, VmixMappingField.enabled.is_(True)))]
return data
def _target_counts(fields: list[dict[str, str]], inputs: list[Any]) -> dict[str, int]:
by_title: dict[str, list[dict[str, Any]]] = {}
for item in inputs:
if isinstance(item, dict):
by_title.setdefault(str(item.get("title") or ""), []).append(item)
counts = {"enabled_links": len(fields), "target_count": 0, "matched_count": 0,
"missing_count": 0, "ambiguous_count": 0, "invalid_count": 0}
targets = {(row["input"], row["field"]) for row in fields}
counts["target_count"] = len(targets)
for title, name in targets:
if not title.strip() or not name or re.fullmatch(r"[+-]?\d+|\{?[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}\}?", title):
counts["invalid_count"] += 1
continue
matches = by_title.get(title, [])
if len(matches) > 1:
counts["ambiguous_count"] += 1
continue
if not matches:
counts["missing_count"] += 1
continue
actual_fields = matches[0].get("fields")
exact = [field for field in actual_fields if isinstance(field, dict) and str(field.get("name") or "").strip() == name] if isinstance(actual_fields, list) else []
counts["matched_count" if len(exact) == 1 else "ambiguous_count" if exact else "missing_count"] += 1
return counts
async def broadcast_readiness(hub: Any, user: Any, *, device_id: str = "", game_id: str = "") -> dict[str, Any]:
"""Observe one explicitly selected owned output; no selection or mutation fallback."""
now = datetime.utcnow()
device_id, game_id = str(device_id or "").strip(), str(game_id or "").strip()
if not device_id or not game_id:
return _response(device_id, game_id, [_check("selection", "unknown", "selection_required", "Выберите матч и Agent в этом браузере")], now)
try:
device_id = hub.normalise_device_id(device_id)
except (HTTPException, ValueError):
raise HTTPException(404, detail="Выбранный Agent недоступен") from None
generation = getattr(hub, "_mapping_generations", {}).get(device_id, 0)
configuration_token = getattr(hub, "_broadcast_configuration_token", None)
connection = hub._live.get(device_id)
try:
metadata = await run_db(_load_metadata, hub, str(user.id), device_id, game_id)
except HTTPException:
raise
except Exception:
# Neither database exceptions nor source/query text reach this response.
return _response(device_id, game_id, [_check(key, "unknown", "state_unavailable",
"Проверка готовности временно недоступна") for key in _LABELS], now)
# Copy event-loop state synchronously; reading must never take transport locks.
now = datetime.utcnow()
scope_changed = (generation != getattr(hub, "_mapping_generations", {}).get(device_id, 0)
or connection is not hub._live.get(device_id)
or configuration_token is not getattr(hub, "_broadcast_configuration_token", None))
live = hub._live.get(device_id)
heartbeat = hub._diagnostic_heartbeat_at.get(device_id)
age = max(0.0, (now - _utc_naive(heartbeat)).total_seconds()) if isinstance(heartbeat, datetime) else None
current_heartbeat = bool(live and isinstance(heartbeat, datetime) and _utc_naive(heartbeat) >= _utc_naive(live.connected_at))
fresh = bool(current_heartbeat and age is not None and age <= HEARTBEAT_MAX_AGE_SECONDS)
state = dict(hub._device_status.get(device_id, {}))
inventory_current = device_id in hub._mapping_inventory_ready and not scope_changed and live is not None
record = dict(getattr(hub, "_broadcast_mapping_results", {}).get(device_id, {}))
pending_count = sum(owner == device_id and not future.done() for owner, future in hub._pending_commands.values())
busy = any(lock is not None and lock.locked() for lock in (
hub._mapping_resolve_locks.get(device_id), hub._mapping_apply_locks.get(device_id)))
checks = [_check("selection", "unknown" if scope_changed else "ready", "state_changed" if scope_changed else "selected",
"Состояние изменилось во время проверки; повторите её" if scope_changed else "Проверяется выбранный матч и Agent")]
assigned = metadata["assigned"]
checks.append(_check("assignment", "ready" if assigned else "error", "assigned" if assigned else "assignment_mismatch",
"Agent назначен выбранному матчу" if assigned else "У Agent нет действующего назначения на выбранный матч"))
agent_status = "error" if live is None or (current_heartbeat and not fresh) else "ready" if fresh else "unknown"
checks.append(_check("agent", agent_status, "online" if fresh else "offline" if live is None else "heartbeat_stale" if current_heartbeat else "heartbeat_pending",
"Agent на связи" if fresh else "Agent отключён" if live is None else "Ждём актуальное подтверждение связи от Agent",
heartbeat_age_seconds=round(age, 1) if current_heartbeat and age is not None else None))
connected = state.get("vmix_connected")
vmix_status = "ready" if connected is True and fresh else "error" if connected is False and live is not None else "unknown"
checks.append(_check("vmix", vmix_status, "connected" if vmix_status == "ready" else "disconnected" if vmix_status == "error" else "connection_unknown",
"Agent сообщил о подключении vMix" if vmix_status == "ready" else "Agent сообщил об отключении vMix" if vmix_status == "error" else "Нет актуального состояния подключения vMix"))
try:
inventory = json.loads(metadata["inventory_json"] or "{}")
except (ValueError, TypeError):
inventory = {}
valid_inventory = isinstance(inventory, dict) and isinstance(inventory.get("inputs"), list) and bool(re.fullmatch(r"[0-9a-f]{64}", metadata["fingerprint"]))
inputs = inventory["inputs"] if valid_inventory else []
inventory_status = "ready" if inventory_current and valid_inventory else "unknown"
checks.append(_check("inventory", inventory_status, "current_inventory" if inventory_status == "ready" else "inventory_pending",
"Схема получена в текущем подключении Agent" if inventory_status == "ready" else "Нужна схема vMix из текущего подключения Agent",
current_connection=inventory_current, scanned_at=_iso(metadata["scanned_at"]) if isinstance(metadata["scanned_at"], datetime) else "",
input_count=len(inputs), field_count=sum(len(item.get("fields", [])) for item in inputs if isinstance(item, dict) and isinstance(item.get("fields"), list))))
publication = metadata["profile"]
publication_valid = bool(assigned and publication and metadata["profile_version"] > 0 and publication["version"] == metadata["profile_version"])
publication_status = "unknown" if not assigned else "error" if metadata["profile_id"] and not publication_valid else "unknown" if not publication else "warning" if publication["source_deleted"] or publication["update_available"] else "ready"
checks.append(_check("publication", publication_status, "publication_current" if publication_status == "ready" else "template_changed" if publication_status == "warning" else "publication_missing" if not publication else "publication_mismatch",
"Используется опубликованная версия Mapping" if publication_status == "ready" else "Опубликованная версия сохранена; шаблон изменён или удалён" if publication_status == "warning" else "Нет подтверждённой опубликованной версии Mapping",
version=publication["version"] if publication else None, update_available=bool(publication and publication["update_available"])))
counts = _target_counts(metadata["fields"], inputs) if assigned and publication_valid and inventory_status == "ready" else {}
targets_status = "unknown" if not counts else "error" if not counts["enabled_links"] or counts["matched_count"] != counts["target_count"] else "ready"
checks.append(_check("targets", targets_status, "targets_matched" if targets_status == "ready" else "targets_invalid" if targets_status == "error" else "targets_unknown",
"Все включённые поля Mapping найдены по именам" if targets_status == "ready" else "Есть отсутствующие, неоднозначные или недопустимые цели Mapping" if targets_status == "error" else "Для проверки полей нужны назначение, публикация и схема текущего vMix", **counts))
record_current = bool(record and not scope_changed and assigned and publication_valid and
record.get("generation") == generation and record.get("configuration_token") is configuration_token and
record.get("configuration_token") is getattr(hub, "_broadcast_configuration_token", None) and record.get("match_id") == game_id and
record.get("assignment_id") == metadata["assignment_id"] and record.get("profile_id") == metadata["profile_id"] and
record.get("profile_version") == metadata["profile_version"])
delivery_status = "unknown"
if record_current:
delivery_status = "error" if record.get("outcome") == "error" else "ready" if record.get("full_profile") is True and record.get("outcome") == "ready" else "unknown"
delivery_details = {key: record[key] for key in ("completed_at", "confirmed", "errors_count", "full_profile") if record_current and key in record}
checks.append(_check("delivery", delivery_status, "full_mapping_confirmed" if delivery_status == "ready" else "mapping_incomplete" if delivery_status == "error" else "mapping_not_confirmed",
"Полная обработка Mapping завершена; команды подтверждены Agent или ранее подтверждённым кэшем" if delivery_status == "ready" else "Последняя обработка Mapping содержит ошибки или неполные данные; нужна успешная полная обработка" if delivery_status == "error" else "Нет результата полной обработки для текущего назначения и конфигурации Mapping/источников", **delivery_details))
checks.append(_check("pending", "warning" if pending_count or busy else "ready", "mapping_busy" if busy else "ack_pending" if pending_count else "idle",
"Обработка Mapping или подтверждение команд ещё продолжается" if pending_count or busy else "Нет команд, ожидающих подтверждения Agent",
count=pending_count, mapping_busy=busy))
return _response(device_id, game_id, checks, now)