tail-fin-youtube 0.7.8

YouTube adapter for tail-fin: search, video, channel, comments, transcript via InnerTube API
Documentation
use serde_json::Value;

use crate::types::Channel;

/// Parse channel info from InnerTube `/youtubei/v1/browse` response.
pub fn parse_channel(data: &Value) -> Option<Channel> {
    let header = data.get("header")?;

    let c4 = header.get("c4TabbedHeaderRenderer");
    let page_header = header
        .get("pageHeaderRenderer")
        .and_then(|p| p.pointer("/content/pageHeaderViewModel"));

    if let Some(h) = c4 {
        let id = h
            .get("channelId")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        let name = h
            .pointer("/title")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        let handle = h
            .pointer("/channelHandleText/runs/0/text")
            .and_then(|v| v.as_str())
            .map(|s| s.to_string());
        let subscribers = h
            .pointer("/subscriberCountText/simpleText")
            .and_then(|v| v.as_str())
            .map(|s| s.to_string());
        let video_count = h
            .pointer("/videosCountText/runs/0/text")
            .and_then(|v| v.as_str())
            .map(|s| s.to_string());

        return Some(Channel {
            url: format!("https://www.youtube.com/channel/{}", id),
            id,
            name,
            handle,
            subscribers,
            video_count,
            description: None,
        });
    }

    if let Some(h) = page_header {
        let name = h
            .pointer("/title/dynamicTextViewModel/text/content")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        let handle = h
            .pointer(
                "/metadata/contentMetadataViewModel/metadataRows/0/metadataParts/0/text/content",
            )
            .and_then(|v| v.as_str())
            .map(|s| s.to_string());
        let subscribers = h
            .pointer(
                "/metadata/contentMetadataViewModel/metadataRows/0/metadataParts/1/text/content",
            )
            .and_then(|v| v.as_str())
            .map(|s| s.to_string());

        let id = data
            .pointer("/metadata/channelMetadataRenderer/externalId")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        let description = data
            .pointer("/metadata/channelMetadataRenderer/description")
            .and_then(|v| v.as_str())
            .map(|s| s.to_string());

        return Some(Channel {
            url: format!("https://www.youtube.com/channel/{}", id),
            id,
            name,
            handle,
            subscribers,
            video_count: None,
            description,
        });
    }

    None
}

/// Parse subscriptions from InnerTube `/youtubei/v1/browse` response (browseId=FEchannels).
pub fn parse_subscriptions(data: &Value, count: usize) -> Vec<Channel> {
    let mut channels = Vec::new();

    let sections = data
        .pointer("/contents/twoColumnBrowseResultsRenderer/tabs/0/tabRenderer/content/sectionListRenderer/contents")
        .and_then(|v| v.as_array());

    if let Some(sections) = sections {
        for section in sections {
            if channels.len() >= count {
                break;
            }
            let items = section
                .pointer("/itemSectionRenderer/contents/0/shelfRenderer/content/expandedShelfContentsRenderer/items")
                .or_else(|| section.pointer("/itemSectionRenderer/contents"))
                .and_then(|v| v.as_array());
            if let Some(items) = items {
                for item in items {
                    if channels.len() >= count {
                        break;
                    }
                    if let Some(renderer) = item
                        .get("channelRenderer")
                        .or_else(|| item.get("gridChannelRenderer"))
                    {
                        let id = renderer
                            .get("channelId")
                            .and_then(|v| v.as_str())
                            .unwrap_or("")
                            .to_string();
                        let name = renderer
                            .pointer("/title/simpleText")
                            .or_else(|| renderer.pointer("/title/runs/0/text"))
                            .and_then(|v| v.as_str())
                            .unwrap_or("")
                            .to_string();
                        let subscribers = renderer
                            .pointer("/subscriberCountText/simpleText")
                            .and_then(|v| v.as_str())
                            .map(|s| s.to_string());
                        let video_count = renderer
                            .pointer("/videoCountText/runs/0/text")
                            .and_then(|v| v.as_str())
                            .map(|s| s.to_string());

                        channels.push(Channel {
                            url: format!("https://www.youtube.com/channel/{}", id),
                            id,
                            name,
                            handle: None,
                            subscribers,
                            video_count,
                            description: None,
                        });
                    }
                }
            }
        }
    }

    channels
}

#[cfg(test)]
mod tests {
    use super::*;

    // ── parse_channel ─────────────────────────────────────────────────────

    #[test]
    fn test_parse_channel_c4_format() {
        let data = serde_json::json!({
            "header": {
                "c4TabbedHeaderRenderer": {
                    "channelId": "UCuAXFkgsw1L7xaCfnd5JJOw",
                    "title": "Rick Astley",
                    "channelHandleText": { "runs": [{ "text": "@RickAstleyYT" }] },
                    "subscriberCountText": { "simpleText": "4.2M subscribers" }
                }
            }
        });
        let ch = parse_channel(&data).unwrap();
        assert_eq!(ch.id, "UCuAXFkgsw1L7xaCfnd5JJOw");
        assert_eq!(ch.name, "Rick Astley");
        assert_eq!(ch.handle.as_deref(), Some("@RickAstleyYT"));
        assert_eq!(ch.subscribers.as_deref(), Some("4.2M subscribers"));
        assert_eq!(
            ch.url,
            "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw"
        );
    }

    #[test]
    fn test_parse_channel_no_header_returns_none() {
        let data = serde_json::json!({ "header": {} });
        assert!(parse_channel(&data).is_none());
    }

    // ── parse_subscriptions ──────────────────────────────────────────────

    #[test]
    fn test_parse_subscriptions_channel_renderer() {
        let data = serde_json::json!({
            "contents": {
                "twoColumnBrowseResultsRenderer": {
                    "tabs": [{
                        "tabRenderer": {
                            "content": {
                                "sectionListRenderer": {
                                    "contents": [{
                                        "itemSectionRenderer": {
                                            "contents": [{
                                                "channelRenderer": {
                                                    "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw",
                                                    "title": { "simpleText": "Google for Developers" },
                                                    "subscriberCountText": { "simpleText": "2.5M subscribers" },
                                                    "videoCountText": { "runs": [{ "text": "1,200" }] }
                                                }
                                            }]
                                        }
                                    }]
                                }
                            }
                        }
                    }]
                }
            }
        });
        let channels = parse_subscriptions(&data, 10);
        assert_eq!(channels.len(), 1);
        assert_eq!(channels[0].id, "UC_x5XG1OV2P6uZZ5FSM9Ttw");
        assert_eq!(channels[0].name, "Google for Developers");
        assert_eq!(channels[0].subscribers.as_deref(), Some("2.5M subscribers"));
        assert_eq!(channels[0].video_count.as_deref(), Some("1,200"));
        assert_eq!(
            channels[0].url,
            "https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw"
        );
        assert!(channels[0].handle.is_none());
    }

    #[test]
    fn test_parse_subscriptions_shelf_renderer_format() {
        let data = serde_json::json!({
            "contents": {
                "twoColumnBrowseResultsRenderer": {
                    "tabs": [{
                        "tabRenderer": {
                            "content": {
                                "sectionListRenderer": {
                                    "contents": [{
                                        "itemSectionRenderer": {
                                            "contents": [{
                                                "shelfRenderer": {
                                                    "content": {
                                                        "expandedShelfContentsRenderer": {
                                                            "items": [{
                                                                "channelRenderer": {
                                                                    "channelId": "UCxyz",
                                                                    "title": { "runs": [{ "text": "Shelf Channel" }] }
                                                                }
                                                            }]
                                                        }
                                                    }
                                                }
                                            }]
                                        }
                                    }]
                                }
                            }
                        }
                    }]
                }
            }
        });
        let channels = parse_subscriptions(&data, 10);
        assert_eq!(channels.len(), 1);
        assert_eq!(channels[0].id, "UCxyz");
        assert_eq!(channels[0].name, "Shelf Channel");
    }

    #[test]
    fn test_parse_subscriptions_count_limit() {
        let make_channel = |id: &str| {
            serde_json::json!({
                "channelRenderer": {
                    "channelId": id,
                    "title": { "simpleText": "Ch" }
                }
            })
        };
        let data = serde_json::json!({
            "contents": {
                "twoColumnBrowseResultsRenderer": {
                    "tabs": [{
                        "tabRenderer": {
                            "content": {
                                "sectionListRenderer": {
                                    "contents": [{
                                        "itemSectionRenderer": {
                                            "contents": [
                                                make_channel("a"),
                                                make_channel("b"),
                                                make_channel("c")
                                            ]
                                        }
                                    }]
                                }
                            }
                        }
                    }]
                }
            }
        });
        let channels = parse_subscriptions(&data, 2);
        assert_eq!(channels.len(), 2);
    }

    #[test]
    fn test_parse_subscriptions_empty_response() {
        let data = serde_json::json!({});
        assert!(parse_subscriptions(&data, 10).is_empty());
    }
}