Skip to content

Commit 627fa49

Browse files
committed
encoder: revalidate cached public broadcasts
1 parent c3670ce commit 627fa49

6 files changed

Lines changed: 242 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ All notable project changes are recorded here. The README shows only the most re
66

77
- Reordered recovery so one marked unlisted broadcast is reconciled, created if needed, and bound before FFmpeg starts ingest.
88
- Added fail-closed detection for unmarked recoverable broadcasts bound to the reusable stream, preventing unintended legacy auto-start events.
9-
- Reused the prepared lifecycle state after ingest becomes active to avoid a redundant reconciliation pass.
9+
- Reused the prepared lifecycle state after ingest becomes active, while revalidating cached public events and reconciling a replacement when YouTube confirms the cached event is terminal, missing, or rebound.
1010
- Added redacted API operation, HTTP status, and structured reason details to supervisor failures for actionable quota diagnostics.
1111
- Added regression coverage for offline staging, insert and bind partial failures, stable generation reuse, public-stream fallback, and CLI gating.
1212

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,7 @@ Only the most recent changelog entry is shown here. See `CHANGELOG.md` for full
429429

430430
### 2026-07-11 - Pre-Ingest Broadcast Staging
431431

432-
- Stages and binds one marked unlisted event before FFmpeg ingest, blocks unmarked bound conflicts, and logs structured API operation and rate-limit details without exposing credentials.
432+
- Stages and binds one marked unlisted event before FFmpeg ingest, blocks unmarked bound conflicts, revalidates cached public events after ingest, and logs structured API operation and rate-limit details without exposing credentials.
433433

434434
## Repository Layout
435435

bin/youtube-autoencoder

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ class ApiCommandError(RuntimeError):
8383
self.http_status = self.payload.get("http_status")
8484
self.reasons = tuple(str(reason) for reason in self.payload.get("reasons") or [])
8585
self.retry_class = str(self.payload.get("retry_class") or "fatal")
86+
self.public_fallback_allowed = self.payload.get("public_fallback_allowed") is not False
8687
retry_after = self.payload.get("retry_after")
8788
try:
8889
self.retry_after = float(retry_after) if retry_after is not None else None
@@ -694,6 +695,7 @@ def current_observation(runtime: StreamRuntime, state: dict[str, Any]) -> dict[s
694695
payload={
695696
"message": f"managed broadcast is bound to a different stream: {bound_stream_id or '<empty>'}",
696697
"retry_class": "ambiguous",
698+
"public_fallback_allowed": False,
697699
},
698700
)
699701
ensure_encoder_running(runtime)
@@ -830,11 +832,12 @@ def manage_youtube_lifecycle(
830832
prepared_state: dict[str, Any] | None = None,
831833
) -> dict[str, Any]:
832834
active_stream = wait_for_active_stream(runtime)
833-
state = (
834-
dict(prepared_state)
835-
if prepared_state is not None
836-
else api_command_while_streaming(reconcile_command_args(), runtime, timeout=120)
837-
)
835+
if prepared_state is None:
836+
state = api_command_while_streaming(reconcile_command_args(), runtime, timeout=120)
837+
else:
838+
state = dict(prepared_state)
839+
if verified_public_state(state):
840+
state = revalidate_public_state(runtime, state)
838841
stream_id = str(state.get("stream_id") or state.get("last_stream_id") or "")
839842
broadcast_id = str(state.get("broadcast_id") or state.get("last_broadcast_id") or "")
840843
if not stream_id or not broadcast_id:
@@ -863,11 +866,50 @@ def verified_public_state(state: dict[str, Any]) -> bool:
863866
return lifecycle == "live" and privacy == "public" and bool(stream_id and broadcast_id)
864867

865868

869+
def disable_public_fallback(error: ApiCommandError) -> ApiCommandError:
870+
payload = dict(error.payload)
871+
payload["public_fallback_allowed"] = False
872+
return ApiCommandError(
873+
returncode=error.returncode,
874+
operation=error.operation,
875+
payload=payload,
876+
)
877+
878+
879+
def reconcile_invalid_public_state(runtime: StreamRuntime) -> dict[str, Any]:
880+
try:
881+
return api_command_while_streaming(reconcile_command_args(), runtime, timeout=120)
882+
except ApiCommandError as error:
883+
raise disable_public_fallback(error) from error
884+
885+
886+
def revalidate_public_state(runtime: StreamRuntime, state: dict[str, Any]) -> dict[str, Any]:
887+
try:
888+
observation = current_observation(runtime, state)
889+
except ApiCommandError as error:
890+
if error.public_fallback_allowed:
891+
raise
892+
log("cached public broadcast is no longer recoverable; reconciling replacement")
893+
return reconcile_invalid_public_state(runtime)
894+
895+
if observation.get("lifecycle") == "live" and observation.get("privacy") == "public":
896+
refreshed = dict(state)
897+
refreshed["lifecycle"] = "live"
898+
refreshed["privacy"] = "public"
899+
return refreshed
900+
901+
log(
902+
"cached public broadcast is no longer live/public; reconciling replacement "
903+
f"lifecycle={observation.get('lifecycle')} privacy={observation.get('privacy')}"
904+
)
905+
return reconcile_invalid_public_state(runtime)
906+
907+
866908
def manage_with_public_fallback(runtime: StreamRuntime, cached_state: dict[str, Any]) -> dict[str, Any]:
867909
try:
868910
return manage_youtube_lifecycle(runtime, prepared_state=cached_state)
869911
except ApiCommandError as error:
870-
if not verified_public_state(cached_state):
912+
if not verified_public_state(cached_state) or not error.public_fallback_allowed:
871913
raise
872914
ensure_encoder_running(runtime)
873915
retry_class = classify_api_error(error)

bin/youtube-autoencoder-api

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ class ReconciliationError(RuntimeError):
8686
retry_class = "ambiguous"
8787

8888

89+
class RemoteBroadcastStateError(ReconciliationError):
90+
public_fallback_allowed = False
91+
92+
8993
def log(message: str) -> None:
9094
print(f"{time.strftime('%Y-%m-%dT%H:%M:%S%z')} {message}", file=sys.stderr, flush=True)
9195

@@ -185,14 +189,17 @@ def error_payload(exc: BaseException) -> dict[str, Any]:
185189
"retry_class": exc.retry_class,
186190
}
187191
retry_class = str(getattr(exc, "retry_class", "fatal"))
188-
return {
192+
payload = {
189193
"error": True,
190194
"http_status": None,
191195
"message": str(exc),
192196
"reasons": [],
193197
"retry_after": None,
194198
"retry_class": retry_class,
195199
}
200+
if getattr(exc, "public_fallback_allowed", True) is False:
201+
payload["public_fallback_allowed"] = False
202+
return payload
196203

197204

198205
def client_config() -> dict[str, str]:
@@ -845,7 +852,7 @@ def broadcast_status_payload(broadcast: dict[str, Any]) -> dict[str, Any]:
845852
def broadcast_status(broadcast_id: str) -> dict[str, Any]:
846853
broadcast = broadcast_by_id(broadcast_id)
847854
if broadcast is None:
848-
raise ReconciliationError(f"liveBroadcast not found: {broadcast_id}")
855+
raise RemoteBroadcastStateError(f"liveBroadcast not found: {broadcast_id}")
849856
return broadcast_status_payload(broadcast)
850857

851858

tests/test_api.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,18 @@ def test_set_privacy_command_reuses_verified_readback(load_script, monkeypatch,
804804
assert output["privacy"] == "public"
805805

806806

807+
def test_missing_broadcast_status_disables_cached_public_fallback(load_script, monkeypatch):
808+
api = load_script("youtube-autoencoder-api", "yta_api_missing_broadcast_status")
809+
monkeypatch.setattr(api, "broadcast_by_id", lambda _broadcast_id: None)
810+
811+
with pytest.raises(api.ReconciliationError, match="not found") as raised:
812+
api.broadcast_status("missing-broadcast")
813+
814+
payload = api.error_payload(raised.value)
815+
assert payload["retry_class"] == "ambiguous"
816+
assert payload["public_fallback_allowed"] is False
817+
818+
807819
def test_retry_state_updates_preserve_broadcast_identity(load_script, monkeypatch, tmp_path):
808820
api = load_script("youtube-autoencoder-api", "yta_api_retry_state")
809821
configure_reconciliation(api, monkeypatch, tmp_path)

tests/test_supervisor.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,7 @@ def test_current_observation_rejects_broadcast_rebound_to_different_stream(load_
467467
)
468468

469469
assert raised.value.retry_class == "ambiguous"
470+
assert raised.value.public_fallback_allowed is False
470471

471472

472473
@pytest.mark.parametrize(
@@ -725,6 +726,134 @@ def test_prepared_lifecycle_reuses_staged_state_after_ingest(load_script, monkey
725726
assert state["privacy"] == "public"
726727

727728

729+
def test_cached_public_broadcast_is_reconciled_when_remote_event_completed(load_script, monkeypatch):
730+
supervisor = load_script("youtube-autoencoder", "yta_public_completed_reconcile")
731+
events = []
732+
runtime = supervisor.StreamRuntime(
733+
process=FakeFfmpegProcess(returncode=None),
734+
selector=FakeSelector(),
735+
watchdog=supervisor.ProgressWatchdog(
736+
started_at=supervisor.time.monotonic(),
737+
timeout=30.0,
738+
last_progress_at=supervisor.time.monotonic(),
739+
),
740+
)
741+
cached = {
742+
"stream_id": "stream-1",
743+
"broadcast_id": "broadcast-old",
744+
"lifecycle": "live",
745+
"privacy": "public",
746+
}
747+
replacement = {
748+
"stream_id": "stream-1",
749+
"broadcast_id": "broadcast-new",
750+
"lifecycle": "ready",
751+
"privacy": "unlisted",
752+
}
753+
754+
monkeypatch.setattr(
755+
supervisor,
756+
"wait_for_active_stream",
757+
lambda _runtime: events.append("active") or {"stream_id": "stream-1"},
758+
)
759+
monkeypatch.setattr(
760+
supervisor,
761+
"current_observation",
762+
lambda _runtime, _state: events.append("revalidate")
763+
or {
764+
"stream_status": "active",
765+
"health": "good",
766+
"lifecycle": "complete",
767+
"privacy": "public",
768+
"encoder_alive": True,
769+
"media_fresh": True,
770+
},
771+
)
772+
773+
def command(args, _runtime, timeout):
774+
assert args[0] == "reconcile-broadcast"
775+
assert "--offline-create" not in args
776+
assert timeout == 120
777+
events.append("reconcile")
778+
return replacement
779+
780+
monkeypatch.setattr(supervisor, "api_command_while_streaming", command)
781+
monkeypatch.setattr(
782+
supervisor,
783+
"reconcile_lifecycle",
784+
lambda _runtime, state: events.append("lifecycle") or {**state, "lifecycle": "live"},
785+
)
786+
monkeypatch.setattr(
787+
supervisor,
788+
"publish_when_healthy",
789+
lambda _runtime, state: events.append("publish") or {**state, "privacy": "public"},
790+
)
791+
792+
state = supervisor.manage_youtube_lifecycle(runtime, prepared_state=cached)
793+
794+
assert events == ["active", "revalidate", "reconcile", "lifecycle", "publish"]
795+
assert state["broadcast_id"] == "broadcast-new"
796+
797+
798+
def test_cached_public_broadcast_is_revalidated_after_ingest(load_script, monkeypatch):
799+
supervisor = load_script("youtube-autoencoder", "yta_public_revalidated")
800+
events = []
801+
runtime = supervisor.StreamRuntime(
802+
process=FakeFfmpegProcess(returncode=None),
803+
selector=FakeSelector(),
804+
watchdog=supervisor.ProgressWatchdog(
805+
started_at=supervisor.time.monotonic(),
806+
timeout=30.0,
807+
last_progress_at=supervisor.time.monotonic(),
808+
),
809+
)
810+
cached = {
811+
"stream_id": "stream-1",
812+
"broadcast_id": "broadcast-1",
813+
"lifecycle": "live",
814+
"privacy": "public",
815+
}
816+
817+
monkeypatch.setattr(
818+
supervisor,
819+
"wait_for_active_stream",
820+
lambda _runtime: events.append("active") or {"stream_id": "stream-1"},
821+
)
822+
monkeypatch.setattr(
823+
supervisor,
824+
"current_observation",
825+
lambda _runtime, _state: events.append("revalidate")
826+
or {
827+
"stream_status": "active",
828+
"health": "good",
829+
"lifecycle": "live",
830+
"privacy": "public",
831+
"encoder_alive": True,
832+
"media_fresh": True,
833+
},
834+
)
835+
monkeypatch.setattr(
836+
supervisor,
837+
"api_command_while_streaming",
838+
lambda *_args, **_kwargs: pytest.fail("valid public broadcast was reconciled again"),
839+
)
840+
monkeypatch.setattr(
841+
supervisor,
842+
"reconcile_lifecycle",
843+
lambda _runtime, state: events.append("lifecycle") or state,
844+
)
845+
monkeypatch.setattr(
846+
supervisor,
847+
"publish_when_healthy",
848+
lambda _runtime, state: events.append("publish") or state,
849+
)
850+
851+
state = supervisor.manage_youtube_lifecycle(runtime, prepared_state=cached)
852+
853+
assert events == ["active", "revalidate", "lifecycle", "publish"]
854+
assert state == cached
855+
856+
728857
def test_nonpublic_state_is_staged_before_ffmpeg_starts(load_script, monkeypatch):
729858
supervisor = load_script("youtube-autoencoder", "yta_preingest_stage_order")
730859
events = []
@@ -852,6 +981,48 @@ def capture(args, _runtime, timeout):
852981
assert calls[0][1] == 30
853982

854983

984+
def test_invalidated_public_stream_does_not_enter_api_fallback(load_script, monkeypatch):
985+
supervisor = load_script("youtube-autoencoder", "yta_invalid_public_no_fallback")
986+
runtime = supervisor.StreamRuntime(
987+
process=FakeFfmpegProcess(returncode=None),
988+
selector=FakeSelector(),
989+
watchdog=supervisor.ProgressWatchdog(
990+
started_at=supervisor.time.monotonic(),
991+
timeout=30.0,
992+
last_progress_at=supervisor.time.monotonic(),
993+
),
994+
)
995+
cached = {
996+
"stream_id": "stream-1",
997+
"broadcast_id": "broadcast-1",
998+
"lifecycle": "live",
999+
"privacy": "public",
1000+
}
1001+
failure = supervisor.ApiCommandError(
1002+
returncode=75,
1003+
payload={
1004+
"message": "rate limited while replacing completed broadcast",
1005+
"retry_class": "quota",
1006+
"public_fallback_allowed": False,
1007+
},
1008+
)
1009+
monkeypatch.setattr(
1010+
supervisor,
1011+
"manage_youtube_lifecycle",
1012+
lambda _runtime, prepared_state=None: (_ for _ in ()).throw(failure),
1013+
)
1014+
monkeypatch.setattr(
1015+
supervisor,
1016+
"api_command_while_streaming",
1017+
lambda *_args, **_kwargs: pytest.fail("invalid public state entered fallback"),
1018+
)
1019+
1020+
with pytest.raises(supervisor.ApiCommandError) as raised:
1021+
supervisor.manage_with_public_fallback(runtime, cached)
1022+
1023+
assert raised.value is failure
1024+
1025+
8551026
def test_prepublic_api_failure_does_not_enter_public_fallback(load_script, monkeypatch):
8561027
supervisor = load_script("youtube-autoencoder", "yta_prepublic_api_failure")
8571028
runtime = supervisor.StreamRuntime(

0 commit comments

Comments
 (0)