823 lines
33 KiB
Python
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
|