feat: reduce authoritative player mqtt state

This commit is contained in:
Media
2026-08-06 17:41:07 -05:00
parent 45083207b4
commit 8b3bdae8a0
5 changed files with 1357 additions and 0 deletions
+495
View File
@@ -0,0 +1,495 @@
"""Authoritative Player status/telemetry reduction and operation fencing."""
from __future__ import annotations
import math
import time
import uuid
from dataclasses import dataclass, replace
from typing import Any, Mapping
from .config import ControllerConfig
from .contracts import SLOT_IDS
from .models import PlayerObservation
from .store import ControllerStore
TERMINAL_REASONS = frozenset(
{"stop", "finished", "mpv_error", "sigterm", "sigint", "shutdown"}
)
CONTROL_ACTIONS = frozenset({"play", "stop", "pause", "resume", "toggle"})
@dataclass(frozen=True)
class PlayerOperation:
operation_id: str
generation: int
action: str
slot: int | None
expected_path: str | None
deadline: float
state: str = "awaiting_player"
message: str | None = None
@dataclass(frozen=True)
class PlaybackEffect:
kind: str
slot: int | None = None
operation_id: str | None = None
message: str | None = None
class OperationInProgressError(RuntimeError):
pass
class PlaybackReducer:
"""Apply Player evidence without projecting Controller intent as success."""
def __init__(
self,
config: ControllerConfig,
store: ControllerStore,
*,
monotonic=time.monotonic,
wall_clock=time.time,
operation_timeout_seconds: float = 10.0,
):
self.config = config
self.store = store
self._monotonic = monotonic
self._wall_clock = wall_clock
self.operation_timeout_seconds = operation_timeout_seconds
self._operation_generation = 0
self.pending: PlayerOperation | None = None
self.last_operation: PlayerOperation | None = None
def begin_operation(
self,
action: str,
*,
slot: int | None = None,
expected_path: str | None = None,
) -> PlayerOperation:
if action not in CONTROL_ACTIONS:
raise ValueError(f"unsupported Player action {action!r}")
if isinstance(slot, bool) or (slot is not None and slot not in SLOT_IDS):
raise ValueError("slot must be in 1..10")
if self.pending is not None:
raise OperationInProgressError(
f"Player operation {self.pending.operation_id} is still pending"
)
self._operation_generation += 1
operation = PlayerOperation(
operation_id=uuid.uuid4().hex,
generation=self._operation_generation,
action=action,
slot=slot,
expected_path=expected_path,
deadline=self._monotonic() + self.operation_timeout_seconds,
)
self.pending = operation
if action == "play" and slot is not None:
self.store.protect_slot(
slot,
"pending_play",
guard_id=f"operation-{operation.operation_id}",
details={"operation_id": operation.operation_id},
)
return operation
def publish_failed(self, reason: str) -> PlaybackEffect | None:
if self.pending is None:
return None
return self._finish_operation("failed", reason)
def _finish_operation(self, state: str, message: str | None = None) -> PlaybackEffect:
assert self.pending is not None
operation = replace(self.pending, state=state, message=message)
self.pending = None
self.last_operation = operation
if operation.action == "play" and operation.slot is not None and state != "confirmed":
self.store.clear_playback_guards(
slot=operation.slot,
kind="pending_play",
reason=f"play operation {state}: {message or ''}".strip(),
)
return PlaybackEffect(
kind=f"operation_{state}",
slot=operation.slot,
operation_id=operation.operation_id,
message=message,
)
@staticmethod
def _slot(value: Any) -> int | None:
if value is None or isinstance(value, bool):
return None
try:
converted = int(value)
except (TypeError, ValueError, OverflowError):
return None
if isinstance(value, float) and (not math.isfinite(value) or not value.is_integer()):
return None
return converted if converted in SLOT_IDS else None
@staticmethod
def _path(value: Any) -> str | None:
return value if isinstance(value, str) and value else None
def _matches_pending(self, slot: int | None, path: str | None) -> bool:
operation = self.pending
if operation is None:
return False
slot_matches = operation.slot is None or operation.slot == slot
path_matches = operation.expected_path is None or operation.expected_path == path
if operation.slot is not None and operation.expected_path is not None:
return slot_matches or path_matches
return slot_matches and path_matches
def _active_evidence(
self,
*,
slot: int | None,
path: str | None,
paused: bool | None,
status: Mapping[str, Any] | None = None,
telemetry: Mapping[str, Any] | None = None,
) -> PlayerObservation:
previous = self.store.player_observation()
if slot is None:
slot = previous.active_slot
if path is None:
path = previous.active_path
if paused is None and previous.active_slot == slot:
paused = previous.paused
phase = "paused" if paused is True else "playing"
if slot is not None:
self.store.reconcile_active_guard(
slot,
kind="player_active",
details={"path": path},
)
return self.store.set_player_observation(
availability="online",
phase=phase,
active_slot=slot,
active_path=path,
paused=paused,
reconciliation_state="confirmed",
reconciliation_due_at=None,
status_payload=status,
telemetry_payload=telemetry,
)
def _idle_evidence(
self,
*,
status: Mapping[str, Any] | None = None,
telemetry: Mapping[str, Any] | None = None,
) -> PlayerObservation:
# Persist idle before removing protection. A crash between the two
# steps can leave harmless over-protection, never unsafe under-protection.
observation = self.store.set_player_observation(
availability="online",
phase="idle",
active_slot=None,
active_path=None,
paused=False,
reconciliation_state="clear",
reconciliation_due_at=None,
status_payload=status,
telemetry_payload=telemetry,
)
self.store.clear_playback_guards(reason="fresh Player idle evidence")
return observation
def handle_status(self, payload: Any) -> list[PlaybackEffect]:
if not isinstance(payload, Mapping):
return [PlaybackEffect("warning", message="Player status must be an object")]
state = payload.get("state")
if not isinstance(state, str) or not state:
return [PlaybackEffect("warning", message="Player status is missing state")]
state = state.strip()
slot = self._slot(payload.get("slot"))
path = self._path(payload.get("path"))
effects: list[PlaybackEffect] = []
if state == "offline":
previous = self.store.player_observation()
due = None
if (
previous.active_slot is not None
and self.config.storage.uncertain_review_after_seconds > 0
):
due = self._wall_clock() + self.config.storage.uncertain_review_after_seconds
self.store.reconcile_active_guard(
previous.active_slot,
kind="restart_uncertain",
review_after=due,
details={"reason": "Player offline"},
)
self.store.set_player_observation(
availability="offline",
phase="unknown" if previous.active_slot is not None else previous.phase,
active_slot=previous.active_slot,
active_path=previous.active_path,
paused=previous.paused,
reconciliation_state=(
"awaiting_evidence" if previous.active_slot is not None else "unknown"
),
reconciliation_due_at=due,
status_payload=payload,
)
effects.append(
PlaybackEffect(
"player_offline",
slot=previous.active_slot,
message="Player availability is offline; playback is unknown",
)
)
return effects
if state == "idle" and payload.get("message") == "player online":
if path is not None:
self._active_evidence(slot=slot, path=path, paused=None, status=payload)
effects.append(
PlaybackEffect(
"player_reconnected_active",
slot=slot,
message="Reconnect idle announcement carried an active path",
)
)
else:
self._idle_evidence(status=payload)
effects.append(PlaybackEffect("player_idle"))
if self.pending is not None and self.pending.action == "stop":
effects.append(self._finish_operation("confirmed"))
return effects
if state == "idle":
reason = payload.get("reason")
self._idle_evidence(status=payload)
effects.append(PlaybackEffect("player_idle", slot=slot, message=str(reason or "idle")))
if self.pending is not None:
if self.pending.action == "stop":
effects.append(self._finish_operation("confirmed", str(reason or "idle")))
elif self.pending.action == "play":
effects.append(self._finish_operation("failed", str(reason or "Player remained idle")))
return effects
if state == "playing":
previous = self.store.player_observation()
actual_paused = previous.paused if previous.active_slot == slot else None
self._active_evidence(
slot=slot,
path=path,
paused=actual_paused,
status=payload,
)
effects.append(PlaybackEffect("player_active", slot=slot))
if (
self.pending is not None
and self.pending.action == "play"
and self._matches_pending(slot, path)
):
effects.append(self._finish_operation("confirmed"))
return effects
if state in {"paused", "toggled"}:
previous = self.store.player_observation()
self._active_evidence(
slot=slot,
path=path,
paused=previous.paused if previous.active_slot == slot else None,
status=payload,
)
effects.append(
PlaybackEffect(
"control_issued",
slot=slot,
message=f"Player issued {state}; awaiting telemetry",
)
)
return effects
if state == "busy":
if slot is not None or path is not None:
previous = self.store.player_observation()
self._active_evidence(
slot=slot,
path=path,
paused=previous.paused if previous.active_slot == slot else None,
status=payload,
)
effects.append(PlaybackEffect("player_busy", slot=slot, message=payload.get("message")))
if self.pending is not None and self.pending.action == "play":
effects.append(self._finish_operation("failed", "Player is busy"))
return effects
if state == "error":
previous = self.store.player_observation()
active_slot = slot if slot is not None else previous.active_slot
active_path = path if path is not None else previous.active_path
if active_slot is not None:
self.store.reconcile_active_guard(
active_slot,
kind="player_active",
details={"error": payload.get("message")},
)
self.store.set_player_observation(
availability="online",
phase="error",
active_slot=active_slot,
active_path=active_path,
paused=previous.paused,
reconciliation_state=("confirmed" if active_slot is not None else "unknown"),
reconciliation_due_at=None,
status_payload=payload,
)
message = str(payload.get("message") or "Player error")
effects.append(PlaybackEffect("player_error", slot=active_slot, message=message))
if self.pending is not None:
effects.append(self._finish_operation("failed", message))
return effects
effects.append(PlaybackEffect("warning", slot=slot, message=f"unknown Player state {state!r}"))
return effects
def handle_telemetry(self, payload: Any) -> list[PlaybackEffect]:
if not isinstance(payload, Mapping) or payload.get("state") != "telemetry":
return [PlaybackEffect("warning", message="invalid Player telemetry envelope")]
slot = self._slot(payload.get("slot"))
path = self._path(payload.get("path"))
effects: list[PlaybackEffect] = []
if path is None:
self._idle_evidence(telemetry=payload)
effects.append(PlaybackEffect("player_idle", slot=slot, message="cleared telemetry"))
if self.pending is not None:
if self.pending.action == "stop":
effects.append(self._finish_operation("confirmed", "cleared telemetry"))
elif self.pending.action == "play":
effects.append(self._finish_operation("failed", "playback cleared"))
return effects
paused_value = payload.get("paused")
paused = paused_value if isinstance(paused_value, bool) else None
observation = self._active_evidence(
slot=slot,
path=path,
paused=paused,
telemetry=payload,
)
effects.append(PlaybackEffect("player_telemetry", slot=observation.active_slot))
operation = self.pending
if operation is None or not self._matches_pending(slot, path):
return effects
if operation.action == "play":
effects.append(self._finish_operation("confirmed", "matching telemetry"))
elif operation.action == "pause" and paused is True:
effects.append(self._finish_operation("confirmed", "telemetry paused=true"))
elif operation.action == "resume" and paused is False:
effects.append(self._finish_operation("confirmed", "telemetry paused=false"))
elif operation.action == "toggle" and paused is not None:
effects.append(self._finish_operation("confirmed", f"telemetry paused={paused}"))
return effects
def check_timeout(self) -> PlaybackEffect | None:
operation = self.pending
if operation is None or self._monotonic() < operation.deadline:
return None
if operation.action == "play" and operation.slot is not None:
due = None
if self.config.storage.uncertain_review_after_seconds > 0:
due = self._wall_clock() + self.config.storage.uncertain_review_after_seconds
self.store.reconcile_active_guard(
operation.slot,
kind="restart_uncertain",
review_after=due,
details={"operation_id": operation.operation_id, "reason": "play timeout"},
)
self.store.set_player_observation(
availability="unknown",
phase="unknown",
active_slot=operation.slot,
active_path=operation.expected_path,
paused=None,
reconciliation_state="awaiting_evidence",
reconciliation_due_at=due,
)
elif operation.action == "stop":
previous = self.store.player_observation()
if previous.active_slot is not None:
due = None
if self.config.storage.uncertain_review_after_seconds > 0:
due = self._wall_clock() + self.config.storage.uncertain_review_after_seconds
self.store.reconcile_active_guard(
previous.active_slot,
kind="restart_uncertain",
review_after=due,
details={"operation_id": operation.operation_id, "reason": "stop timeout"},
)
self.store.set_player_observation(
availability="unknown",
phase="unknown",
active_slot=previous.active_slot,
active_path=previous.active_path,
paused=previous.paused,
reconciliation_state="awaiting_evidence",
reconciliation_due_at=due,
)
return self._finish_operation("timed_out", "no authoritative Player confirmation")
def projected_slot_state(self, slot: int) -> str:
record = self.store.get_slot(slot)
if record.media_state != "ready":
return record.media_state
observation = self.store.player_observation()
if observation.active_slot == slot:
if observation.reconciliation_state == "operator_review":
return "operator_review"
if observation.phase == "paused" and observation.paused is True:
return "paused"
if observation.phase == "playing":
return "playing"
if observation.phase in {"unknown", "error"}:
return "unknown"
guard = next(
(guard for guard in self.store.list_live_guards() if guard.slot == slot),
None,
)
if guard is not None:
if guard.kind == "pending_play":
return "ready"
return "operator_review" if guard.state == "review_required" else "unknown"
return "ready"
def mark_due_reconciliation(self) -> list[PlaybackEffect]:
due = self.store.mark_due_guards_for_review(self._wall_clock())
if not due:
return []
observation = self.store.player_observation()
if observation.active_slot is not None and any(
guard.slot == observation.active_slot for guard in due
):
self.store.set_player_observation(
availability=observation.availability,
phase="unknown",
active_slot=observation.active_slot,
active_path=observation.active_path,
paused=observation.paused,
reconciliation_state="operator_review",
reconciliation_due_at=observation.reconciliation_due_at,
)
return [
PlaybackEffect(
"operator_review",
slot=guard.slot,
message=(
"Playback remains unknown and protected; fresh Player evidence "
"or an administrative clear is required"
),
)
for guard in due
]