# -*- coding: utf-8 -*- """播放命令总线:HTTP 写入期望状态,播放页通过 SSE 订阅并执行。""" from __future__ import annotations import queue import threading import time from typing import Any REMOTE_METHODS = frozenset({"playAction", "stopAction", "clearActions"}) def _as_string(v: Any) -> str: return v.strip() if isinstance(v, str) else "" def _as_string_list(v: Any) -> list[str]: if isinstance(v, list): return [_as_string(x) for x in v if _as_string(x)] if isinstance(v, str): return [s for s in v.replace("+", ",").split(",") if s.strip()] return [] class PlaybackHub: def __init__(self) -> None: self._lock = threading.Lock() self._revision = 0 self._state: dict[str, Any] = {"modelId": None, "activeActions": []} self._subscribers: list[queue.Queue] = [] def _snapshot_locked(self) -> dict[str, Any]: return { "modelId": self._state.get("modelId"), "activeActions": list(self._state.get("activeActions") or []), "revision": self._revision, "updatedAt": self._state.get("updatedAt"), } def get_state(self) -> dict[str, Any]: with self._lock: return self._snapshot_locked() def subscriber_count(self) -> int: with self._lock: return len(self._subscribers) def subscribe(self) -> queue.Queue: q: queue.Queue = queue.Queue(maxsize=128) with self._lock: self._subscribers.append(q) return q def unsubscribe(self, q: queue.Queue) -> None: with self._lock: if q in self._subscribers: self._subscribers.remove(q) def _bump_locked(self) -> int: self._revision += 1 self._state["updatedAt"] = time.time() return self._revision def _broadcast_locked(self, message: dict[str, Any]) -> None: dead: list[queue.Queue] = [] for sub in self._subscribers: try: sub.put_nowait(message) except queue.Full: dead.append(sub) for sub in dead: if sub in self._subscribers: self._subscribers.remove(sub) def _set_model(self, model_id: str) -> None: if self._state.get("modelId") != model_id: self._state["activeActions"] = [] self._state["modelId"] = model_id def _require_model(self, params: dict[str, Any]) -> str: model_id = _as_string(params.get("modelId") or params.get("model")) if not model_id: raise ValueError("params.modelId 必填") self._set_model(model_id) return model_id def _apply(self, method: str, params: dict[str, Any]) -> dict[str, Any]: params = params if isinstance(params, dict) else {} if method == "playAction": model_id = self._require_model(params) active = list(self._state.get("activeActions") or []) ids = _as_string_list(params.get("actionIds") or params.get("actions")) if not ids: one = _as_string(params.get("actionId") or params.get("action")) if one: ids = [one] if not ids: raise ValueError("params.actionId 必填") for action_id in ids: if action_id not in active: active.append(action_id) self._state["activeActions"] = active return {"modelId": model_id, "played": ids, "activeActions": active} if method == "stopAction": model_id = self._require_model(params) active = list(self._state.get("activeActions") or []) ids = _as_string_list(params.get("actionIds") or params.get("actions")) if not ids: one = _as_string(params.get("actionId") or params.get("action")) if one: ids = [one] if not ids: raise ValueError("params.actionId 必填") active = [a for a in active if a not in ids] self._state["activeActions"] = active return {"modelId": model_id, "stopped": ids, "activeActions": active} if method == "clearActions": model_id = self._require_model(params) self._state["activeActions"] = [] return {"modelId": model_id, "activeActions": []} raise ValueError(f"不支持的 method: {method}") def _emit_locked(self, method: str, params: dict[str, Any], req_id: Any, source_client_id: str | None) -> dict[str, Any]: data = self._apply(method, params) revision = self._bump_locked() state = self._snapshot_locked() self._broadcast_locked( { "version": 1, "id": req_id, "revision": revision, "method": method, "params": params, "state": state, "sourceClientId": source_client_id, } ) return { "dispatched": True, "subscribers": len(self._subscribers), "revision": revision, "state": state, **data, } def dispatch(self, method: str, params: dict[str, Any] | None, req_id: Any = None) -> dict[str, Any]: if method not in REMOTE_METHODS: raise ValueError(f"method「{method}」不可远程调度") with self._lock: return self._emit_locked(method, params or {}, req_id, None) def report_from_client( self, client_id: str, model_id: str | None, active_actions: list[str] | None, ) -> dict[str, Any]: with self._lock: if model_id: self._state["modelId"] = model_id if active_actions is not None: self._state["activeActions"] = list(active_actions) revision = self._bump_locked() state = self._snapshot_locked() self._broadcast_locked( { "version": 1, "revision": revision, "method": "syncState", "params": {}, "state": state, "sourceClientId": client_id, } ) return {"revision": revision, "state": state, "subscribers": len(self._subscribers)} HUB = PlaybackHub()