diff --git a/src/youtube/channel.rs b/src/youtube/channel.rs index b387c9c..f4e6e1e 100644 --- a/src/youtube/channel.rs +++ b/src/youtube/channel.rs @@ -87,7 +87,29 @@ pub fn resolve_handle_to_channel_id(url_fragment: &str) -> Result Result { + // First browse — Home tab. Gives us channel header + metadata. YT + // doesn't ship video items here for most channels in 2026. + let home_response = fetch_browse(channel_id, None)?; + let mut info = parse_channel_browse(channel_id, &home_response); + + // Second browse — Videos tab. Best-effort: any failure here just + // leaves recent_videos empty (header still populated from first browse). + if let Ok(videos_response) = fetch_browse(channel_id, Some(CHANNEL_VIDEOS_TAB_PARAMS)) { + info.recent_videos = parse_videos_tab(&videos_response); + if let Some(token) = parse_videos_continuation(&videos_response) { + info.videos_continuation = Some(token); + } + } + Ok(info) +} + +fn fetch_browse(channel_id: &str, params: Option<&str>) -> Result { let downloader = NewPipe::downloader().ok_or(ExtractionError::DownloaderMissing)?; let localization = NewPipe::preferred_localization(); let content_country = NewPipe::preferred_content_country(); @@ -95,6 +117,9 @@ pub fn fetch_channel_browse(channel_id: &str) -> Result Result ChannelInfo { @@ -170,38 +194,188 @@ pub fn parse_channel_browse(channel_id: &str, body: &Value) -> ChannelInfo { info.description = desc.to_string(); } - // First tab's video grid — recent videos. - if let Some(tabs) = body + // Note: recent_videos are populated by a separate second browse to + // the Videos tab — see fetch_channel_browse. The first browse's Home + // tab does NOT contain a clean video grid in current YT. + info +} + +/// Walk the Videos-tab browse response into a list of StreamInfoItems. +/// Handles BOTH old-style `videoRenderer` items and new-style +/// `lockupViewModel` items (YT migrated channel-videos UI to +/// lockupViewModel around 2024). +fn parse_videos_tab(body: &Value) -> Vec { + let mut out = Vec::new(); + let tabs = body .get("contents") .and_then(|c| c.get("twoColumnBrowseResultsRenderer")) .and_then(|c| c.get("tabs")) - .and_then(|t| t.as_array()) - { - for tab in tabs { - let Some(tr) = tab.get("tabRenderer") else { continue }; - if !tr - .get("selected") - .and_then(|s| s.as_bool()) - .unwrap_or(false) - { + .and_then(|t| t.as_array()); + let Some(tabs) = tabs else { return out }; + + for tab in tabs { + let Some(tr) = tab.get("tabRenderer") else { continue }; + if !tr + .get("selected") + .and_then(|s| s.as_bool()) + .unwrap_or(false) + { + continue; + } + let Some(items) = tr + .get("content") + .and_then(|c| c.get("richGridRenderer")) + .and_then(|g| g.get("contents")) + .and_then(|c| c.as_array()) + else { + continue; + }; + for cell in items { + // richItemRenderer carries either videoRenderer (legacy) or + // lockupViewModel (current 2026 YT). + let Some(content) = cell + .get("richItemRenderer") + .and_then(|r| r.get("content")) + else { continue; + }; + if let Some(vr) = content.get("videoRenderer") { + if let Some(item) = crate::youtube::search_extractor::test_helpers::video_renderer_to_item(vr) { + out.push(item); + } + } else if let Some(lvm) = content.get("lockupViewModel") { + if let Some(item) = parse_lockup_video(lvm) { + out.push(item); + } } - if let Some(items) = tr - .get("content") - .and_then(|c| c.get("richGridRenderer")) - .and_then(|g| g.get("contents")) - .and_then(|c| c.as_array()) + } + } + out +} + +fn parse_videos_continuation(body: &Value) -> Option { + let tabs = body + .get("contents") + .and_then(|c| c.get("twoColumnBrowseResultsRenderer")) + .and_then(|c| c.get("tabs")) + .and_then(|t| t.as_array())?; + for tab in tabs { + let Some(tr) = tab.get("tabRenderer") else { continue }; + if !tr.get("selected").and_then(|s| s.as_bool()).unwrap_or(false) { + continue; + } + let items = tr + .get("content") + .and_then(|c| c.get("richGridRenderer")) + .and_then(|g| g.get("contents")) + .and_then(|c| c.as_array())?; + for cell in items { + if let Some(token) = cell + .get("continuationItemRenderer") + .and_then(|s| s.get("continuationEndpoint")) + .and_then(|c| c.get("continuationCommand")) + .and_then(|c| c.get("token")) + .and_then(|t| t.as_str()) { - for cell in items { - if let Some(item) = cell - .get("richItemRenderer") - .and_then(|r| r.get("content")) - .and_then(|c| c.get("videoRenderer")) + return Some(token.to_string()); + } + } + } + None +} + +fn parse_lockup_video(lvm: &Value) -> Option { + // lockupViewModel only carries videos when contentType says so. Skip + // playlists, shorts collections, channel-redirects, etc. + let content_type = lvm.get("contentType").and_then(|v| v.as_str()).unwrap_or(""); + if content_type != "LOCKUP_CONTENT_TYPE_VIDEO" { + return None; + } + let video_id = lvm.get("contentId").and_then(|v| v.as_str())?.to_string(); + if video_id.len() != 11 { + return None; + } + + let lockup_md = lvm + .get("metadata") + .and_then(|m| m.get("lockupMetadataViewModel"))?; + let title = lockup_md + .get("title") + .and_then(|t| t.get("content")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + // metadataRows[0] = ["1.1m views", "2 years ago"]. metadataRows[1] is + // sometimes uploader name (when shown on home/search lockups) but on + // a channel's own Videos tab it's not present (we know the channel). + let mut view_count = -1i64; + let mut upload_relative = String::new(); + let mut uploader_name = String::new(); + if let Some(cmv) = lockup_md + .get("metadata") + .and_then(|m| m.get("contentMetadataViewModel")) + { + if let Some(rows) = cmv.get("metadataRows").and_then(|r| r.as_array()) { + for row in rows { + let Some(parts) = row.get("metadataParts").and_then(|p| p.as_array()) else { + continue; + }; + for part in parts { + let Some(txt) = part + .get("text") + .and_then(|t| t.get("content")) + .and_then(|v| v.as_str()) + else { + continue; + }; + let lc = txt.to_ascii_lowercase(); + if lc.contains("view") && view_count < 0 { + view_count = parse_lockup_view_count(txt); + } else if (lc.contains("ago") + || lc.contains("hour") + || lc.contains("minute") + || lc.contains("yesterday") + || lc.contains("days") + || lc.contains("weeks") + || lc.contains("months") + || lc.contains("years")) + && upload_relative.is_empty() { - if let Some(s) = - crate::youtube::search_extractor::test_helpers::video_renderer_to_item(item) - { - info.recent_videos.push(s); + upload_relative = txt.to_string(); + } else if uploader_name.is_empty() + && !lc.contains("view") + && !lc.contains("ago") + { + uploader_name = txt.to_string(); + } + } + } + } + } + + // duration text lives in a thumbnail overlay badge ("3:14:08") + let mut duration_seconds = 0i64; + if let Some(overlays) = lvm + .get("contentImage") + .and_then(|c| c.get("thumbnailViewModel")) + .and_then(|t| t.get("overlays")) + .and_then(|o| o.as_array()) + { + for ov in overlays { + if let Some(badges) = ov + .get("thumbnailBottomOverlayViewModel") + .and_then(|b| b.get("badges")) + .and_then(|b| b.as_array()) + { + for b in badges { + if let Some(txt) = b + .get("thumbnailBadgeViewModel") + .and_then(|m| m.get("text")) + .and_then(|v| v.as_str()) + { + if txt.contains(':') && duration_seconds == 0 { + duration_seconds = parse_duration_clock(txt); } } } @@ -209,7 +383,65 @@ pub fn parse_channel_browse(channel_id: &str, body: &Value) -> ChannelInfo { } } - info + // thumbnails — sources array, pre-sorted ascending by size + let mut thumbnails = Vec::new(); + if let Some(sources) = lvm + .get("contentImage") + .and_then(|c| c.get("thumbnailViewModel")) + .and_then(|t| t.get("image")) + .and_then(|i| i.get("sources")) + .and_then(|s| s.as_array()) + { + for src in sources { + if let Some(url) = src.get("url").and_then(|v| v.as_str()) { + let h = src.get("height").and_then(|v| v.as_i64()).unwrap_or(-1) as i32; + let w = src.get("width").and_then(|v| v.as_i64()).unwrap_or(-1) as i32; + thumbnails.push(Image::new(url, h, w, ResolutionLevel::from_height(h))); + } + } + } + + Some(StreamInfoItem { + service_id: 0, + url: format!("https://www.youtube.com/watch?v={video_id}"), + name: title, + thumbnails, + uploader_name, + uploader_url: String::new(), + uploader_id: String::new(), + uploader_verified: false, + duration_seconds, + view_count, + upload_date_relative: upload_relative, + stream_type: Some(crate::stream::StreamType::VideoStream), + short_description: String::new(), + }) +} + +fn parse_lockup_view_count(text: &str) -> i64 { + // "1.1m views" / "23k views" / "5.4b views" / "999 views" + let cleaned = text.to_ascii_lowercase().replace(",", ""); + let cleaned = cleaned.replace(" views", "").replace(" view", ""); + let cleaned = cleaned.trim(); + let (num, mult) = if let Some(n) = cleaned.strip_suffix('k') { + (n.trim(), 1_000.0) + } else if let Some(n) = cleaned.strip_suffix('m') { + (n.trim(), 1_000_000.0) + } else if let Some(n) = cleaned.strip_suffix('b') { + (n.trim(), 1_000_000_000.0) + } else { + (cleaned, 1.0) + }; + num.parse::().map(|n| (n * mult) as i64).unwrap_or(-1) +} + +fn parse_duration_clock(text: &str) -> i64 { + let mut total = 0i64; + for part in text.split(':') { + let n: i64 = part.trim().parse().unwrap_or(0); + total = total * 60 + n; + } + total } fn parse_image_set(value: Option<&Value>) -> ImageSet {