Skip to content

Commit 4f11eaf

Browse files
feat(youtube): discover scheduled live broadcasts
1 parent af40171 commit 4f11eaf

4 files changed

Lines changed: 135 additions & 2 deletions

File tree

‎plugins/google/README.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,30 @@ completes consent.
99
See the [shared connector procedures](../../docs/connectors/README.md) for OAuth credential
1010
rotation, CLI operations, and the general process for changing scopes.
1111

12+
## Discover scheduled YouTube broadcasts
13+
14+
`videos` lists recent uploads, which does not establish whether a scheduled
15+
stream exists. Use the existing authorized YouTube connection and the shared
16+
discovery API/console resource instead:
17+
18+
```bash
19+
dapier connections show youtube datatalks
20+
dapier connections discover youtube:datatalks broadcasts
21+
dapier connections discover youtube:datatalks broadcasts --param status=all
22+
dapier connections discover youtube:datatalks broadcasts --param page_token=NEXT_TOKEN
23+
```
24+
25+
The default is `status=upcoming`; `active`, `completed` and `all` are also
26+
supported. Verify `channel_id`, title and `scheduled_start` against the owning
27+
event before changing anything. Results include the existing description and
28+
privacy/lifecycle state. If the final row has `next_page_token`, continue with
29+
that token and the same status before concluding a target is absent.
30+
31+
This resource uses Google's [liveBroadcasts.list](https://developers.google.com/youtube/data/live/reference/rest/v3/liveBroadcasts/list)
32+
and works with `youtube.readonly`. It creates or changes no broadcast. A read
33+
grant does not authorize metadata or thumbnail writes; do not broaden an OAuth
34+
grant to bypass a missing write permission.
35+
1236
## Current configuration
1337

1438
- Google Cloud project: **`dtcdev-click`**.

‎plugins/google/connector/youtube.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,23 @@ def _run_videos(connection, params, *, transport=None):
299299
))
300300

301301

302+
def _run_broadcasts(connection, params, *, transport=None):
303+
return provider.discover(connection, "broadcasts", params, transport=transport)
304+
305+
306+
register_discovery(Discovery(
307+
name="broadcasts",
308+
connector="youtube",
309+
label="Live broadcasts",
310+
description="Owned live broadcasts (default: upcoming), including scheduled streams",
311+
params=({"key": "status", "label": "Status (upcoming, active, completed, all)",
312+
"type": "text", "required": False},
313+
{"key": "page_token", "label": "Next page token", "type": "text",
314+
"required": False}),
315+
run=_run_broadcasts,
316+
))
317+
318+
302319
def _run_test(connection, *, transport=None):
303320
return provider.test_connection(connection, transport=transport)
304321

‎src/dapier/connections/discovery.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,45 @@ def _youtube_videos(connection, token, params, limit, *, transport=None):
434434
limit, transport=transport)
435435

436436

437+
def _youtube_broadcasts(connection, token, params, limit, *, transport=None):
438+
"""Read scheduled streams separately from uploaded videos.
439+
440+
The status filter scopes liveBroadcasts.list to the authenticated channel;
441+
it must not be combined with the mutually exclusive mine filter.
442+
"""
443+
status = params.get("status", "upcoming")
444+
if status not in {"upcoming", "active", "completed", "all"}:
445+
raise DiscoveryError("broadcasts status must be upcoming, active, completed or all")
446+
query = {"part": "snippet,status", "broadcastStatus": status,
447+
"broadcastType": "all", "maxResults": min(limit, 50)}
448+
if params.get("page_token"):
449+
query["pageToken"] = params["page_token"]
450+
url = f"{YOUTUBE_API_URL}/liveBroadcasts?" + urllib.parse.urlencode(query)
451+
data = _request("GET", url, token, None, transport=transport)
452+
items = []
453+
for entry in data.get("items") or []:
454+
if not isinstance(entry, dict) or not entry.get("id"):
455+
continue
456+
snippet = entry.get("snippet") or {}
457+
state = entry.get("status") or {}
458+
items.append({
459+
"id": entry["id"], "name": snippet.get("title") or entry["id"],
460+
"channel_id": snippet.get("channelId"),
461+
"scheduled_start": snippet.get("scheduledStartTime"),
462+
"scheduled_end": snippet.get("scheduledEndTime"),
463+
"lifecycle_status": state.get("lifeCycleStatus"),
464+
"privacy_status": state.get("privacyStatus"),
465+
"description": snippet.get("description", ""),
466+
"url": f"https://www.youtube.com/watch?v={entry['id']}",
467+
})
468+
if data.get("nextPageToken"):
469+
if not items:
470+
raise DiscoveryError("YouTube returned an empty broadcast page with a continuation token",
471+
status=502)
472+
items[-1]["next_page_token"] = data["nextPageToken"]
473+
return items
474+
475+
437476
# --- Dropbox ---
438477

439478

@@ -758,6 +797,10 @@ def _zoom_recordings(connection, token, params, limit, *, transport=None):
758797
(Param("playlist_id", True, "Playlist ID from the playlists list"),)),
759798
Resource("videos", "Channel videos", "The channel's recent uploads, newest first",
760799
_youtube_videos),
800+
Resource("broadcasts", "Live broadcasts", "Owned live broadcasts (default: upcoming)",
801+
_youtube_broadcasts,
802+
(Param("status", False, "upcoming (default), active, completed or all"),
803+
Param("page_token", False, "next_page_token from the last result row"))),
761804
],
762805
"dropbox": [
763806
Resource("folder", "Folder entries",

‎tests/test_discovery_youtube.py‎

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def live_token(monkeypatch):
4545
def test_youtube_catalog_lists_channel_playlists_playlist_items_and_videos():
4646
resources = discovery.resources_for("youtube")
4747
assert [resource.name for resource in resources] == \
48-
["channel", "playlists", "playlist_items", "videos"]
48+
["channel", "playlists", "playlist_items", "videos", "broadcasts"]
4949
playlist_items = resources[2]
5050
assert [(param.name, param.required) for param in playlist_items.params] == \
5151
[("playlist_id", True)]
@@ -166,7 +166,56 @@ def fake_discover(connection, resource, params, **kwargs):
166166
assert sorted(seen) == sorted([("yt-main", "channel", {"a": "b"}),
167167
("yt-main", "playlists", {"a": "b"}),
168168
("yt-main", "playlist_items", {"a": "b"}),
169-
("yt-main", "videos", {"a": "b"})])
169+
("yt-main", "videos", {"a": "b"}),
170+
("yt-main", "broadcasts", {"a": "b"})])
171+
172+
173+
def test_broadcasts_include_upcoming_details_and_continuation():
174+
from urllib.parse import parse_qs, urlparse
175+
176+
transport = FakeTransport(("liveBroadcasts?", 200, {
177+
"items": [{"id": "scheduled-1", "snippet": {
178+
"title": "A future Q&A", "channelId": "UCfixture",
179+
"description": "Existing speaker details",
180+
"scheduledStartTime": "2027-03-02T16:00:00Z",
181+
}, "status": {"lifeCycleStatus": "ready", "privacyStatus": "unlisted"}}],
182+
"nextPageToken": "page-two",
183+
}))
184+
items = discovery.discover(CONNECTION, "broadcasts", {}, transport=transport)
185+
assert items[0]["id"] == "scheduled-1"
186+
assert items[0]["channel_id"] == "UCfixture"
187+
assert items[0]["scheduled_start"] == "2027-03-02T16:00:00Z"
188+
assert items[0]["description"] == "Existing speaker details"
189+
assert items[0]["privacy_status"] == "unlisted"
190+
assert items[0]["next_page_token"] == "page-two"
191+
query = parse_qs(urlparse(transport.calls[0]["url"]).query)
192+
assert query["broadcastStatus"] == ["upcoming"]
193+
assert query["broadcastType"] == ["all"]
194+
assert "mine" not in query
195+
196+
197+
def test_broadcasts_follow_explicit_page_and_preserve_empty_result():
198+
transport = FakeTransport(("pageToken=page-two", 200, {"items": []}))
199+
assert discovery.discover(CONNECTION, "broadcasts", {
200+
"status": "all", "page_token": "page-two", "limit": 100,
201+
}, transport=transport) == []
202+
assert "maxResults=50" in transport.calls[0]["url"]
203+
204+
205+
def test_broadcasts_reject_invalid_status():
206+
transport = FakeTransport()
207+
with pytest.raises(discovery.DiscoveryError, match="status must"):
208+
discovery.discover(CONNECTION, "broadcasts", {"status": "draft"},
209+
transport=transport)
210+
assert not transport.calls
211+
212+
213+
def test_empty_incomplete_broadcast_page_is_not_reported_as_absent():
214+
transport = FakeTransport(("liveBroadcasts?", 200, {
215+
"items": [], "nextPageToken": "more",
216+
}))
217+
with pytest.raises(discovery.DiscoveryError, match="empty broadcast page"):
218+
discovery.discover(CONNECTION, "broadcasts", {}, transport=transport)
170219

171220

172221
def test_youtube_connection_test_reports_the_channel_identity(monkeypatch):

0 commit comments

Comments
 (0)