From e6fbbb79b48f60fdfb6dc392e7562602ccb5e495 Mon Sep 17 00:00:00 2001 From: Kayos Date: Sun, 24 May 2026 20:06:43 -0700 Subject: [PATCH] channel: second-browse to Videos tab + parse lockupViewModel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found via emulator smoke that channelInfo was returning empty recent_videos list, breaking the subscriptions feed. Two root causes: 1. First browse of a channel by browseId lands on the HOME tab in 2026 YT, not Videos. Home uses sectionListRenderer, not the richGridRenderer my parser expected. The Videos tab in the response carries an empty content block (you need a SECOND browse with the params token to populate it). 2. Channel video items on the Videos tab migrated from videoRenderer to lockupViewModel (YT made the switch ~2024). My old parser only handled videoRenderer. Fix: * fetch_channel_browse now does TWO browses — first for Home (header + metadata), second with params='EgZ2aWRlb3PyBgQKAjoA' for the Videos tab. Same magic constant NPE uses (audit Track A §2.4). * parse_videos_tab handles BOTH videoRenderer (legacy/fallback) AND lockupViewModel (current). lockupViewModel parse extracts: - contentId → video ID - metadata.lockupMetadataViewModel.title.content → title - metadataRows[].metadataParts[].text.content → view-count ('1.1m views') + relative-age ('2 years ago') + uploader - contentImage.thumbnailViewModel.overlays[] .thumbnailBottomOverlayViewModel.badges[] .thumbnailBadgeViewModel.text → duration ('3:14:08') - contentImage.thumbnailViewModel.image.sources[] → thumbnails * parse_videos_continuation pulls the continuation token from the Videos tab grid for pagination. Second browse is best-effort: if it fails, recent_videos stays empty and the channel header still populates from the first. Verified the YT response shape by probing live channel UCwwtUfy0-CqN50HfaFDzL0w (NCS Spektrem) — got 30+ lockup-style video items with the expected fields. --- src/youtube/channel.rs | 290 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 261 insertions(+), 29 deletions(-) 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 {