omni_dev/transcript/sources/youtube/
channel.rs1use 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
36const CHANNEL_ID_LEN: usize = 24;
38
39const VIDEOS_TAB_PARAMS: &str = "EgZ2aWRlb3PyBgQKAjoA";
43
44#[derive(Clone, Debug, PartialEq, Eq)]
50pub struct VideoEntry {
51 pub id: String,
53 pub published: Option<DateTime<Utc>>,
55}
56
57pub 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 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
104pub 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
126pub 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 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 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
179fn 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
187fn 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
194fn 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
207fn 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
215fn 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
225fn 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
234fn 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
243fn 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
266fn 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
314fn find_grid_continuation(value: &Value) -> Option<String> {
318 match value {
319 Value::Array(arr) => {
320 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
339fn 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
348fn 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
362fn 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 #[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")); }
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 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 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 let empty = json!({ "a": [{ "x": 1 }], "b": { "c": [] } });
461 assert_eq!(find_grid_continuation(&empty), None);
462
463 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 #[tokio::test]
490 async fn resolve_channel_id_passthrough_skips_http() {
491 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 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 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 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 #[cfg(online_tests)]
622 #[tokio::test]
623 async fn online_resolve_and_enumerate() {
624 const BASE_URL: &str = "https://www.youtube.com";
625 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 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}