feat: coordinate authoritative playback and audio routing

This commit is contained in:
Media
2026-08-06 18:52:01 -05:00
parent 8b3bdae8a0
commit 3ee7f94d72
17 changed files with 4863 additions and 186 deletions
+400 -73
View File
@@ -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