Skip to main content

omni_dev/transcript/sources/youtube/
channel.rs

1//! Channel video enumeration — the one capability the per-video fetcher
2//! lacks. Two no-auth strategies, both reusing the existing HTTP plumbing:
3//!
4//! - **RSS** ([`fetch_recent_videos`]): GET `feeds/videos.xml?channel_id=…`,
5//!   regex out the ~15 newest `<yt:videoId>`s plus their `<published>` dates.
6//!   Trivial and robust; ideal for incremental "keep up to date" syncs.
7//! - **InnerTube `/browse`** ([`fetch_all_video_ids`]): page through the
8//!   channel's *Videos* tab via continuation tokens for the full upload
9//!   history. Reuses [`super::innertube::fetch_browse`] (same host, same
10//!   client context as `/player`). This depends on YouTube's internal JSON
11//!   shape — the same accepted fragility the `/player` parsing already lives
12//!   with.
13//!
14//! Both return newest-first, which lets a sync stop paging once it hits a
15//! contiguous run of already-synced IDs.
16//!
17//! Channel locators (`@handle`, `/c/Name`, `/user/Name`, channel URLs) are
18//! resolved to a canonical `UC…` ID by [`resolve_channel_id`] — a raw `UC…`
19//! and `/channel/UC…` URLs short-circuit without any HTTP; everything else is
20//! scraped from the channel page (same watch-page-bootstrap pattern as
21//! [`super::watch_page`]).
22
23use std::collections::HashSet;
24use std::sync::OnceLock;
25
26use chrono::{DateTime, Utc};
27use regex::Regex;
28use serde_json::{json, Value};
29use url::Url;
30
31use crate::transcript::error::{Result, TranscriptError};
32
33use super::innertube::{fetch_browse, web_client_context};
34use super::watch_page::BROWSER_USER_AGENT;
35
36/// Length of a `UC…` channel ID (the `UC` prefix plus 22 base64-ish chars).
37const CHANNEL_ID_LEN: usize = 24;
38
39/// Opaque, version-stable `params` selecting a channel's *Videos* tab on the
40/// InnerTube `/browse` endpoint. A base64-encoded protobuf; treated as a
41/// constant token (the same way the `/player` request shape is pinned).
42const VIDEOS_TAB_PARAMS: &str = "EgZ2aWRlb3PyBgQKAjoA";
43
44/// A single video enumerated from a channel, newest-first.
45///
46/// `published` is present for the RSS path (which carries `<published>` per
47/// entry) and absent for the browse path (the upload grid has no per-item
48/// dates).
49#[derive(Clone, Debug, PartialEq, Eq)]
50pub struct VideoEntry {
51    /// 11-character YouTube video ID.
52    pub id: String,
53    /// Publish timestamp, when the enumeration source provides one.
54    pub published: Option<DateTime<Utc>>,
55}
56
57/// Resolve a channel locator to its canonical `UC…` ID.
58///
59/// Short-circuits (no HTTP) for a bare `UC…` ID and for `/channel/UC…` URLs.
60/// For `@handle`, `/c/Name`, `/user/Name`, a bare handle/name, or any other
61/// channel URL, GETs the page with a browser UA and scrapes the `channelId`
62/// token out of the HTML. Returns [`TranscriptError::ChannelNotFound`] when
63/// the input is unusable or the token is absent.
64pub async fn resolve_channel_id(
65    http: &reqwest::Client,
66    base_url: &str,
67    input: &str,
68) -> Result<String> {
69    if is_channel_id(input) {
70        return Ok(input.to_string());
71    }
72
73    // Decide which page to scrape. A `/channel/UC…` URL is resolved inline.
74    let page_url = match Url::parse(input) {
75        Ok(url) => {
76            if let Some(id) = channel_id_from_path(url.path()) {
77                return Ok(id);
78            }
79            input.to_string()
80        }
81        Err(_) => format!(
82            "{base}/{path}",
83            base = base_url.trim_end_matches('/'),
84            path = input.trim_start_matches('/'),
85        ),
86    };
87
88    let body = http
89        .get(&page_url)
90        .header(reqwest::header::USER_AGENT, BROWSER_USER_AGENT)
91        .send()
92        .await?
93        .error_for_status()?
94        .text()
95        .await?;
96
97    extract_channel_id(&body)
98        .map(str::to_string)
99        .ok_or_else(|| TranscriptError::ChannelNotFound {
100            input: input.to_string(),
101        })
102}
103
104/// RSS enumeration: the ~15 most recent uploads, newest-first. One GET, no
105/// continuation logic. Ideal for incremental syncs.
106pub async fn fetch_recent_videos(
107    http: &reqwest::Client,
108    base_url: &str,
109    channel_id: &str,
110) -> Result<Vec<VideoEntry>> {
111    let url = format!(
112        "{base}/feeds/videos.xml?channel_id={id}",
113        base = base_url.trim_end_matches('/'),
114        id = channel_id,
115    );
116    let body = http
117        .get(&url)
118        .send()
119        .await?
120        .error_for_status()?
121        .text()
122        .await?;
123    Ok(parse_rss(&body))
124}
125
126/// Browse enumeration: the full upload history, newest-first.
127///
128/// Pages the *Videos* tab through continuation tokens using the WEB client
129/// (see [`super::innertube::fetch_browse`]). No `visitorData` is needed —
130/// browse is a public, non-bot-gated endpoint.
131pub async fn fetch_all_video_ids(
132    http: &reqwest::Client,
133    base_url: &str,
134    channel_id: &str,
135) -> Result<Vec<String>> {
136    let mut ids: Vec<String> = Vec::new();
137    let mut seen: HashSet<String> = HashSet::new();
138    let mut seen_tokens: HashSet<String> = HashSet::new();
139
140    // First page keys off `browseId` + the Videos-tab `params`; subsequent
141    // pages key off the continuation token returned by the previous page.
142    let mut body = json!({
143        "context": web_client_context(),
144        "browseId": channel_id,
145        "params": VIDEOS_TAB_PARAMS,
146    });
147
148    loop {
149        let raw = fetch_browse(http, base_url, &body).await?;
150        let value: Value = serde_json::from_str(&raw).map_err(|e| {
151            TranscriptError::ParseError(format!("browse response was not valid JSON: {e}"))
152        })?;
153        let (page_ids, token) = parse_browse_page(&value);
154
155        let mut added_any = false;
156        for id in page_ids {
157            if seen.insert(id.clone()) {
158                ids.push(id);
159                added_any = true;
160            }
161        }
162
163        // Stop when the page advances nothing new or the token loops/ends —
164        // each guard alone prevents a runaway pagination loop.
165        match token {
166            Some(t) if added_any && seen_tokens.insert(t.clone()) => {
167                body = json!({
168                    "context": web_client_context(),
169                    "continuation": t,
170                });
171            }
172            _ => break,
173        }
174    }
175
176    Ok(ids)
177}
178
179/// Whether `s` is a bare canonical channel ID (`UC` + 22 ID chars).
180fn is_channel_id(s: &str) -> bool {
181    s.len() == CHANNEL_ID_LEN
182        && s.starts_with("UC")
183        && s.bytes()
184            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
185}
186
187/// Pull a `UC…` ID out of a URL path like `/channel/UC…/videos`.
188fn channel_id_from_path(path: &str) -> Option<String> {
189    let rest = path.trim_start_matches('/').strip_prefix("channel/")?;
190    let candidate = rest.split('/').next().unwrap_or(rest);
191    is_channel_id(candidate).then(|| candidate.to_string())
192}
193
194/// Lazy-compiled `channelId` / `externalId` extractor. The token is a JSON
195/// string value in an inline script block; anchored on the exact keys so it
196/// doesn't match neighbouring `*Id` fields. Same regex-over-HTML approach as
197/// [`super::watch_page`]'s `visitorData` scrape.
198fn channel_id_regex() -> &'static Regex {
199    static RE: OnceLock<Regex> = OnceLock::new();
200    #[allow(clippy::expect_used)]
201    RE.get_or_init(|| {
202        Regex::new(r#""(?:channelId|externalId)":"(UC[0-9A-Za-z_-]{22})""#)
203            .expect("channel_id regex must compile")
204    })
205}
206
207/// Pure helper: pull the `UC…` channel ID out of a channel-page body.
208fn extract_channel_id(body: &str) -> Option<&str> {
209    channel_id_regex()
210        .captures(body)
211        .and_then(|c| c.get(1))
212        .map(|m| m.as_str())
213}
214
215/// Lazy-compiled extractor for a single RSS `<entry>` block's video ID.
216fn rss_video_id_regex() -> &'static Regex {
217    static RE: OnceLock<Regex> = OnceLock::new();
218    #[allow(clippy::expect_used)]
219    RE.get_or_init(|| {
220        Regex::new(r"<yt:videoId>([0-9A-Za-z_-]{11})</yt:videoId>")
221            .expect("rss video id regex must compile")
222    })
223}
224
225/// Lazy-compiled extractor for a single RSS `<entry>` block's publish date.
226fn rss_published_regex() -> &'static Regex {
227    static RE: OnceLock<Regex> = OnceLock::new();
228    #[allow(clippy::expect_used)]
229    RE.get_or_init(|| {
230        Regex::new(r"<published>([^<]+)</published>").expect("rss published regex must compile")
231    })
232}
233
234/// Lazy-compiled splitter for RSS `<entry>…</entry>` blocks.
235fn rss_entry_regex() -> &'static Regex {
236    static RE: OnceLock<Regex> = OnceLock::new();
237    #[allow(clippy::expect_used)]
238    RE.get_or_init(|| {
239        Regex::new(r"(?s)<entry>(.*?)</entry>").expect("rss entry regex must compile")
240    })
241}
242
243/// Pure helper: parse a YouTube channel RSS feed into newest-first entries.
244/// Entries without a parseable video ID are skipped; an unparseable
245/// `<published>` degrades to `None` rather than dropping the entry.
246fn parse_rss(xml: &str) -> Vec<VideoEntry> {
247    rss_entry_regex()
248        .captures_iter(xml)
249        .filter_map(|entry| {
250            let block = entry.get(1)?.as_str();
251            let id = rss_video_id_regex()
252                .captures(block)
253                .and_then(|c| c.get(1))?
254                .as_str()
255                .to_string();
256            let published = rss_published_regex()
257                .captures(block)
258                .and_then(|c| c.get(1))
259                .and_then(|m| DateTime::parse_from_rfc3339(m.as_str()).ok())
260                .map(|dt| dt.with_timezone(&Utc));
261            Some(VideoEntry { id, published })
262        })
263        .collect()
264}
265
266/// Pure helper: extract `(video_ids, next_continuation_token)` from one
267/// `/browse` response page.
268///
269/// IDs are collected by a tolerant tree-walk in document order (newest-first
270/// for the Videos tab). Two item shapes are recognised:
271///
272/// - `lockupViewModel` with `contentType == "LOCKUP_CONTENT_TYPE_VIDEO"` →
273///   `contentId` (the current WEB grid shape).
274/// - `videoRenderer.videoId` (the legacy shape, still emitted on some tabs).
275///
276/// The continuation token is taken **only** from the array that also holds the
277/// video items — the grid's "load more". A response carries other
278/// `continuationItemRenderer`s too (e.g. one in the channel header) that page
279/// unrelated content; scoping to the video array avoids paging the wrong list
280/// (which would stop enumeration after the first ~30 uploads).
281///
282/// The `contentType` guard avoids playlist / channel lockups, and keying only
283/// off these wrappers avoids the duplicate IDs that appear under
284/// `watchEndpoint` / `addToPlaylist` link targets.
285fn parse_browse_page(value: &Value) -> (Vec<String>, Option<String>) {
286    let mut ids = Vec::new();
287    let mut seen = HashSet::new();
288    collect_video_ids(value, &mut ids, &mut seen);
289    let token = find_grid_continuation(value);
290    (ids, token)
291}
292
293fn collect_video_ids(value: &Value, ids: &mut Vec<String>, seen: &mut HashSet<String>) {
294    match value {
295        Value::Object(map) => {
296            if let Some(id) = video_id_from_item(map) {
297                if seen.insert(id.to_string()) {
298                    ids.push(id.to_string());
299                }
300            }
301            for child in map.values() {
302                collect_video_ids(child, ids, seen);
303            }
304        }
305        Value::Array(arr) => {
306            for child in arr {
307                collect_video_ids(child, ids, seen);
308            }
309        }
310        _ => {}
311    }
312}
313
314/// Find the continuation token belonging to the *video grid*: the
315/// `continuationItemRenderer` token that sits in the same array as the video
316/// items. Returns `None` once the grid has no further pages.
317fn find_grid_continuation(value: &Value) -> Option<String> {
318    match value {
319        Value::Array(arr) => {
320            // The video items in this array are wrapped (e.g. in
321            // `richItemRenderer`), so test the subtree, not the item directly.
322            let has_video = arr.iter().any(contains_video);
323            if has_video {
324                if let Some(token) = arr
325                    .iter()
326                    .filter_map(Value::as_object)
327                    .find_map(continuation_token_from_item)
328                {
329                    return Some(token);
330                }
331            }
332            arr.iter().find_map(find_grid_continuation)
333        }
334        Value::Object(map) => map.values().find_map(find_grid_continuation),
335        _ => None,
336    }
337}
338
339/// Whether `value`'s subtree contains at least one video item.
340fn contains_video(value: &Value) -> bool {
341    match value {
342        Value::Object(map) => video_id_from_item(map).is_some() || map.values().any(contains_video),
343        Value::Array(arr) => arr.iter().any(contains_video),
344        _ => false,
345    }
346}
347
348/// Pull a video ID out of a single item renderer if `map` is one. Recognises
349/// the current `lockupViewModel` shape and the legacy `videoRenderer` shape;
350/// returns `None` for any other object.
351fn video_id_from_item(map: &serde_json::Map<String, Value>) -> Option<&str> {
352    if let Some(lockup) = map.get("lockupViewModel") {
353        if lockup.get("contentType").and_then(Value::as_str) == Some("LOCKUP_CONTENT_TYPE_VIDEO") {
354            return lockup.get("contentId").and_then(Value::as_str);
355        }
356    }
357    map.get("videoRenderer")
358        .and_then(|vr| vr.get("videoId"))
359        .and_then(Value::as_str)
360}
361
362/// Pull a continuation token out of a `continuationItemRenderer` item.
363fn continuation_token_from_item(map: &serde_json::Map<String, Value>) -> Option<String> {
364    map.get("continuationItemRenderer")?
365        .get("continuationEndpoint")?
366        .get("continuationCommand")?
367        .get("token")?
368        .as_str()
369        .map(str::to_string)
370}
371
372#[cfg(test)]
373#[allow(clippy::unwrap_used, clippy::expect_used)]
374mod tests {
375    use super::*;
376    use wiremock::matchers::{body_partial_json, method, path, query_param};
377    use wiremock::{Mock, MockServer, ResponseTemplate};
378
379    const CHANNEL_PAGE: &str = include_str!("fixtures/channel_page.html");
380    const RSS_FEED: &str = include_str!("fixtures/channel_rss.xml");
381    const BROWSE_PAGE1: &str = include_str!("fixtures/browse_videos_page1.json");
382    const BROWSE_PAGE2: &str = include_str!("fixtures/browse_videos_page2.json");
383
384    const CHANNEL_ID: &str = "UC_x5XG1OV2P6uZZ5FSM9Ttw";
385
386    fn http() -> reqwest::Client {
387        reqwest::Client::builder().build().unwrap()
388    }
389
390    // ── pure helpers ──
391
392    #[test]
393    fn is_channel_id_accepts_canonical() {
394        assert!(is_channel_id(CHANNEL_ID));
395        assert!(!is_channel_id("UCshort"));
396        assert!(!is_channel_id("AB_x5XG1OV2P6uZZ5FSM9Ttw")); // wrong prefix
397    }
398
399    #[test]
400    fn channel_id_from_path_extracts_uc() {
401        assert_eq!(
402            channel_id_from_path(&format!("/channel/{CHANNEL_ID}/videos")).as_deref(),
403            Some(CHANNEL_ID)
404        );
405        assert_eq!(channel_id_from_path("/@handle"), None);
406    }
407
408    #[test]
409    fn extract_channel_id_from_fixture() {
410        assert_eq!(extract_channel_id(CHANNEL_PAGE), Some(CHANNEL_ID));
411    }
412
413    #[test]
414    fn extract_channel_id_ignores_other_id_keys() {
415        let body = r#"{"clientId":"x","sessionId":"y"}"#;
416        assert_eq!(extract_channel_id(body), None);
417    }
418
419    #[test]
420    fn parse_rss_returns_entries_newest_first_with_dates() {
421        let entries = parse_rss(RSS_FEED);
422        assert_eq!(entries.len(), 3);
423        assert_eq!(entries[0].id, "aaaaaaaaaaa");
424        assert_eq!(entries[2].id, "ccccccccccc");
425        assert!(entries[0].published.unwrap() > entries[2].published.unwrap());
426    }
427
428    #[test]
429    fn parse_browse_page1_yields_ids_and_token() {
430        let value: Value = serde_json::from_str(BROWSE_PAGE1).unwrap();
431        let (ids, token) = parse_browse_page(&value);
432        // The playlist lockup is filtered out by the contentType guard.
433        assert_eq!(ids, vec!["vid00000001", "vid00000002"]);
434        assert_eq!(token.as_deref(), Some("CONT_TOKEN_1"));
435    }
436
437    #[test]
438    fn parse_browse_page_accepts_legacy_video_renderer() {
439        // Older shape: `videoRenderer.videoId` rather than `lockupViewModel`.
440        let value = json!({
441            "contents": {
442                "richGridRenderer": {
443                    "contents": [
444                        { "richItemRenderer": { "content": {
445                            "videoRenderer": { "videoId": "legacyvid01" } } } },
446                        { "continuationItemRenderer": { "continuationEndpoint": {
447                            "continuationCommand": { "token": "LEGACY_TOK" } } } }
448                    ]
449                }
450            }
451        });
452        let (ids, token) = parse_browse_page(&value);
453        assert_eq!(ids, vec!["legacyvid01"]);
454        assert_eq!(token.as_deref(), Some("LEGACY_TOK"));
455    }
456
457    #[test]
458    fn find_grid_continuation_recurses_past_video_less_arrays() {
459        // Arrays without any video → `contains_video` false → recurse → None.
460        let empty = json!({ "a": [{ "x": 1 }], "b": { "c": [] } });
461        assert_eq!(find_grid_continuation(&empty), None);
462
463        // Outer array's items are wrappers (no direct continuation); the grid
464        // token sits deeper, forcing the recursion fall-through branch.
465        let nested = json!({
466            "outer": [
467                { "wrap": { "richGridRenderer": { "contents": [
468                    { "richItemRenderer": { "content": { "lockupViewModel": {
469                        "contentId": "vidxxxxxxx1",
470                        "contentType": "LOCKUP_CONTENT_TYPE_VIDEO" } } } },
471                    { "continuationItemRenderer": { "continuationEndpoint": {
472                        "continuationCommand": { "token": "DEEP_TOK" } } } }
473                ] } } }
474            ]
475        });
476        assert_eq!(find_grid_continuation(&nested).as_deref(), Some("DEEP_TOK"));
477    }
478
479    #[test]
480    fn parse_browse_page2_is_final() {
481        let value: Value = serde_json::from_str(BROWSE_PAGE2).unwrap();
482        let (ids, token) = parse_browse_page(&value);
483        assert_eq!(ids, vec!["vid00000003"]);
484        assert_eq!(token, None);
485    }
486
487    // ── HTTP layer ──
488
489    #[tokio::test]
490    async fn resolve_channel_id_passthrough_skips_http() {
491        // No mock server: a bare UC id must not trigger any request.
492        let id = resolve_channel_id(&http(), "http://127.0.0.1:1", CHANNEL_ID)
493            .await
494            .unwrap();
495        assert_eq!(id, CHANNEL_ID);
496    }
497
498    #[tokio::test]
499    async fn resolve_channel_id_from_channel_url_skips_http() {
500        let id = resolve_channel_id(
501            &http(),
502            "http://127.0.0.1:1",
503            &format!("https://www.youtube.com/channel/{CHANNEL_ID}"),
504        )
505        .await
506        .unwrap();
507        assert_eq!(id, CHANNEL_ID);
508    }
509
510    #[tokio::test]
511    async fn resolve_channel_id_scrapes_handle() {
512        let server = MockServer::start().await;
513        Mock::given(method("GET"))
514            .and(path("/@google"))
515            .respond_with(ResponseTemplate::new(200).set_body_string(CHANNEL_PAGE))
516            .expect(1)
517            .mount(&server)
518            .await;
519
520        let id = resolve_channel_id(&http(), &server.uri(), "@google")
521            .await
522            .unwrap();
523        assert_eq!(id, CHANNEL_ID);
524    }
525
526    #[tokio::test]
527    async fn resolve_channel_id_scrapes_full_url() {
528        // A full URL whose path isn't `/channel/UC…` is scraped as-is.
529        let server = MockServer::start().await;
530        Mock::given(method("GET"))
531            .and(path("/c/SomeName"))
532            .respond_with(ResponseTemplate::new(200).set_body_string(CHANNEL_PAGE))
533            .expect(1)
534            .mount(&server)
535            .await;
536
537        let input = format!("{}/c/SomeName", server.uri());
538        let id = resolve_channel_id(&http(), &server.uri(), &input)
539            .await
540            .unwrap();
541        assert_eq!(id, CHANNEL_ID);
542    }
543
544    #[tokio::test]
545    async fn resolve_channel_id_surfaces_missing_token() {
546        let server = MockServer::start().await;
547        Mock::given(method("GET"))
548            .and(path("/@nobody"))
549            .respond_with(ResponseTemplate::new(200).set_body_string("<html>no id</html>"))
550            .mount(&server)
551            .await;
552
553        let err = resolve_channel_id(&http(), &server.uri(), "@nobody")
554            .await
555            .unwrap_err();
556        assert!(matches!(err, TranscriptError::ChannelNotFound { .. }));
557    }
558
559    #[tokio::test]
560    async fn fetch_recent_videos_parses_feed() {
561        let server = MockServer::start().await;
562        Mock::given(method("GET"))
563            .and(path("/feeds/videos.xml"))
564            .and(query_param("channel_id", CHANNEL_ID))
565            .respond_with(ResponseTemplate::new(200).set_body_string(RSS_FEED))
566            .expect(1)
567            .mount(&server)
568            .await;
569
570        let entries = fetch_recent_videos(&http(), &server.uri(), CHANNEL_ID)
571            .await
572            .unwrap();
573        assert_eq!(entries.len(), 3);
574        assert_eq!(entries[0].id, "aaaaaaaaaaa");
575    }
576
577    #[tokio::test]
578    async fn fetch_all_video_ids_pages_through_continuation() {
579        let server = MockServer::start().await;
580        // First page: keyed by browseId.
581        Mock::given(method("POST"))
582            .and(path(super::super::innertube::BROWSE_PATH))
583            .and(body_partial_json(json!({ "browseId": CHANNEL_ID })))
584            .respond_with(ResponseTemplate::new(200).set_body_string(BROWSE_PAGE1))
585            .mount(&server)
586            .await;
587        // Continuation page: keyed by the token from page 1.
588        Mock::given(method("POST"))
589            .and(path(super::super::innertube::BROWSE_PATH))
590            .and(body_partial_json(json!({ "continuation": "CONT_TOKEN_1" })))
591            .respond_with(ResponseTemplate::new(200).set_body_string(BROWSE_PAGE2))
592            .mount(&server)
593            .await;
594
595        let ids = fetch_all_video_ids(&http(), &server.uri(), CHANNEL_ID)
596            .await
597            .unwrap();
598        assert_eq!(ids, vec!["vid00000001", "vid00000002", "vid00000003"]);
599    }
600
601    #[tokio::test]
602    async fn fetch_all_video_ids_surfaces_invalid_json() {
603        let server = MockServer::start().await;
604        Mock::given(method("POST"))
605            .and(path(super::super::innertube::BROWSE_PATH))
606            .respond_with(ResponseTemplate::new(200).set_body_string("{ not json"))
607            .mount(&server)
608            .await;
609
610        let err = fetch_all_video_ids(&http(), &server.uri(), CHANNEL_ID)
611            .await
612            .unwrap_err();
613        assert!(matches!(err, TranscriptError::ParseError(_)));
614    }
615
616    // ── Online integration test ──
617    //
618    // Hits real YouTube — gated behind the `online_tests` custom cfg (see the
619    // note in `super`'s tests). Run manually with
620    // `RUSTFLAGS='--cfg online_tests' cargo test online_resolve_and_enumerate`.
621    #[cfg(online_tests)]
622    #[tokio::test]
623    async fn online_resolve_and_enumerate() {
624        const BASE_URL: &str = "https://www.youtube.com";
625        // Google Developers — stable, high-volume, captioned channel.
626        let id = resolve_channel_id(&http(), BASE_URL, "@GoogleDevelopers")
627            .await
628            .unwrap();
629        assert!(is_channel_id(&id));
630
631        let recent = fetch_recent_videos(&http(), BASE_URL, &id).await.unwrap();
632        assert!(!recent.is_empty());
633        assert!(recent.iter().all(|e| e.id.len() == 11));
634
635        // Browse should return at least as many as RSS (full history ≥ recent
636        // 15) and page past the first ~30 via continuation tokens.
637        let all = fetch_all_video_ids(&http(), BASE_URL, &id).await.unwrap();
638        assert!(all.len() >= recent.len());
639        assert!(all.iter().all(|v| v.len() == 11));
640    }
641}