"""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 ]