Skip to content

Commit 2fdc14b

Browse files
authored
Add opt-in quota-bounded video telemetry (#12)
Add a disabled-by-default videos.list telemetry collector with strict eligibility checks, write-ahead quota throttling, private local storage, bounded retention, independent system/user timers, CI coverage, and operator documentation.
1 parent 174aa03 commit 2fdc14b

16 files changed

Lines changed: 2460 additions & 37 deletions

.github/workflows/ci.yml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ jobs:
4141
run: ruff check .
4242

4343
- name: Compile scripts
44-
run: python -m py_compile bin/youtube-autoencoder bin/youtube-autoencoder-api bin/youtube-autoencoder-test-pattern
44+
run: python -m py_compile bin/youtube-autoencoder bin/youtube-autoencoder-api bin/youtube-autoencoder-test-pattern bin/youtube-autoencoder-telemetry
4545

4646
- name: Run tests
4747
run: pytest -q
@@ -51,11 +51,19 @@ jobs:
5151
test -x bin/youtube-autoencoder
5252
test -x bin/youtube-autoencoder-api
5353
test -x bin/youtube-autoencoder-test-pattern
54+
test -x bin/youtube-autoencoder-telemetry
5455
5556
- name: Verify systemd units
5657
run: |
5758
sudo install -m 0755 bin/youtube-autoencoder /usr/local/bin/youtube-autoencoder
58-
systemd-analyze verify systemd/youtube-autoencoder@.service systemd/user/youtube-autoencoder.service
59+
sudo install -m 0755 bin/youtube-autoencoder-telemetry /usr/local/bin/youtube-autoencoder-telemetry
60+
systemd-analyze verify \
61+
systemd/youtube-autoencoder@.service \
62+
systemd/youtube-autoencoder-telemetry@.service \
63+
systemd/youtube-autoencoder-telemetry@.timer \
64+
systemd/user/youtube-autoencoder.service \
65+
systemd/user/youtube-autoencoder-telemetry.service \
66+
systemd/user/youtube-autoencoder-telemetry.timer
5967
6068
repository-metadata:
6169
name: Repository Metadata

CHANGELOG.md

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

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

5+
## 2026-07-11 - Optional Video Telemetry
6+
7+
- Added an opt-in `videos.list` command and one-shot collector for aggregate live-stream timing, viewer, view, like, and comment metrics.
8+
- Added private local latest and daily JSONL samples, strict payload validation, a nonblocking lock, write-ahead quota throttling, and configurable retention.
9+
- Added independent system and user five-minute timers that remain disabled after installation; one selected timer consumes at most 288 quota units per 24 hours at the minimum interval and never controls stream recovery.
10+
511
## 2026-07-11 - Pre-Ingest Broadcast Staging
612

713
- 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.

README.md

Lines changed: 121 additions & 26 deletions
Large diffs are not rendered by default.

bin/youtube-autoencoder-api

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ OBS_PROFILE_DIR = pathlib.Path(
3939
OBS_SERVICE_FILE = pathlib.Path(os.environ.get("YTA_OBS_SERVICE_FILE", OBS_PROFILE_DIR / "service.json")).expanduser()
4040
SCOPES = "https://www.googleapis.com/auth/youtube"
4141
API_BASE = "https://www.googleapis.com/youtube/v3"
42+
VIDEO_METRICS_FIELDS = (
43+
"items(id,liveStreamingDetails(actualStartTime,actualEndTime,scheduledStartTime,"
44+
"scheduledEndTime,concurrentViewers),statistics(viewCount,likeCount,commentCount))"
45+
)
4246

4347
child: subprocess.Popen[Any] | None = None
4448
stopping = False
@@ -154,6 +158,12 @@ def read_state() -> dict[str, Any]:
154158
return {}
155159

156160

161+
def read_state_snapshot() -> dict[str, Any]:
162+
if not STATE_FILE.exists():
163+
return {}
164+
return read_json(STATE_FILE)
165+
166+
157167
@contextlib.contextmanager
158168
def mutation_lock(timeout: float | None = None):
159169
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
@@ -357,6 +367,57 @@ def api(method: str, path: str, params: dict[str, str], body: dict[str, Any] | N
357367
return http_json(method, f"{API_BASE}{path}?{query}", token=token(), body=body)
358368

359369

370+
def metric_int(value: Any, field: str) -> int | None:
371+
if value is None or value == "":
372+
return None
373+
if isinstance(value, bool) or not isinstance(value, int | str):
374+
raise ReconciliationError(f"invalid {field} metric")
375+
try:
376+
parsed = int(value)
377+
except (TypeError, ValueError) as exc:
378+
raise ReconciliationError(f"invalid {field} metric") from exc
379+
if parsed < 0:
380+
raise ReconciliationError(f"invalid {field} metric")
381+
return parsed
382+
383+
384+
def video_metrics(video_id: str) -> dict[str, Any]:
385+
data = api(
386+
"GET",
387+
"/videos",
388+
{
389+
"id": video_id,
390+
"part": "liveStreamingDetails,statistics",
391+
"fields": VIDEO_METRICS_FIELDS,
392+
},
393+
)
394+
items = data.get("items") or []
395+
if len(items) != 1 or not isinstance(items[0], dict):
396+
raise ReconciliationError(f"video not found: {video_id}")
397+
item = items[0]
398+
if str(item.get("id") or "") != video_id:
399+
raise ReconciliationError("videos.list returned an unexpected video id")
400+
live = item.get("liveStreamingDetails")
401+
statistics = item.get("statistics")
402+
if (live is not None and not isinstance(live, dict)) or (
403+
statistics is not None and not isinstance(statistics, dict)
404+
):
405+
raise ReconciliationError("videos.list returned malformed metrics")
406+
live = live or {}
407+
statistics = statistics or {}
408+
return {
409+
"video_id": video_id,
410+
"actual_start_time": live.get("actualStartTime"),
411+
"actual_end_time": live.get("actualEndTime"),
412+
"scheduled_start_time": live.get("scheduledStartTime"),
413+
"scheduled_end_time": live.get("scheduledEndTime"),
414+
"concurrent_viewers": metric_int(live.get("concurrentViewers"), "concurrentViewers"),
415+
"view_count": metric_int(statistics.get("viewCount"), "viewCount"),
416+
"like_count": metric_int(statistics.get("likeCount"), "likeCount"),
417+
"comment_count": metric_int(statistics.get("commentCount"), "commentCount"),
418+
}
419+
420+
360421
def obs_stream_name() -> str:
361422
settings = read_json(OBS_SERVICE_FILE).get("settings") or {}
362423
stream_name = str(settings.get("key") or "")
@@ -968,6 +1029,17 @@ def state_command(_args: argparse.Namespace) -> int:
9681029
return 0
9691030

9701031

1032+
def video_metrics_command(args: argparse.Namespace) -> int:
1033+
video_id = args.video_id
1034+
if not video_id:
1035+
state = read_state_snapshot()
1036+
video_id = str(state.get("broadcast_id") or state.get("last_broadcast_id") or "")
1037+
if not video_id:
1038+
raise ValueError("no broadcast id provided and no previous state file found")
1039+
print(json.dumps(video_metrics(video_id), indent=2, sort_keys=True))
1040+
return 0
1041+
1042+
9711043
def reconcile_command(args: argparse.Namespace) -> int:
9721044
stream = ensure_stream(create_if_missing=args.create_stream)
9731045
stream_id = str(stream["id"])
@@ -1151,6 +1223,10 @@ def main() -> int:
11511223
state_p = sub.add_parser("state", help="Show non-secret persisted lifecycle state")
11521224
state_p.set_defaults(func=state_command)
11531225

1226+
video_metrics_p = sub.add_parser("video-metrics", help="Show read-only aggregate metrics for a live video")
1227+
video_metrics_p.add_argument("video_id", nargs="?")
1228+
video_metrics_p.set_defaults(func=video_metrics_command)
1229+
11541230
stream_status_p = sub.add_parser("stream-status", help="Show reusable liveStream ingest status")
11551231
stream_status_p.add_argument("stream_id", nargs="?")
11561232
stream_status_p.set_defaults(func=stream_status_command)

0 commit comments

Comments
 (0)