Files
Video-Player-Controller/controller/playback.py
T

823 lines
33 KiB
Python

"""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"}
)
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":
if slot is not None or path is not None:
self._active_evidence(
slot=slot,
path=path,
paused=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")},
)
elif active_path is not None:
self.store.reconcile_external_active()
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=(
"confirmed"
if active_slot is not None or active_path is not None
else "unknown"
),
reconciliation_due_at=None,
reconciliation_id=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, 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",
},
)
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)
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]:
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