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