Skip to content

Commit 174aa03

Browse files
authored
Stage managed broadcast before starting ingest (#11)
## Summary - Reconcile, create if needed, and bind exactly one marked unlisted broadcast before FFmpeg starts ingest. - Fail closed before ingest when an unmarked recoverable broadcast is already bound to the reusable stream. - Reuse the prepared lifecycle state after ingest becomes active, avoiding a redundant reconciliation pass. - Include API operation, HTTP status, and structured reason values in redacted supervisor failures. - Update the executive flow, Pi runbook, architecture diagram, lifecycle design, and changelog. ## Root Cause The deployed controller started FFmpeg before it could create the first marked replacement event. A legacy unmarked event with auto-start and auto-stop enabled therefore became live as soon as ingest appeared. `liveBroadcasts.insert` then returned `User requests exceed the rate limit.`, the pre-public fail-closed path stopped FFmpeg, and YouTube ended the legacy event seven seconds after it started. The durable write-ahead generation remained safe and no new marked broadcast ID was recorded, but the quota backoff repeated the same visible start/stop sequence. ## Recovery Design - Source probe remains the first gate; a camera that is already unavailable creates nothing. - Non-public recovery stages one marked unlisted event and durably verifies binding before FFmpeg starts. - Active and upcoming inventory blocks any unmarked event bound to the reusable stream. - Lost insert responses reconcile by the existing generation marker before another insert. - Insert throttling preserves the same pending generation. - Bind failure preserves the returned broadcast ID and retries binding without reinserting. - Active/good ingest and fresh FFmpeg media remain mandatory for `testing`, `live`, and public promotion. - A previously verified public event still starts media without making control-plane availability a hard pre-ingest dependency. ## Rollout And Rollback Rollout will stop the Pi service, create a private timestamped snapshot, deploy the merged scripts, clear only the expired retry deadline, and restart the service. If insert remains rate-limited, FFmpeg must not start and the same pending generation must remain under bounded cooldown. If insert succeeds, exactly one marked unlisted event must be bound before ingest, then transition to live and public after health gates. Local rollback is valid only before a new remote event is staged. Once staging succeeds, the prior version must remain stopped because it cannot understand the new marked generation; no automatic YouTube deletion, completion, or unbinding is part of rollback. ## Verification - `pytest -q`: 102 passed - `ruff check .`: passed - `python -m py_compile` for all runtime scripts: passed - Markdownlint CLI 0.18.1 across repository Markdown: passed - `git diff --check`: passed - Commit is GPG-signed - Two distinct-family architecture reviews completed; the first changed sequencing to pre-ingest staging, and the revised review surfaced no load-bearing blocker - Distinct-family code review was attempted twice but returned no content; hosted CI and PR review remain required before merge
1 parent 9a7f535 commit 174aa03

10 files changed

Lines changed: 908 additions & 76 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
All notable project changes are recorded here. The README shows only the most recent entry.
44

5+
## 2026-07-11 - Pre-Ingest Broadcast Staging
6+
7+
- Reordered every recovery, including cached public state, so one marked broadcast is reconciled, created unlisted if needed, and bound before FFmpeg starts ingest; startup API failures now fail closed.
8+
- 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, while revalidating cached public events and reconciling a replacement when YouTube confirms the cached event is terminal, missing, or rebound.
10+
- Added redacted API operation, HTTP status, and structured reason details to supervisor failures for actionable quota diagnostics.
11+
- Added regression coverage for offline staging, insert and bind partial failures, stable generation reuse, public-stream fallback, and CLI gating.
12+
513
## 2026-07-10 - Idempotent YouTube Lifecycle Recovery
614

715
- Added durable instance and generation markers so one nonterminal broadcast is reconciled and reused across camera, FFmpeg, service, and host restarts.

README.md

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ The project runs FFmpeg under systemd, optionally reuses OBS profile data for ca
1212

1313
YouTube AutoEncoder turns a dedicated Linux device into an unattended YouTube streaming encoder. The core service performs three jobs:
1414

15-
- Validate the camera and FFmpeg media path before creating or transitioning a YouTube broadcast.
15+
- Validate the camera source, stage one marked unlisted broadcast, and require healthy media before lifecycle transitions.
1616
- Push the stream with FFmpeg using low-CPU video copy mode or explicit transcode mode.
1717
- Reconcile one marked YouTube broadcast, stage it unlisted, and publish it only after live health is confirmed.
1818

@@ -78,18 +78,18 @@ OBS compatibility mode can also read and update:
7878
2. The supervisor acquires its single-instance lock and honors any persisted recovery deadline.
7979
3. The source and ingest URLs are resolved from direct settings or OBS compatibility files.
8080
4. FFprobe checks the RTSP source; unavailable sources use bounded source backoff without a broadcast insert.
81-
5. FFmpeg starts, emits machine-readable progress, and pushes to the reusable YouTube stream.
82-
6. One-shot API checks wait for both recent media progress and active YouTube ingest.
83-
7. The helper reconciles local state with exact instance and generation markers, then reuses one nonterminal broadcast or creates one unlisted event when no recoverable event exists.
84-
8. Before and after each `testing` or `live` transition, the supervisor rechecks FFmpeg progress and YouTube ingest.
85-
9. Two consecutive healthy `live` observations are required before visibility changes to `YTA_YOUTUBE_LIVE_PRIVACY`.
86-
10. The helper verifies the privacy readback, clears recovery state, and the supervisor stops nonessential API polling.
87-
11. FFmpeg remains supervised until source loss, process failure, service stop, rotation, or host interruption.
88-
12. Failures persist a class-specific cooldown and restart the same broadcast without completing it.
81+
5. Before ingest starts, the helper lists recoverable events, blocks any unmarked event bound to the reusable stream, and reuses or stages exactly one marked unlisted broadcast.
82+
6. The helper durably stores the broadcast ID and verifies binding before the supervisor starts FFmpeg.
83+
7. FFmpeg emits machine-readable progress and pushes to the reusable YouTube stream.
84+
8. One-shot API checks wait for both recent media progress and active YouTube ingest.
85+
9. Before and after each `testing` or `live` transition, the supervisor rechecks FFmpeg progress and YouTube ingest.
86+
10. Two consecutive healthy `live` observations are required before visibility changes to `YTA_YOUTUBE_LIVE_PRIVACY`.
87+
11. The helper verifies the privacy readback, clears recovery state, and the supervisor stops nonessential API polling.
88+
12. FFmpeg remains supervised until source loss, process failure, service stop, rotation, or host interruption; failures persist a class-specific cooldown and reuse the same broadcast without completing it.
8989

9090
### Recovery Behavior
9191

92-
Every recovery path first preserves ownership and retry state. Media must be fresh and YouTube ingest active before reconciliation can create or transition anything.
92+
Every recovery path first preserves ownership and retry state. After a passing source probe, the YouTube control plane must reconcile ownership and binding before FFmpeg starts, including for cached public state. Fresh media and active YouTube ingest remain mandatory before `testing`, `live`, or public promotion.
9393

9494
See the [recovery state machine](docs/architecture-and-flows.md#recovery-state-machine) for startup, managed-generation, and durable-cooldown transitions.
9595

@@ -99,11 +99,13 @@ See the [recovery state machine](docs/architecture-and-flows.md#recovery-state-m
9999
| Camera loses power during stream | FFmpeg exits; the same broadcast and watch URL remain; source backoff continues until the camera returns. |
100100
| Host reboots | systemd restarts the service, the durable cache is reconciled, and the same nonterminal event resumes. |
101101
| FFmpeg exits or stalls | The child and any in-flight API helper stop; the same event is retained for retry. |
102-
| YouTube ingest does not become active | No insert or transition occurs; the attempt uses source/encoder backoff. |
103-
| YouTube API rate limit or outage | The retry class and deadline persist. A previously verified public stream can continue without control-plane mutation. |
102+
| YouTube ingest does not become active | The single staged unlisted event is retained; no transition or publication occurs. |
103+
| YouTube API rate limit or outage before FFmpeg starts | Startup fails closed, no ingest begins, and the retry class and deadline persist. |
104+
| YouTube API rate limit or outage after validated public ingest is active | The already-running public stream can continue without control-plane mutation. |
105+
| Unmarked event is bound to the reusable stream | Reconciliation fails closed before FFmpeg starts, preventing an unintended legacy auto-start. |
104106
| Ambiguous or unknown remote state | Reconciliation fails closed and creates nothing until the ambiguity is resolved. |
105107
| OAuth access token expires | API helper refreshes from the stored refresh token. |
106-
| Previous broadcast is `complete`, `revoked`, or confirmed missing | One new generation may be created after ingest is active. |
108+
| Previous broadcast is `complete`, `revoked`, or confirmed missing | One new unlisted generation may be staged after the source probe passes. |
107109

108110
Recovery deadlines survive service and host restarts. Exponential backoff uses these class floors and caps:
109111

@@ -408,6 +410,7 @@ youtube-autoencoder-api complete
408410
- YouTube Live must already be enabled on the channel. New or restricted channels may not be allowed to stream immediately.
409411
- The YouTube Data API flow requires OAuth user consent. A simple API key is not enough for creating, binding, or transitioning live broadcasts.
410412
- Google OAuth app restrictions can block authorization if the app is limited to an organization that does not include the streaming account.
413+
- A source that passes its probe and then fails can leave one marked unlisted upcoming event. Recovery reuses that event; it does not create another generation.
411414
- YouTube API quota, API outages, or account policy restrictions can prevent lifecycle operations even when FFmpeg is healthy.
412415
- Recovery reuses one exactly marked nonterminal broadcast. A new generation is permitted only after the previous managed event is `complete`, `revoked`, or confirmed missing.
413416
- Broadcasts created by older releases do not contain ownership markers. They are not adopted or deleted automatically; inventory and clean up legacy duplicates separately after verifying their lifecycle and watch URLs.
@@ -425,9 +428,9 @@ youtube-autoencoder-api complete
425428

426429
Only the most recent changelog entry is shown here. See `CHANGELOG.md` for full history.
427430

428-
### 2026-07-10 - Idempotent YouTube Lifecycle Recovery
431+
### 2026-07-11 - Pre-Ingest Broadcast Staging
429432

430-
- Added exact broadcast reconciliation, FFmpeg progress supervision, persisted backoff, explicit-only completion, and verified unlisted-to-public promotion while preserving one watch URL across failures.
433+
- Reconciles every startup before FFmpeg ingest, stages and binds one marked unlisted event when needed, blocks unmarked bound conflicts, revalidates cached public events after ingest, and logs structured API operation and rate-limit details without exposing credentials.
431434

432435
## Repository Layout
433436

bin/youtube-autoencoder

Lines changed: 104 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -73,21 +73,32 @@ class ApiCommandError(RuntimeError):
7373
self,
7474
*,
7575
returncode: int,
76+
operation: str | None = None,
7677
payload: dict[str, Any] | None = None,
7778
output: str = "",
7879
) -> None:
7980
self.returncode = returncode
81+
self.operation = operation
8082
self.payload = payload or {}
8183
self.http_status = self.payload.get("http_status")
8284
self.reasons = tuple(str(reason) for reason in self.payload.get("reasons") or [])
8385
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
8487
retry_after = self.payload.get("retry_after")
8588
try:
8689
self.retry_after = float(retry_after) if retry_after is not None else None
8790
except (TypeError, ValueError):
8891
self.retry_after = None
8992
message = str(self.payload.get("message") or output or "YouTube API helper failed")
90-
super().__init__(redact_text(message))
93+
details = []
94+
if operation:
95+
details.append(f"operation={operation}")
96+
if self.http_status is not None:
97+
details.append(f"http_status={self.http_status}")
98+
if self.reasons:
99+
details.append(f"reasons={','.join(self.reasons)}")
100+
suffix = f" ({' '.join(details)})" if details else ""
101+
super().__init__(redact_text(message + suffix))
91102

92103

93104
@dataclasses.dataclass
@@ -401,11 +412,17 @@ def json_object_from_output(output: str) -> dict[str, Any] | None:
401412
return None
402413

403414

404-
def parse_api_result(returncode: int, stdout: str, stderr: str) -> dict[str, Any]:
415+
def parse_api_result(
416+
returncode: int,
417+
stdout: str,
418+
stderr: str,
419+
*,
420+
operation: str | None = None,
421+
) -> dict[str, Any]:
405422
if returncode != 0:
406423
payload = json_object_from_output(stderr) or json_object_from_output(stdout)
407424
output = (stdout + "\n" + stderr).strip()
408-
raise ApiCommandError(returncode=returncode, payload=payload, output=output)
425+
raise ApiCommandError(returncode=returncode, operation=operation, payload=payload, output=output)
409426
output = stdout.strip()
410427
if not output:
411428
return {}
@@ -414,23 +431,27 @@ def parse_api_result(returncode: int, stdout: str, stderr: str) -> dict[str, Any
414431
except json.JSONDecodeError as exc:
415432
raise ApiCommandError(
416433
returncode=returncode,
434+
operation=operation,
417435
payload={"message": f"YouTube API helper returned invalid JSON: {exc}", "retry_class": "fatal"},
418436
) from exc
419437
if not isinstance(value, dict):
420438
raise ApiCommandError(
421439
returncode=returncode,
440+
operation=operation,
422441
payload={"message": "YouTube API helper did not return a JSON object", "retry_class": "fatal"},
423442
)
424443
return value
425444

426445

427446
def api_command(args: list[str], *, timeout: int = 90) -> dict[str, Any]:
428447
cmd = [env("YTA_YOUTUBE_API", "youtube-autoencoder-api"), *args]
448+
operation = args[0] if args else None
429449
try:
430450
result = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout, check=False)
431451
except subprocess.TimeoutExpired as exc:
432452
raise ApiCommandError(
433453
returncode=-1,
454+
operation=operation,
434455
payload={
435456
"message": f"YouTube API command timed out after {timeout}s",
436457
"retry_class": "api",
@@ -439,9 +460,10 @@ def api_command(args: list[str], *, timeout: int = 90) -> dict[str, Any]:
439460
except OSError as exc:
440461
raise ApiCommandError(
441462
returncode=-1,
463+
operation=operation,
442464
payload={"message": f"could not start YouTube API helper: {exc}", "retry_class": "fatal"},
443465
) from exc
444-
return parse_api_result(result.returncode, result.stdout, result.stderr)
466+
return parse_api_result(result.returncode, result.stdout, result.stderr, operation=operation)
445467

446468

447469
def ffmpeg_args(
@@ -582,6 +604,7 @@ def api_command_while_streaming(
582604
timeout: int,
583605
) -> dict[str, Any]:
584606
cmd = [env("YTA_YOUTUBE_API", "youtube-autoencoder-api"), *args]
607+
operation = args[0] if args else None
585608
with (
586609
tempfile.TemporaryFile(mode="w+t", encoding="utf-8") as stdout_file,
587610
tempfile.TemporaryFile(mode="w+t", encoding="utf-8") as stderr_file,
@@ -596,6 +619,7 @@ def api_command_while_streaming(
596619
except OSError as exc:
597620
raise ApiCommandError(
598621
returncode=-1,
622+
operation=operation,
599623
payload={"message": f"could not start YouTube API helper: {exc}", "retry_class": "fatal"},
600624
) from exc
601625
deadline = time.monotonic() + timeout
@@ -607,6 +631,7 @@ def api_command_while_streaming(
607631
if time.monotonic() >= deadline:
608632
raise ApiCommandError(
609633
returncode=-1,
634+
operation=operation,
610635
payload={
611636
"message": f"YouTube API command timed out after {timeout}s",
612637
"retry_class": "api",
@@ -622,7 +647,7 @@ def api_command_while_streaming(
622647
except BaseException:
623648
stop_process(api_process)
624649
raise
625-
return parse_api_result(api_process.returncode, stdout, stderr)
650+
return parse_api_result(api_process.returncode, stdout, stderr, operation=operation)
626651

627652

628653
def poll_interval() -> float:
@@ -662,6 +687,17 @@ def current_observation(runtime: StreamRuntime, state: dict[str, Any]) -> dict[s
662687
ensure_encoder_running(runtime)
663688
stream = api_command_while_streaming(["stream-status", stream_id], runtime, timeout=45)
664689
broadcast = api_command_while_streaming(["broadcast-status", broadcast_id], runtime, timeout=45)
690+
bound_stream_id = str(broadcast.get("bound_stream_id") or "")
691+
if bound_stream_id != stream_id:
692+
raise ApiCommandError(
693+
returncode=78,
694+
operation="broadcast-status",
695+
payload={
696+
"message": f"managed broadcast is bound to a different stream: {bound_stream_id or '<empty>'}",
697+
"retry_class": "ambiguous",
698+
"public_fallback_allowed": False,
699+
},
700+
)
665701
ensure_encoder_running(runtime)
666702
return {
667703
"stream_status": stream.get("stream_status"),
@@ -686,7 +722,7 @@ def wait_for_active_stream(runtime: StreamRuntime) -> dict[str, Any]:
686722
wait_with_runtime(runtime, poll_interval())
687723

688724

689-
def reconcile_command_args() -> list[str]:
725+
def reconcile_command_args(*, offline_create: bool = False) -> list[str]:
690726
args = [
691727
"reconcile-broadcast",
692728
"--allow-create",
@@ -698,6 +734,8 @@ def reconcile_command_args() -> list[str]:
698734
title = os.environ.get("YTA_YOUTUBE_TITLE")
699735
if title:
700736
args.extend(["--title", title])
737+
if offline_create:
738+
args.append("--offline-create")
701739
return args
702740

703741

@@ -789,9 +827,17 @@ def publish_when_healthy(runtime: StreamRuntime, initial_state: dict[str, Any])
789827
raise EncoderStopped("YouTube stream did not remain healthy long enough for publication")
790828

791829

792-
def manage_youtube_lifecycle(runtime: StreamRuntime) -> dict[str, Any]:
830+
def manage_youtube_lifecycle(
831+
runtime: StreamRuntime,
832+
prepared_state: dict[str, Any] | None = None,
833+
) -> dict[str, Any]:
793834
active_stream = wait_for_active_stream(runtime)
794-
state = api_command_while_streaming(reconcile_command_args(), runtime, timeout=120)
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)
795841
stream_id = str(state.get("stream_id") or state.get("last_stream_id") or "")
796842
broadcast_id = str(state.get("broadcast_id") or state.get("last_broadcast_id") or "")
797843
if not stream_id or not broadcast_id:
@@ -820,11 +866,50 @@ def verified_public_state(state: dict[str, Any]) -> bool:
820866
return lifecycle == "live" and privacy == "public" and bool(stream_id and broadcast_id)
821867

822868

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+
823908
def manage_with_public_fallback(runtime: StreamRuntime, cached_state: dict[str, Any]) -> dict[str, Any]:
824909
try:
825-
return manage_youtube_lifecycle(runtime)
910+
return manage_youtube_lifecycle(runtime, prepared_state=cached_state)
826911
except ApiCommandError as error:
827-
if not verified_public_state(cached_state):
912+
if not verified_public_state(cached_state) or not error.public_fallback_allowed:
828913
raise
829914
ensure_encoder_running(runtime)
830915
retry_class = classify_api_error(error)
@@ -912,6 +997,12 @@ def read_recovery_state() -> dict[str, Any]:
912997
return api_command(["state"], timeout=30)
913998

914999

1000+
def prepare_youtube_lifecycle(cached_state: dict[str, Any]) -> dict[str, Any]:
1001+
if not lifecycle_enabled():
1002+
return dict(cached_state)
1003+
return api_command(reconcile_command_args(offline_create=True), timeout=120)
1004+
1005+
9151006
def supervise_stream(runtime: StreamRuntime, *, started_at: float, max_runtime: float) -> int:
9161007
while not stopping:
9171008
drain_ffmpeg_output(runtime, timeout=1.0)
@@ -939,6 +1030,8 @@ def run_once(cached_state: dict[str, Any] | None = None) -> int:
9391030
if not source_available(input_url):
9401031
raise SourceUnavailable("camera source probe failed")
9411032

1033+
prepared_state = prepare_youtube_lifecycle(cached_state or {})
1034+
9421035
log(f"starting ffmpeg mode={env('YTA_MODE', 'copy')}")
9431036
started = time.monotonic()
9441037
child = subprocess.Popen(
@@ -963,7 +1056,7 @@ def run_once(cached_state: dict[str, Any] | None = None) -> int:
9631056
if bool_env("YTA_YOUTUBE_COMPLETE_ON_EXIT", False):
9641057
log("YTA_YOUTUBE_COMPLETE_ON_EXIT is deprecated and ignored; use the explicit complete command")
9651058
if lifecycle_enabled():
966-
manage_with_public_fallback(runtime, cached_state or {})
1059+
manage_with_public_fallback(runtime, prepared_state)
9671060
return supervise_stream(runtime, started_at=started, max_runtime=max_runtime)
9681061
finally:
9691062
if child and child.poll() is None:

0 commit comments

Comments
 (0)