feat: overhaul video player controller

This commit is contained in:
Media
2026-08-06 20:08:12 -05:00
parent 72cc66df92
commit 5e81403be3
10 changed files with 2019 additions and 122 deletions
+175 -49
View File
@@ -99,7 +99,7 @@ class ControllerService:
self._processed_packets: dict[tuple[int, str, int], float] = {}
self._player_evidence_ready = False
self._operator_cleanup_override = False
self._recovery_scheduled = False
self._cleanup_review_id: str | None = None
@property
def started(self) -> bool:
@@ -149,9 +149,7 @@ class ControllerService:
)
# Cleanup is deliberately deferred until fresh Player evidence
# (or an explicit administrative reconciliation) makes it safe.
self._player_evidence_ready = False
self._operator_cleanup_override = False
self._recovery_scheduled = False
self._reset_cleanup_evidence()
self._stop_event.clear()
self._next_expiry = (
self._monotonic()
@@ -271,8 +269,13 @@ class ControllerService:
guards = self.store.list_live_guards() if started else []
player_operation_pending = bool(
started
and self.playback is not None
and self.playback.pending is not None
and (
self._pending_play is not None
or (
self.playback is not None
and self.playback.pending is not None
)
)
)
mqtt_connected = bool(started and self.mqtt.connected)
degraded: list[str] = []
@@ -321,6 +324,22 @@ class ControllerService:
"clearable": not player_operation_pending,
}
)
cleanup_reconciliation = None
if (
started
and not self._cleanup_allowed()
and self._cleanup_review_id is not None
and not guards
and player_observation is not None
and player_observation.active_slot is None
and player_observation.active_path is None
):
cleanup_reconciliation = {
"review_id": self._cleanup_review_id,
"kind": "player_evidence",
"state": "awaiting_evidence",
"clearable": not player_operation_pending,
}
return {
"status": (
"stopped"
@@ -346,6 +365,7 @@ class ControllerService:
}
),
"reconciliation_reviews": reviews,
"cleanup_reconciliation": cleanup_reconciliation,
"degraded_reasons": degraded,
}
@@ -416,16 +436,14 @@ class ControllerService:
handled_successfully = True
return
if event.kind == "connected":
self._player_evidence_ready = False
self._operator_cleanup_override = False
self._reset_cleanup_evidence()
self._announce_discovery()
self._sync_slot_states(force=True)
self._announce_reconciliation_reviews()
handled_successfully = True
return
if event.kind == "disconnected":
self._player_evidence_ready = False
self._operator_cleanup_override = False
self._reset_cleanup_evidence()
assert self.playback is not None
assert self.router is not None
playback_effect = self.playback.mark_transport_uncertain(
@@ -441,30 +459,41 @@ class ControllerService:
handled_successfully = True
return
if event.topic == self.config.topics.player_status:
payload = self._decode_json(event.payload, "Player status")
if payload is not None:
assert self.playback is not None
before_generation = self.store.player_observation().generation
self._handle_playback_effects(self.playback.handle_status(payload))
self._refresh_player_evidence(before_generation)
if event.retain:
self._publish_event(
"retained_player_evidence_rejected",
"warning",
"Retained Player status was ignored as historical evidence",
)
else:
payload = self._decode_json(event.payload, "Player status")
if payload is not None:
assert self.playback is not None
before_generation = self.store.player_observation().generation
effects = self.playback.handle_status(payload)
self._handle_playback_effects(effects)
self._refresh_player_evidence(before_generation, effects)
elif event.topic == self.config.topics.player_telemetry:
payload = self._decode_json(event.payload, "Player telemetry")
if payload is not None:
assert self.playback is not None
before_generation = self.store.player_observation().generation
self._handle_playback_effects(self.playback.handle_telemetry(payload))
self._refresh_player_evidence(before_generation)
if event.retain:
self._publish_event(
"retained_player_evidence_rejected",
"warning",
"Retained Player telemetry was ignored as historical evidence",
)
else:
payload = self._decode_json(event.payload, "Player telemetry")
if payload is not None:
assert self.playback is not None
before_generation = self.store.player_observation().generation
effects = self.playback.handle_telemetry(payload)
self._handle_playback_effects(effects)
self._refresh_player_evidence(before_generation, effects)
elif event.topic == self.config.topics.player_state:
try:
flat_state = event.payload.decode("utf-8").strip()
except UnicodeDecodeError:
flat_state = ""
# Retained flat idle is sufficient only as a quiescence gate;
# it never mutates detailed playback state or clears guards.
self._player_evidence_ready = flat_state == "idle"
self._operator_cleanup_override = False
if self._player_evidence_ready:
self._schedule_deletion_recovery()
# The Player owns this retained Companion topic. Its value is
# historical across outages and reconnect deliberately emits
# flat ``idle`` even while MPV is active, so it is never fresh
# cleanup authority.
pass
elif event.topic == self.config.topics.controller_command:
if event.retain:
self._publish_event(
@@ -511,8 +540,9 @@ class ControllerService:
else:
decision = self.router.observe_mode(mode)
self._handle_router_decision(decision)
# Flat state is only a cold-start cleanup gate. Detailed playback
# truth continues to come exclusively from status and telemetry.
# The flat state subscription is compatibility observation only.
# Playback and cleanup truth come exclusively from detailed status
# and telemetry or an explicit administrative reconciliation.
handled_successfully = True
finally:
if handled_successfully:
@@ -873,17 +903,20 @@ class ControllerService:
expected_url = payload.get("url")
if expected_url is not None and not isinstance(expected_url, str):
raise ValueError("delete URL guard must be a string")
self.downloads.delete_slot(
self.downloads.schedule_delete_slot(
slot,
reason="manual",
expected_url=expected_url,
)
self._announce_slot(slot, force=True)
return {"status": "deleted", "slot": slot}
return {"status": "deleting", "slot": slot}
def _request_reconciliation(self, payload: Mapping[str, Any]) -> dict[str, Any]:
if payload.get("decision") != "clear" or payload.get("confirm") is not True:
raise ValueError("reconciliation clear requires decision=clear and confirm=true")
scope = payload.get("scope")
if scope not in {None, "cleanup"}:
raise ValueError("reconciliation scope must be cleanup when provided")
slot = (
self._parse_slot(payload.get("slot"))
if payload.get("slot") is not None
@@ -899,7 +932,36 @@ class ControllerService:
raise ValueError(
"playback reconciliation cannot be cleared while a Player operation is pending"
)
if slot is None:
if scope == "cleanup":
if slot is not None:
raise ValueError("cleanup reconciliation does not accept a slot")
observation = self.store.player_observation()
if (
self._cleanup_allowed()
or self._cleanup_review_id is None
or review_id.strip() != self._cleanup_review_id
):
raise StaleOperationError("cleanup reconciliation review_id is stale")
if (
self.store.list_live_guards()
or observation.active_slot is not None
or observation.active_path is not None
):
raise SlotProtectedError(
"cleanup reconciliation cannot bypass known playback protection"
)
self.store.record_audit_event(
"player_evidence_admin_override",
actor=actor.strip(),
details={
"review_id": review_id.strip(),
"reason": reason.strip(),
},
)
self._operator_cleanup_override = True
self._cleanup_review_id = None
cleared = True
elif slot is None:
cleared = self.store.clear_external_reconciliation(
review_id.strip(),
actor=actor.strip(),
@@ -933,7 +995,9 @@ class ControllerService:
and observation.active_slot is None
and observation.active_path is None
):
self._operator_cleanup_override = True
if scope != "cleanup":
self._operator_cleanup_override = True
self._cleanup_review_id = None
self._schedule_deletion_recovery()
assert self.router is not None
self.router.abandon(
@@ -954,6 +1018,8 @@ class ControllerService:
"status": "cleared",
"review_id": review_id.strip(),
}
if scope == "cleanup":
result["scope"] = "cleanup"
if slot is not None:
result["slot"] = slot
return result
@@ -996,7 +1062,11 @@ class ControllerService:
no_active_player = (
observation.active_slot is None and observation.active_path is None
)
if terminal or (failed_play and no_active_player):
unidentified_busy = (
no_active_player
and any(effect.kind == "player_busy" for effect in effects)
)
if terminal or (failed_play and no_active_player and not unidentified_busy):
self._handle_router_decision(self.router.release(self._session_lease_id))
self._session_lease_id = None
for effect in effects:
@@ -1028,6 +1098,27 @@ class ControllerService:
self._sync_slot_states()
def _handle_download_event(self, event: DownloadEvent) -> None:
if event.state == "deleted":
self._announce_slot(event.slot, force=True)
return
if event.state == "delete_deferred":
self._announce_slot(event.slot, force=True)
return
if event.state == "delete_error":
message = event.message or "slot deletion remains pending"
self._publish_event(
"slot_deletion_pending",
"warning",
message,
slot=event.slot,
)
self._announce_slot(event.slot, force=True)
retry_delay = max(
1.0,
min(30.0, self.config.storage.expiry_interval_seconds),
)
self._next_expiry = self._monotonic() + retry_delay
return
payload: dict[str, Any] = {"slot": event.slot, "state": event.state}
for key in ("title", "count", "message", "url", "submitter", "note"):
value = getattr(event, key)
@@ -1171,21 +1262,55 @@ class ControllerService:
return self._player_evidence_ready or self._operator_cleanup_override
def _schedule_deletion_recovery(self) -> None:
if self._recovery_scheduled or not self._cleanup_allowed():
if not self._cleanup_allowed():
return
assert self.downloads is not None
self._recovery_scheduled = (
self.downloads.schedule_pending_deletion_recovery()
)
self.downloads.schedule_pending_deletion_recovery()
def _refresh_player_evidence(self, before_generation: int) -> None:
def _refresh_player_evidence(
self,
before_generation: int,
effects: list[PlaybackEffect],
) -> None:
observation = self.store.player_observation()
if observation.generation == before_generation:
return
self._operator_cleanup_override = False
self._player_evidence_ready = observation.availability == "online"
if self._player_evidence_ready and observation.phase == "idle":
kinds = {effect.kind for effect in effects}
identity_known = (
observation.active_slot is not None
or observation.active_path is not None
)
safe_evidence = "player_idle" in kinds or (
identity_known
and bool(
kinds
& {
"player_active",
"player_busy",
"player_reconnected_active",
"player_telemetry",
"control_issued",
}
)
)
if safe_evidence and observation.availability == "online":
self._player_evidence_ready = True
self._operator_cleanup_override = False
self._cleanup_review_id = None
self._schedule_deletion_recovery()
return
self._mark_cleanup_unknown()
def _reset_cleanup_evidence(self) -> None:
self._player_evidence_ready = False
self._operator_cleanup_override = False
self._cleanup_review_id = uuid.uuid4().hex
def _mark_cleanup_unknown(self) -> None:
self._player_evidence_ready = False
self._operator_cleanup_override = False
if self._cleanup_review_id is None:
self._cleanup_review_id = uuid.uuid4().hex
def _run_timers(self) -> None:
assert self.playback is not None
@@ -1203,6 +1328,7 @@ class ControllerService:
self._handle_playback_effects(self.playback.mark_due_reconciliation())
self._next_reconciliation = now + 1.0
if now >= self._next_expiry and self._cleanup_allowed():
for slot in self.downloads.expire_due_slots():
self._announce_slot(slot, force=True)
self._schedule_deletion_recovery()
for token in self.downloads.schedule_expired_deletions():
self._announce_slot(token.slot, force=True)
self._next_expiry = now + self.config.storage.expiry_interval_seconds