"""Authoritative Player status/telemetry reduction and operation fencing.""" from __future__ import annotations import math import os 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"} ) TERMINAL_ERROR_REASONS = frozenset({"startup_error", "worker_start_error"}) 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 reconciliation_id: str | None = None 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, operation_id: 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=operation_id or uuid.uuid4().hex, generation=self._operation_generation, action=action, slot=slot, expected_path=expected_path, deadline=self._monotonic() + self.operation_timeout_seconds, ) 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}, ) elif action == "play" and expected_path 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 ) observation = self.store.mark_external_uncertain( expected_path, review_after=due, details={ "operation_id": operation.operation_id, "reason": "play command pending Player evidence", }, ) operation = replace( operation, reconciliation_id=observation.reconciliation_id, ) self.pending = operation return operation def publish_failed(self, reason: str) -> PlaybackEffect | None: if self.pending is None: return None if ( self.pending.action == "play" and self.pending.slot is None and self.pending.reconciliation_id is not None ): self.store.cancel_external_intent( self.pending.reconciliation_id, reason=reason, ) 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 positive_match = False if operation.slot is not None and slot is not None: if operation.slot != slot: return False positive_match = True if operation.expected_path is not None and path is not None: if os.path.abspath(operation.expected_path) != os.path.abspath(path): return False positive_match = True return positive_match def _canonical_slot_for_path(self, path: str | None) -> int | None: if path is None: return None candidate = os.path.abspath(path) for slot in SLOT_IDS: slot_directory = os.path.abspath( self.config.storage.media_base / f"slot{slot}" ) try: if os.path.commonpath((candidate, slot_directory)) == slot_directory: return slot except ValueError: continue return None def _normalized_identity( self, payload: Mapping[str, Any], ) -> tuple[int | None, str | None, str | None]: slot = self._slot(payload.get("slot")) path = self._path(payload.get("path")) derived = self._canonical_slot_for_path(path) if slot is not None and derived is not None and slot != derived: return ( slot, path, f"Player identity conflicts: slot {slot} does not contain path {path!r}", ) return (derived if slot is None else slot), path, None @staticmethod def _same_playback_identity( previous: PlayerObservation, slot: int | None, path: str | None, ) -> bool: if previous.active_slot != slot: return False if slot is None: return ( previous.active_path is not None and path is not None and os.path.abspath(previous.active_path) == os.path.abspath(path) ) if previous.active_path is None or path is None: return True return os.path.abspath(previous.active_path) == os.path.abspath(path) 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 and (path is None or path == previous.active_path): slot = previous.active_slot if path is None and (slot is None or slot == previous.active_slot): path = previous.active_path if paused is None and self._same_playback_identity(previous, slot, path): 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}, ) elif path is not None: self.store.reconcile_external_active() 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, reconciliation_id=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, reconciliation_id=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, path, identity_warning = self._normalized_identity(payload) if identity_warning is not None: return [PlaybackEffect("warning", slot=slot, message=identity_warning)] effects: list[PlaybackEffect] = [] if state == "offline": previous = self.store.player_observation() due = None active_known = ( previous.active_slot is not None or previous.active_path is not None ) if previous.active_slot is not None: if self.config.storage.uncertain_review_after_seconds > 0: due = self._wall_clock() + self.config.storage.uncertain_review_after_seconds guard = self.store.mark_slot_uncertain( previous.active_slot, review_after=due, details={"reason": "Player offline"}, ) due = guard.review_after self.store.set_player_observation( availability="offline", phase="unknown", active_slot=previous.active_slot, active_path=previous.active_path, paused=previous.paused, reconciliation_state=( "operator_review" if guard.state == "review_required" else "awaiting_evidence" ), reconciliation_due_at=due, reconciliation_id=guard.guard_id, status_payload=payload, ) elif previous.active_path is not None: if self.config.storage.uncertain_review_after_seconds > 0: due = self._wall_clock() + self.config.storage.uncertain_review_after_seconds self.store.mark_external_uncertain( previous.active_path, review_after=due, availability="offline", details={"reason": "Player offline"}, status_payload=payload, ) else: self.store.set_player_observation( availability="offline", phase=previous.phase, active_slot=None, active_path=None, paused=previous.paused, reconciliation_state="unknown", reconciliation_due_at=None, reconciliation_id=None, 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", ) ) if self.pending is not None and self.pending.action == "play": if self._matches_pending(slot, path): effects.append( self._finish_operation( "confirmed", "matching reconnect evidence" ) ) else: effects.append( self._finish_operation( "failed", "Player reconnected with different active playback", ) ) else: self._idle_evidence(status=payload) effects.append(PlaybackEffect("player_idle")) if self.pending is not None: if self.pending.action == "stop": effects.append(self._finish_operation("confirmed")) elif self.pending.action == "play": effects.append( self._finish_operation( "failed", "Player reconnected without active playback" ) ) return effects if state == "idle": reason = payload.get("reason") if reason not in TERMINAL_REASONS: previous = self.store.player_observation() self.store.set_player_observation( availability="online", phase=previous.phase, active_slot=previous.active_slot, active_path=previous.active_path, paused=previous.paused, reconciliation_state=previous.reconciliation_state, reconciliation_due_at=previous.reconciliation_due_at, reconciliation_id=previous.reconciliation_id, status_payload=payload, ) return [ PlaybackEffect( "warning", slot=previous.active_slot, message="ambiguous Player idle status was not treated as terminal", ) ] 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": self._active_evidence( slot=slot, path=path, paused=None, status=payload, ) effects.append(PlaybackEffect("player_active", slot=slot)) if ( self.pending is not None and self.pending.action == "play" ): if self._matches_pending(slot, path): effects.append(self._finish_operation("confirmed")) else: effects.append( self._finish_operation( "failed", "Player became active with a different slot or path", ) ) return effects if state in {"paused", "toggled"}: self._active_evidence( slot=slot, path=path, paused=None, status=payload, ) effects.append( PlaybackEffect( "control_issued", slot=slot, message=f"Player issued {state}; awaiting telemetry", ) ) if self.pending is not None and self.pending.action == "play": if self._matches_pending(slot, path): effects.append(self._finish_operation("confirmed", state)) else: effects.append( self._finish_operation( "failed", "Player control status identified different playback", ) ) return effects if state == "busy": previous = self.store.player_observation() if ( slot is not None or path is not None or previous.active_slot is not None or previous.active_path is not None ): self._active_evidence( slot=slot, path=path, paused=None, status=payload, ) else: self.store.set_player_observation( availability="online", phase="unknown", active_slot=None, active_path=None, paused=None, reconciliation_state="unknown", reconciliation_due_at=None, reconciliation_id=None, status_payload=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() message = str(payload.get("message") or "Player error") reason = payload.get("reason") if reason in TERMINAL_ERROR_REASONS: terminal_slot = slot if slot is not None else previous.active_slot self._idle_evidence(status=payload) effects.extend( ( PlaybackEffect( "player_error", slot=terminal_slot, message=message, ), PlaybackEffect( "player_idle", slot=terminal_slot, message=str(reason), ), ) ) if self.pending is not None: effects.append(self._finish_operation("failed", message)) return effects active_slot = slot if slot is not None else previous.active_slot active_path = path if path is not None else previous.active_path due = None if self.config.storage.uncertain_review_after_seconds > 0: due = ( self._wall_clock() + self.config.storage.uncertain_review_after_seconds ) if active_slot is not None: guard = self.store.mark_slot_uncertain( active_slot, review_after=due, details={"reason": "Player error", "message": message}, ) due = guard.review_after self.store.set_player_observation( availability="online", phase="error", active_slot=active_slot, active_path=active_path, paused=( previous.paused if self._same_playback_identity( previous, active_slot, active_path, ) else None ), reconciliation_state=( "operator_review" if guard.state == "review_required" else "awaiting_evidence" ), reconciliation_due_at=due, reconciliation_id=guard.guard_id, status_payload=payload, ) elif active_path is not None: self.store.mark_external_uncertain( active_path, review_after=due, availability="online", details={"reason": "Player error", "message": message}, status_payload=payload, ) else: self.store.set_player_observation( availability="online", phase="unknown", active_slot=None, active_path=None, paused=None, reconciliation_state="unknown", reconciliation_due_at=None, reconciliation_id=None, status_payload=payload, ) 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, path, identity_warning = self._normalized_identity(payload) if identity_warning is not None: return [PlaybackEffect("warning", slot=slot, message=identity_warning)] 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: return effects if not self._matches_pending(slot, path): effects.append( self._finish_operation( "failed", "Player telemetry identified a different slot or 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": previous = self.store.player_observation() has_confirmed_active = ( previous.reconciliation_state == "confirmed" and (previous.active_slot is not None or previous.active_path is not None) ) if has_confirmed_active and self._matches_pending( previous.active_slot, previous.active_path, ): return self._finish_operation( "confirmed", "matching authoritative Player evidence" ) if not has_confirmed_active: self._mark_play_uncertain(operation, "play timeout") 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 guard = self.store.mark_slot_uncertain( previous.active_slot, review_after=due, details={"operation_id": operation.operation_id, "reason": "stop timeout"}, ) due = guard.review_after self.store.set_player_observation( availability="unknown", phase="unknown", active_slot=previous.active_slot, active_path=previous.active_path, paused=previous.paused, reconciliation_state=( "operator_review" if guard.state == "review_required" else "awaiting_evidence" ), reconciliation_due_at=due, reconciliation_id=guard.guard_id, ) elif previous.active_path 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.mark_external_uncertain( previous.active_path, review_after=due, details={ "operation_id": operation.operation_id, "reason": "stop timeout", }, ) elif operation.action in {"pause", "resume", "toggle"}: previous = self.store.player_observation() if previous.active_slot is not None or previous.active_path is not None: self.store.set_player_observation( availability=previous.availability, phase="unknown", active_slot=previous.active_slot, active_path=previous.active_path, paused=None, reconciliation_state=previous.reconciliation_state, reconciliation_due_at=previous.reconciliation_due_at, reconciliation_id=previous.reconciliation_id, ) return self._finish_operation("timed_out", "no authoritative Player confirmation") def _mark_play_uncertain(self, operation: PlayerOperation, reason: str) -> None: due = None if self.config.storage.uncertain_review_after_seconds > 0: due = self._wall_clock() + self.config.storage.uncertain_review_after_seconds slot = operation.slot or self._canonical_slot_for_path(operation.expected_path) if slot is not None: guard = self.store.mark_slot_uncertain( slot, review_after=due, details={"operation_id": operation.operation_id, "reason": reason}, ) due = guard.review_after self.store.set_player_observation( availability="unknown", phase="unknown", active_slot=slot, active_path=operation.expected_path, paused=None, reconciliation_state=( "operator_review" if guard.state == "review_required" else "awaiting_evidence" ), reconciliation_due_at=due, reconciliation_id=guard.guard_id, ) elif operation.expected_path is not None: self.store.mark_external_uncertain( operation.expected_path, review_after=due, details={"operation_id": operation.operation_id, "reason": reason}, ) def publication_uncertain(self, reason: str) -> PlaybackEffect | None: if self.pending is None: return None operation = self.pending if operation.action == "play": self._mark_play_uncertain(operation, reason) return self._finish_operation("uncertain", reason) def mark_transport_uncertain(self, reason: str) -> PlaybackEffect: previous = self.store.player_observation() pending_play = ( self.pending if self.pending is not None and self.pending.action == "play" else None ) active_slot = previous.active_slot active_path = previous.active_path if active_slot is None and active_path is None and pending_play is not None: active_slot = pending_play.slot or self._canonical_slot_for_path( pending_play.expected_path ) active_path = pending_play.expected_path due = None if active_slot is not None: if self.config.storage.uncertain_review_after_seconds > 0: due = self._wall_clock() + self.config.storage.uncertain_review_after_seconds guard = self.store.mark_slot_uncertain( active_slot, review_after=due, details={"reason": reason}, ) due = guard.review_after self.store.set_player_observation( availability="unknown", phase="unknown", active_slot=active_slot, active_path=active_path, paused=previous.paused, reconciliation_state=( "operator_review" if guard.state == "review_required" else "awaiting_evidence" ), reconciliation_due_at=due, reconciliation_id=guard.guard_id, ) elif active_path is not None: if self.config.storage.uncertain_review_after_seconds > 0: due = self._wall_clock() + self.config.storage.uncertain_review_after_seconds self.store.mark_external_uncertain( active_path, review_after=due, details={"reason": reason}, ) else: self.store.set_player_observation( availability="unknown", phase=previous.phase, active_slot=None, active_path=None, paused=previous.paused, reconciliation_state="unknown", reconciliation_due_at=None, reconciliation_id=None, ) return PlaybackEffect( "transport_uncertain", slot=active_slot, message=reason, ) def projected_slot_state(self, slot: int) -> str: record = self.store.get_slot(slot) 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 record.media_state return "operator_review" if guard.state == "review_required" else "unknown" return record.media_state def mark_due_reconciliation(self) -> list[PlaybackEffect]: now = self._wall_clock() due = self.store.mark_due_guards_for_review(now) external_due = self.store.mark_external_reconciliation_due(now) 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, reconciliation_id=observation.reconciliation_id, ) effects = [ PlaybackEffect( "operator_review", slot=guard.slot, operation_id=guard.guard_id, message=( "Playback remains unknown and protected; fresh Player evidence " "or an administrative clear is required " f"(review_id={guard.guard_id})" ), ) for guard in due ] if external_due is not None: review_id, path = external_due effects.append( PlaybackEffect( "operator_review", operation_id=review_id, message=( f"Playback at {path!r} remains unknown and protected; " "fresh Player evidence or an administrative clear is required " f"(review_id={review_id})" ), ) ) return effects