feat: coordinate authoritative playback and audio routing
This commit is contained in:
+400
-73
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, replace
|
||||
@@ -28,6 +29,7 @@ class PlayerOperation:
|
||||
slot: int | None
|
||||
expected_path: str | None
|
||||
deadline: float
|
||||
reconciliation_id: str | None = None
|
||||
state: str = "awaiting_player"
|
||||
message: str | None = None
|
||||
|
||||
@@ -71,6 +73,7 @@ class PlaybackReducer:
|
||||
*,
|
||||
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}")
|
||||
@@ -82,14 +85,13 @@ class PlaybackReducer:
|
||||
)
|
||||
self._operation_generation += 1
|
||||
operation = PlayerOperation(
|
||||
operation_id=uuid.uuid4().hex,
|
||||
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,
|
||||
)
|
||||
self.pending = operation
|
||||
if action == "play" and slot is not None:
|
||||
self.store.protect_slot(
|
||||
slot,
|
||||
@@ -97,11 +99,40 @@ class PlaybackReducer:
|
||||
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:
|
||||
@@ -142,11 +173,64 @@ class PlaybackReducer:
|
||||
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
|
||||
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,
|
||||
@@ -158,11 +242,11 @@ class PlaybackReducer:
|
||||
telemetry: Mapping[str, Any] | None = None,
|
||||
) -> PlayerObservation:
|
||||
previous = self.store.player_observation()
|
||||
if slot is None:
|
||||
if slot is None and (path is None or path == previous.active_path):
|
||||
slot = previous.active_slot
|
||||
if path is None:
|
||||
if path is None and (slot is None or slot == previous.active_slot):
|
||||
path = previous.active_path
|
||||
if paused is None and previous.active_slot == slot:
|
||||
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:
|
||||
@@ -171,6 +255,8 @@ class PlaybackReducer:
|
||||
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,
|
||||
@@ -179,6 +265,7 @@ class PlaybackReducer:
|
||||
paused=paused,
|
||||
reconciliation_state="confirmed",
|
||||
reconciliation_due_at=None,
|
||||
reconciliation_id=None,
|
||||
status_payload=status,
|
||||
telemetry_payload=telemetry,
|
||||
)
|
||||
@@ -199,6 +286,7 @@ class PlaybackReducer:
|
||||
paused=False,
|
||||
reconciliation_state="clear",
|
||||
reconciliation_due_at=None,
|
||||
reconciliation_id=None,
|
||||
status_payload=status,
|
||||
telemetry_payload=telemetry,
|
||||
)
|
||||
@@ -212,36 +300,63 @@ class PlaybackReducer:
|
||||
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"))
|
||||
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
|
||||
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(
|
||||
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,
|
||||
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,
|
||||
)
|
||||
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",
|
||||
@@ -261,15 +376,56 @@ class PlaybackReducer:
|
||||
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 and self.pending.action == "stop":
|
||||
effects.append(self._finish_operation("confirmed"))
|
||||
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:
|
||||
@@ -280,29 +436,33 @@ class PlaybackReducer:
|
||||
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,
|
||||
paused=None,
|
||||
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"))
|
||||
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"}:
|
||||
previous = self.store.player_observation()
|
||||
self._active_evidence(
|
||||
slot=slot,
|
||||
path=path,
|
||||
paused=previous.paused if previous.active_slot == slot else None,
|
||||
paused=None,
|
||||
status=payload,
|
||||
)
|
||||
effects.append(
|
||||
@@ -312,15 +472,24 @@ class PlaybackReducer:
|
||||
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:
|
||||
previous = self.store.player_observation()
|
||||
self._active_evidence(
|
||||
slot=slot,
|
||||
path=path,
|
||||
paused=previous.paused if previous.active_slot == slot else None,
|
||||
paused=None,
|
||||
status=payload,
|
||||
)
|
||||
effects.append(PlaybackEffect("player_busy", slot=slot, message=payload.get("message")))
|
||||
@@ -338,14 +507,29 @@ class PlaybackReducer:
|
||||
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,
|
||||
reconciliation_state=("confirmed" if active_slot is not None else "unknown"),
|
||||
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")
|
||||
@@ -360,8 +544,9 @@ class PlaybackReducer:
|
||||
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"))
|
||||
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)
|
||||
@@ -383,7 +568,15 @@ class PlaybackReducer:
|
||||
)
|
||||
effects.append(PlaybackEffect("player_telemetry", slot=observation.active_slot))
|
||||
operation = self.pending
|
||||
if operation is None or not self._matches_pending(slot, path):
|
||||
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"))
|
||||
@@ -399,48 +592,165 @@ class PlaybackReducer:
|
||||
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,
|
||||
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
|
||||
self.store.reconcile_active_guard(
|
||||
guard = self.store.mark_slot_uncertain(
|
||||
previous.active_slot,
|
||||
kind="restart_uncertain",
|
||||
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="awaiting_evidence",
|
||||
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":
|
||||
@@ -466,9 +776,9 @@ class PlaybackReducer:
|
||||
return "ready"
|
||||
|
||||
def mark_due_reconciliation(self) -> list[PlaybackEffect]:
|
||||
due = self.store.mark_due_guards_for_review(self._wall_clock())
|
||||
if not due:
|
||||
return []
|
||||
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
|
||||
@@ -481,15 +791,32 @@ class PlaybackReducer:
|
||||
paused=observation.paused,
|
||||
reconciliation_state="operator_review",
|
||||
reconciliation_due_at=observation.reconciliation_due_at,
|
||||
reconciliation_id=observation.reconciliation_id,
|
||||
)
|
||||
return [
|
||||
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"
|
||||
"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
|
||||
|
||||
Reference in New Issue
Block a user