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 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
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 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
123pub 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 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 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
176fn 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
184fn 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
191fn 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
204fn 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
212fn 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
222fn 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
231fn 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
240fn 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
263fn 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
311fn find_grid_continuation(value: &Value) -> Option<String> {
315 match value {
316 Value::Array(arr) => {
317 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
336fn 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
345fn 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
359fn 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 #[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")); }
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 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 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 let empty = json!({ "a": [{ "x": 1 }], "b": { "c": [] } });
458 assert_eq!(find_grid_continuation(&empty), None);
459
460 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 #[tokio::test]
487 async fn resolve_channel_id_passthrough_skips_http() {
488 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 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 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 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 #[cfg(online_tests)]
619 #[tokio::test]
620 async fn online_resolve_and_enumerate() {
621 const BASE_URL: &str = "https://www.youtube.com";
622 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 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}